Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/flows-list-card-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

Fit printed human flow lists to the terminal width. Tables that do not fit become cards with whole values, a shared pulled-directory prefix, and flow IDs when available. JSON and agent output retain their existing layout.
24 changes: 17 additions & 7 deletions src/commands/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,12 +98,17 @@ export function registerFlowsCommand(
requiredMessage: flowsMessages.list.remoteRequiresEnv,
},
(ctx, env) =>
flowsListRemote(ctx, pattern, {
env,
includeDrafts: opts.includeDrafts,
aiTaskId: opts.aiTaskId,
tags,
}),
flowsListRemote(
ctx,
pattern,
{
env,
includeDrafts: opts.includeDrafts,
aiTaskId: opts.aiTaskId,
tags,
},
{ columns: process.stdout.columns },
),
)(opts, command);
}
// Only an explicitly passed --ai-task-id is a usage error here:
Expand All @@ -124,7 +129,12 @@ export function registerFlowsCommand(
// Without --remote the tags come from the pull cache, so this works
// offline; it cannot validate names against the team's tag list.
return withContext(signals, (ctx) =>
handleFlowsList(ctx, pattern, { tags, env: opts.env }),
handleFlowsList(
ctx,
pattern,
{ tags, env: opts.env },
{ columns: process.stdout.columns },
),
)(opts, command);
},
);
Expand Down
9 changes: 9 additions & 0 deletions src/core/ansi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { displayWidth } from "./displayWidth.js";

// Each style ends with its own reset rather than a full one (`\x1b[0m`), so a
// style inside another — a dimmed cell in a highlighted row — does not switch
// the outer one off.
export const bold = (text: string): string => `\x1b[1m${text}\x1b[22m`;
export const dim = (text: string): string => `\x1b[2m${text}\x1b[22m`;

export const visibleLength = displayWidth;
99 changes: 99 additions & 0 deletions src/domains/flows/list.human.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it, mock } from "bun:test";

import type { CommandContext } from "~/shell/commandContext.js";
import { makeNoopSignals } from "~/shell/signals/createSignalRegistry.fixtures.js";
import type { OutputMode } from "~/shell/ui/env.js";
import { makeNoopLogger } from "~/shell/logger.testUtils.js";
import { makeMemoryFs } from "~/shell/fs.testUtils.js";

import { type FlowsListDeps, flowsList } from "./list.js";
import { callsOf, makeFakeUI } from "~/shell/commandContext.testUtils.js";

const noopSignals = makeNoopSignals();

afterEach(() => {
mock.restore();
});

const fakeCwd = "/proj";

function makeCtx(
ui = makeFakeUI(),
outputMode: OutputMode = "human",
): CommandContext {
return {
ui: { ...ui, mode: outputMode },
configDir: "/tmp/test-config",
outputMode,
isInteractive: false,
apiBaseUrl: "https://example.invalid",
signals: noopSignals,
log: () => makeNoopLogger(),
fs: makeMemoryFs(),
};
}

function makeDeps(overrides?: {
files?: readonly string[];
metaByFile?: Record<string, { name?: string; target?: string }>;
}): FlowsListDeps {
const { files = [], metaByFile = {} } = overrides ?? {};
return {
cwd: fakeCwd,
expandPatterns: mock<FlowsListDeps["expandPatterns"]>(() =>
Promise.resolve([...files]),
),
peekFlowMeta: mock<FlowsListDeps["peekFlowMeta"]>((file: string) =>
Promise.resolve({
name: metaByFile[file]?.name,
target: metaByFile[file]?.target,
}),
),
readCachedFlows: mock<FlowsListDeps["readCachedFlows"]>(() =>
Promise.resolve(new Map()),
),
readEnvLabel: mock<FlowsListDeps["readEnvLabel"]>((dir: string) =>
Promise.resolve(dir),
),
findPulledEnv: mock<FlowsListDeps["findPulledEnv"]>(() =>
Promise.resolve(undefined),
),
listPulledEnvDirs: mock<FlowsListDeps["listPulledEnvDirs"]>(() =>
Promise.resolve([]),
),
};
}

