Skip to content
Closed
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/qawolf-lint-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

Add `qawolf flows lint [pattern]` to lint source files with QA Wolf's rules, honoring the repo's `.eslintrc.json`. It lints every `.ts` and `.js` file in the project when the pattern is omitted — flows, helpers, and page objects alike, skipping generated output such as `dist/` and `coverage/` — exits 1 when a file has a lint error or could not be read, and 0 when every file is clean or only has warnings.
205 changes: 158 additions & 47 deletions .oxlintrc.json

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@
"@qawolf/flow-targets": "1.0.0",
"@qawolf/flows": "0.1.4",
"@qawolf/testkit": "1.1.1",
"@qawolf/workflow-linter": "1.0.0",
"commander": "14.0.3",
"env-paths": "4.0.0",
"picomatch": "4.0.4",
Expand Down
1 change: 1 addition & 0 deletions skills/qawolf-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,7 @@ that `url`; never guess a route and never send a repository link in its place.
| `qawolf environment update` | write | Update an environment owned by the caller's team and return it in the environment.get shape. Omitted fields remain unchanged. |
| `qawolf flow addTag` | write | Assign an existing tag to the selected flows. Create tags with tag.create. |
| `qawolf flow update` | write | Move a flow between draft and active readiness. The other statuses shown in the app are derived and cannot be set. |
| `qawolf flows lint` | local | Lint source files matching [pattern], or every .ts/.js file when omitted, with QA Wolf's rules, honoring the project's .eslintrc.json |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this belong on the flows namespace?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not even sure if we need this, if we publish our own rules and add them to the .eslintrc.json as a package, users can use eslint command directly instead of going throught our cli

| `qawolf flows list` | local (read with --remote) | List flows matching [pattern] from the local project, or from a QA Wolf environment with --remote |
| `qawolf flows pull` | read | Download an environment's flows into the local .qawolf/<env>/ cache |
| `qawolf flows run` | local (read with --env) | Run flows matching [pattern], or every flow when omitted; with --env, pull missing flows from that QA Wolf environment |
Expand Down
3 changes: 3 additions & 0 deletions src/commands/__snapshots__/help.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,9 @@ Commands:
Wolf environment
list [options] [pattern] List flows matching [pattern] from the local
project, or from a QA Wolf environment with --remote
lint [options] [pattern] Lint source files matching [pattern], or every
.ts/.js file when omitted, with QA Wolf's rules,
honoring the project's .eslintrc.json
pull [options] Download an environment's flows into the local
.qawolf/<env>/ cache
help [command] display help for command
Expand Down
2 changes: 2 additions & 0 deletions src/commands/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js";

import { handleFlowsList } from "~/domains/flows/listDefaults.js";
import { flowsListRemote } from "~/domains/flows/listRemote.js";
import { registerFlowsLintCommand } from "./lint.register.js";
import { registerFlowsPullCommand } from "./pull.register.js";
import { registerFlowsRunCommand } from "./run.register.js";
import { registerRunWorkerCommand } from "./runWorker.register.js";
Expand Down Expand Up @@ -105,5 +106,6 @@ export function registerFlowsCommand(
},
);

registerFlowsLintCommand(flows, signals);
registerFlowsPullCommand(flows, signals);
}
40 changes: 40 additions & 0 deletions src/commands/flows/lint.register.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { Command } from "commander";

import { declareCommandKind } from "~/commands/commandKind.js";
import { withContext } from "~/commands/context.js";
import type { SignalRegistry } from "~/shell/signals/createSignalRegistry.js";

import { type FlowsLintFlags, handleFlowsLint } from "./lintDefaults.js";

const lintExamples = `
Examples:
$ qawolf flows lint
$ qawolf flows lint "flows/checkout/**"
$ qawolf flows lint "src/pages/**/*.ts"
$ qawolf flows lint flows/login.flow.ts

Exits 1 when a file has a lint error, and 0 when every file is clean or only
has warnings.`;

export function registerFlowsLintCommand(
flows: Command,
signals: SignalRegistry,
): void {
declareCommandKind(flows.command("lint [pattern]"), "local")
.description(
"Lint source files matching [pattern], or every .ts/.js file when omitted, with QA Wolf's rules, honoring the project's .eslintrc.json",
)
.option(
"--allow-no-match",
"Exit 0 instead of 2 when the pattern selects no lintable file",
false,
)
.addHelpText("after", lintExamples)
.action(
(pattern: string | undefined, opts: FlowsLintFlags, command: Command) => {
return withContext(signals, (ctx) =>
handleFlowsLint(ctx, pattern, opts),
)(opts, command);
},
);
}
224 changes: 224 additions & 0 deletions src/commands/flows/lintDefaults.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
import { afterEach, describe, expect, it } from "bun:test";
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";