describe("flowsList human mode on a narrow terminal", () => {
it("prints a card per flow instead of a table that would not fit", async () => {
const ui = makeFakeUI();
const deps = makeDeps({
files: ["/proj/src/flows/login.flow.ts"],
metaByFile: {
"/proj/src/flows/login.flow.ts": {
name: "Login",
target: "Web - Chrome",
},
},
});

await flowsList(
{ ...makeCtx(ui, "human"), isInteractive: true },
undefined,
deps,
{ tags: [] },
{ columns: 20 },
);

const output = callsOf(ui.write)
.map((c) => String(c[0]))
.join("");
// oxlint-disable-next-line no-control-regex
const plainOutput = output.replace(/\x1b\[[\d;]*m/g, "");
expect(plainOutput).not.toMatch(/^name\s+target/m);
expect(plainOutput).toMatch(/^Login {2}· {2}Web - Chrome$/m);
expect(plainOutput).toMatch(/^ {2}file\s+src/m);
expect(ui.intro).toHaveBeenCalledWith("Flows");
expect(ui.outro).toHaveBeenCalledWith("1 flow");
});
});
35 changes: 9 additions & 26 deletions src/domains/flows/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import path from "node:path";

import type { CommandContext, CommandResult } from "~/shell/commandContext.js";
import { flowsMessages, runnerMessages } from "~/core/messages/index.js";
import type { CachedFlow } from "./readCachedFlows.js";
import type { BrowserName } from "~/core/types.js";

import { batchMap, flowBatchSize } from "~/core/batchMap.js";
import { matchesSelectors, type FlowSelectors } from "~/core/flowSelectors.js";
Expand All @@ -15,6 +13,10 @@ import {
import { envLabelFor, readEnvLabels } from "./envLabels.js";
import { selectPulledEnv } from "./selectPulledEnv.js";
import { emptySelectionResult, tagsNotCachedResult } from "./selectorGuards.js";
import { renderFlowsList } from "./renderFlowsList.js";
import { type FlowsListItem, toListRow } from "./listItem.js";
import { type ListView, printedView } from "./listView.js";
import type { CachedFlow } from "./readCachedFlows.js";
import { renderListTable } from "./renderListTable.js";

export type FlowsListDeps = {
Expand All @@ -24,7 +26,7 @@ export type FlowsListDeps = {
cwd: string,
) => Promise<string[]>;
readonly peekFlowMeta: PeekFlowMetaFn;
/** What each flows pull recorded, keyed by absolute flow path. */
/** What each flow's pull recorded, keyed by absolute flow path. */
readonly readCachedFlows: (
files: readonly string[],
) => Promise<ReadonlyMap<string, CachedFlow>>;
Expand All @@ -38,25 +40,12 @@ export type FlowsListDeps = {
readonly listPulledEnvDirs: () => Promise<string[]>;
};

type FlowsListItem = {
file: string;
name: string;
flowId: string | undefined;
// The pulled environment the flow came from. Undefined for project flows,
// which belong to no environment.
env: string | undefined;
// Absent when the flow was never pulled, so its tags are unknown rather
// than known to be empty.
tags: readonly string[] | undefined;
target: string | undefined;
browser: BrowserName | undefined;
};

export async function flowsList(
ctx: CommandContext,
pattern: string | undefined,
deps: FlowsListDeps,
selectors: FlowSelectors & { env?: string | undefined } = { tags: [] },
view: ListView = printedView,
): Promise<CommandResult> {
const patterns = pattern ? [pattern] : [];
let files = await deps.expandPatterns(patterns, deps.cwd);
Expand Down Expand Up @@ -95,7 +84,7 @@ export async function flowsList(
name: meta.name ?? flowBasename(file),
flowId: cached.get(file)?.flowId,
env: envLabelFor(file, envLabels),
tags: cachedTags.get(file),
tags: cached.get(file)?.tags,
target: meta.target,
browser: meta.target ? targetToBrowser(meta.target) : undefined,
});
Expand All @@ -114,19 +103,13 @@ export async function flowsList(
ctx.ui.info(runnerMessages.noFlowsMatched);
return;
}
const rows = items.map((it) => ({
name: it.name,
target: it.target ?? "",
env: it.env,
tags: it.tags,
file: it.file,
}));
const rows = items.map(toListRow);
if (ctx.ui.mode === "agent") {
ctx.ui.write(renderListTable(rows, false));
return;
}
ctx.ui.gap();
ctx.ui.intro(flowsMessages.title);
ctx.ui.write(renderListTable(rows, true));
ctx.ui.write(renderFlowsList(rows, { styled: true, columns: view.columns }));
ctx.ui.outro(flowsMessages.flowCount(items.length));
}
5 changes: 4 additions & 1 deletion src/domains/flows/listDefaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,15 @@ import {
makePeekFlowMeta,
} from "./expand.js";
import { flowsList } from "./list.js";
import type { ListView } from "./listView.js";
import { readCachedFlows as defaultReadCachedFlows } from "./readCachedFlows.js";
import { readEnvLabel as defaultReadEnvLabel } from "./readEnvLabel.js";

export function handleFlowsList(
ctx: CommandContext,
pattern: string | undefined,
selectors?: FlowSelectors & { env?: string | undefined },
selectors: FlowSelectors & { env?: string | undefined },
view: ListView,
): Promise<CommandResult> {
const { fs } = ctx;
return flowsList(
Expand All @@ -33,5 +35,6 @@ export function handleFlowsList(
listPulledEnvDirs: () => defaultListPulledEnvDirs(process.cwd(), fs),
},
selectors,
view,
);
}
28 changes: 28 additions & 0 deletions src/domains/flows/listItem.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import type { BrowserName } from "~/core/types.js";

import type { FlowsListRow } from "./renderListTable.js";

/** One flow as `flows list` reports it; `--json` emits these verbatim. */
export type FlowsListItem = {
file: string;
name: string;
// Undefined for project flows and pulls made before IDs were recorded.
flowId: string | undefined;
// The pulled environment the flow came from. Undefined for project flows,
// which belong to no environment.
env: string | undefined;
// Absent when the flow was never pulled, so its tags are unknown rather
// than known to be empty.
tags: readonly string[] | undefined;
target: string | undefined;
browser: BrowserName | undefined;
};

export const toListRow = (it: FlowsListItem): FlowsListRow => ({
name: it.name,
flowId: it.flowId,
target: it.target,
env: it.env,
tags: it.tags,
file: it.file,
});
8 changes: 4 additions & 4 deletions src/domains/flows/listRemote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ describe("flowsListRemote wire call", () => {
});

describe("flowsListRemote success paths", () => {
it("renders a bolded header + name|target|tags|file rows in human mode", async () => {
it("renders IDs alongside the other fields in human mode", async () => {
const { ui } = await run({ mode: "human" });

const output = callsOf(ui.write)
Expand All @@ -129,13 +129,13 @@ describe("flowsListRemote success paths", () => {
expect(lines[0]).toMatch(/\[0m/);
// oxlint-disable-next-line no-control-regex, @typescript-eslint/no-non-null-assertion
expect(lines[0]!.replace(/\x1b[^m]*m/g, "")).toMatch(
/^name\s+target\s+tags\s+file$/,
/^name\s+id\s+target\s+tags\s+file$/,
);
expect(stripAnsi(lines[1])).toMatch(
/^Login\s+Web - Chrome\s+src\/flows\/login\.flow\.ts$/,
/^Login\s+flow-id-1\s+Web - Chrome\s+src\/flows\/login\.flow\.ts$/,
);
expect(stripAnsi(lines[2])).toMatch(
/^Checkout\s+Web - Firefox\s+smoke\s+src\/flows\/sub\/checkout\.flow\.ts$/,
/^Checkout\s+flow-id-2\s+Web - Firefox\s+smoke\s+src\/flows\/sub\/checkout\.flow\.ts$/,
);
expect(ui.intro).toHaveBeenCalledWith("Remote Flows");
expect(ui.outro).toHaveBeenCalledWith("2 flows");
Expand Down
28 changes: 17 additions & 11 deletions src/domains/flows/listRemote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { flowsMessages, runnerMessages } from "~/core/messages/index.js";
import { matchesSelectors } from "~/core/flowSelectors.js";

import { fetchKnownTags } from "./fetchKnownTags.js";
import { renderListTable } from "./renderListTable.js";
import { renderFlowsList } from "./renderFlowsList.js";
import { renderListTable, type FlowsListRow } from "./renderListTable.js";
import { type ListView, printedView } from "./listView.js";
import { emptySelectionResult } from "./selectorGuards.js";

type RemoteListItem = {
Expand All @@ -22,6 +24,17 @@ type RemoteListItem = {
url: string;
};

const toListRow = (it: RemoteListItem): FlowsListRow => ({
name: it.name,
flowId: it.flowId,
target: it.target,
// A remote listing is scoped to one environment by definition, so the env
// column would repeat the --env value on every row.
env: undefined,
tags: it.tags,
file: it.file,
});

export type FlowsListRemoteOptions = {
readonly env: string;
readonly includeDrafts: boolean;
Expand All @@ -33,6 +46,7 @@ export async function flowsListRemote(
ctx: AuthCommandContext,
pattern: string | undefined,
options: FlowsListRemoteOptions,
view: ListView = printedView,
): Promise<CommandResult> {
const result = await ctx.platformClient.callPublicApi(
publicContractsV1.flow.list,
Expand Down Expand Up @@ -77,21 +91,13 @@ export async function flowsListRemote(
ctx.ui.info(runnerMessages.noFlowsMatched);
return;
}
const rows = items.map((it) => ({
name: it.name,
target: it.target,
// A remote listing is scoped to one environment by definition, so the env
// column would repeat the --env value on every row.
env: undefined,
tags: it.tags,
file: it.file,
}));
const rows = items.map(toListRow);
if (ctx.ui.mode === "agent") {
ctx.ui.write(renderListTable(rows, false));
return;
}
ctx.ui.gap();
ctx.ui.intro(flowsMessages.remoteTitle);
ctx.ui.write(renderListTable(rows, true));
ctx.ui.write(renderFlowsList(rows, { styled: true, columns: view.columns }));
ctx.ui.outro(flowsMessages.flowCount(items.length));
}
5 changes: 5 additions & 0 deletions src/domains/flows/listView.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export type ListView = {
readonly columns: number | undefined;
};

export const printedView: ListView = { columns: undefined };
Loading
Loading