import { makeCtx } from "~/shell/commandContext.testUtils.js";
import { exitCodes } from "~/shell/exit.js";
import { makeDefaultFs } from "~/shell/fs.js";
import { makeTmpDirTracker } from "~/shell/tmpDir.testUtils.js";

import { handleFlowsLint } from "./lintDefaults.js";

const tracker = makeTmpDirTracker("qawolf-flows-lint-test-");

afterEach(() => tracker.cleanup());

const brokenFlow = "const value: any = 1;\nexport const doubled = value * 2;\n";
const cleanFlow = "export const greeting = `hello`;\n";

async function inProject(
filesByPath: Record<string, string>,
run: () => Promise<void>,
): Promise<void> {
const project = await tracker.makeTmpDir();
await writeFile(join(project, "package.json"), "{}");
await Promise.all(
Object.entries(filesByPath).map(async ([filePath, content]) => {
const absolutePath = join(project, filePath);
await mkdir(join(absolutePath, ".."), { recursive: true });
await writeFile(absolutePath, content);
}),
);
const previousCwd = process.cwd();
process.chdir(project);
try {
await run();
} finally {
process.chdir(previousCwd);
}
}

function writtenText(ctx: ReturnType<typeof makeCtx>): string {
return (ctx.ui.write as unknown as { mock: { calls: string[][] } }).mock.calls
.map((call) => call[0])
.join("");
}

describe("handleFlowsLint", () => {
it("lints every source file in the project when no pattern is given", async () => {
await inProject(
{
"flows/broken.flow.ts": brokenFlow,
"flows/nested/also-broken.flow.ts": brokenFlow,
"helpers/not-a-flow.ts": brokenFlow,
"src/pages/LoginPage.ts": brokenFlow,
"data/fixture.json": '{ "value": 1 }\n',
"node_modules/dep/dep.flow.ts": brokenFlow,
},
async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, undefined, {
allowNoMatch: false,
});

expect(result).toEqual({
error: "4 lint errors found",
exitCode: exitCodes.testFailure,
});
const output = writtenText(ctx);
expect(output).toContain("flows/broken.flow.ts");
expect(output).toContain(
join("flows", "nested", "also-broken.flow.ts"),
);
expect(output).toContain(join("helpers", "not-a-flow.ts"));
expect(output).toContain(join("src", "pages", "LoginPage.ts"));
expect(output).not.toContain("fixture.json");
expect(output).not.toContain("dep.flow.ts");
},
);
});

it("ignores the files a pattern matches that are not lintable", async () => {
await inProject(
{
"flows/broken.flow.ts": brokenFlow,
"flows/fixture.json": '{ "value": 1 }\n',
},
async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, "flows/*", {
allowNoMatch: false,
});

expect(result).toEqual({
error: "1 lint error found",
exitCode: exitCodes.testFailure,
});
expect(writtenText(ctx)).not.toContain("fixture.json");
},
);
});

it("treats a pattern that matches only unlintable files as no match", async () => {
await inProject(
{
"flows/broken.flow.ts": brokenFlow,
"data/fixture.json": '{ "value": 1 }\n',
},
async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, "data/**", {
allowNoMatch: false,
});

expect(result).toEqual({
error:
"No lintable source files matched 'data/**'. Pass --allow-no-match to exit 0 instead.",
exitCode: exitCodes.invalidArgs,
});
expect(ctx.ui.write).not.toHaveBeenCalled();
},
);
});

it("lints only the files a pattern selects", async () => {
await inProject(
{
"flows/checkout/pay.flow.ts": brokenFlow,
"flows/login.flow.ts": brokenFlow,
},
async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, "flows/checkout/**", {
allowNoMatch: false,
});

expect(result).toEqual({
error: "1 lint error found",
exitCode: exitCodes.testFailure,
});
expect(writtenText(ctx)).not.toContain("login.flow.ts");
},
);
});

it("succeeds without output when every file is clean", async () => {
await inProject({ "flows/clean.flow.ts": cleanFlow }, async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, undefined, {
allowNoMatch: false,
});

expect(result).toBeUndefined();
expect(ctx.ui.write).not.toHaveBeenCalled();
});
});

it("succeeds when a file only has warnings", async () => {
await inProject(
{
".eslintrc.json": JSON.stringify({
rules: { "@typescript-eslint/no-explicit-any": "warn" },
}),
"flows/broken.flow.ts": brokenFlow,
},
async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, undefined, {
allowNoMatch: false,
});

expect(result).toBeUndefined();
expect(writtenText(ctx)).toContain("1 problem (0 errors, 1 warning)");
},
);
});

it("emits the report as data in json mode", async () => {
await inProject({ "flows/broken.flow.ts": brokenFlow }, async () => {
const ctx = makeCtx("json", { fs: makeDefaultFs() });

await handleFlowsLint(ctx, undefined, { allowNoMatch: false });

expect(ctx.ui.json).toHaveBeenCalledTimes(1);
expect(ctx.ui.write).not.toHaveBeenCalled();
});
});

it("fails when the pattern selects no file", async () => {
await inProject({ "flows/clean.flow.ts": cleanFlow }, async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, "flows/checkout/**", {
allowNoMatch: false,
});

expect(result).toEqual({
error:
"No lintable source files matched 'flows/checkout/**'. Pass --allow-no-match to exit 0 instead.",
exitCode: exitCodes.invalidArgs,
});
});
});

it("succeeds on no match with --allow-no-match", async () => {
await inProject({ "flows/clean.flow.ts": cleanFlow }, async () => {
const ctx = makeCtx("human", { fs: makeDefaultFs() });

const result = await handleFlowsLint(ctx, "flows/checkout/**", {
allowNoMatch: true,
});

expect(result).toBeUndefined();
expect(ctx.ui.info).toHaveBeenCalledWith(
"No lintable source files matched.",
);
});
});
});
80 changes: 80 additions & 0 deletions src/commands/flows/lintDefaults.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { lintMessages } from "~/core/messages/index.js";
import { buildPatternArgs } from "~/core/patternArgs.js";
import { pluralize } from "~/core/pluralize.js";
import { resolveProjectDirSafe } from "~/domains/flows/ensureDeps.js";
import { expandPatterns as defaultExpandPatterns } from "~/domains/flows/expand.js";
import { lintFiles as defaultLintFiles } from "~/domains/lint/lintFiles.js";
import {
lintablePattern,
selectLintableFiles,
} from "~/domains/lint/selectLintableFiles.js";
import { renderLintReport } from "~/domains/lint/renderLintReport.js";
import { noMatchResult } from "~/domains/runner/noMatch.js";
import type { CommandContext, CommandResult } from "~/shell/commandContext.js";
import { exitCodes } from "~/shell/exit.js";
import type { Fs } from "~/shell/fs.js";
import type { Logger } from "~/shell/logger.js";

export type FlowsLintFlags = { readonly allowNoMatch: boolean };

export type HandleFlowsLintDeps = {
expandPatterns: (
patterns: string[],
cwd: string,
logger?: Logger,
) => Promise<string[]>;
lintFiles: typeof defaultLintFiles;
};

function makeDefaultDeps(fs: Fs): HandleFlowsLintDeps {
return {
expandPatterns: (patterns, cwd, logger) =>
defaultExpandPatterns(patterns, cwd, logger, fs),
lintFiles: defaultLintFiles,
};
}

export async function handleFlowsLint(
ctx: CommandContext,
pattern: string | undefined,
flags: FlowsLintFlags,
deps?: HandleFlowsLintDeps,
): Promise<CommandResult> {
const resolvedDeps = deps ?? makeDefaultDeps(ctx.fs);
const cwd = process.cwd();

const matched = await resolvedDeps.expandPatterns(
buildPatternArgs(pattern ?? lintablePattern),
cwd,
ctx.log("flows"),
);
const files = selectLintableFiles(matched, cwd);
if (files.length === 0) {
return noMatchResult(ctx, {
allowNoMatch: flags.allowNoMatch,
error: lintMessages.noFilesMatchedPattern(pattern),
notice: lintMessages.noFilesMatched,
});
}

const report = await resolvedDeps.lintFiles({
cwd,
filePaths: files,
fs: ctx.fs,
projectDir: resolveProjectDirSafe([...files], ctx.fs),
});
renderLintReport(ctx.ui, report);

if (report.errorCount > 0) {
return {
error: `${pluralize(report.errorCount, "lint error")} found`,
exitCode: exitCodes.testFailure,
};
}
if (report.unreadablePaths.length > 0) {
return {
error: lintMessages.unreadableFiles(report.unreadablePaths.length),
exitCode: exitCodes.testFailure,
};
}
}
Loading
Loading