Skip to content
Merged
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
2 changes: 1 addition & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"superjson": "2.2.6",
"tar": "7.5.21",
"tinyglobby": "0.2.17",
"typescript": "6.0.3",
"zod": "4.4.3"
},
"devDependencies": {
Expand All @@ -90,7 +91,6 @@
"oxfmt": "0.54.0",
"oxlint": "1.69.0",
"oxlint-tsgolint": "0.23.0",
"typescript": "6.0.3",
"webdriverio": "9.27.1"
},
"engines": {
Expand Down
22 changes: 14 additions & 8 deletions skills/qawolf-cli/references/runner.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,14 +153,20 @@ so the snippet can use your own page objects and helpers.

## Running a flow

`qawolf runner run <flowFile>` ships the current directory's runnable files with the
request. The runner holds no copy of your project, so what runs is exactly what
is on disk at that moment, uncommitted edits included. A `package.json` has to
be there, since the run reads its npm dependencies from it, and the files may
carry at most 30 MiB in total: run from a directory holding the flow and what it
imports rather than from the root of a large monorepo. A missing file, a missing
`package.json` and files over the cap are all refused before any runner is
resolved or launched, so a typo costs nothing.
`qawolf runner run <flowFile>` ships the flow file, everything it imports, and
your `package.json` and `tsconfig.json`. Nothing else travels, so you can run
from the root of a large project without sending it. The runner holds no copy of
your project, so what runs is exactly what is on disk at that moment,
uncommitted edits included.

Imports are followed the same way a run from the QA Wolf app follows them:
relative paths and `tsconfig.json` path aliases, resolving `.ts` and `.js`. An
`export ... from` re-export is not followed, and neither is `require()`, so a
barrel file does not pull in what it re-exports. A `package.json` has to be
there, since the run reads its npm dependencies from it, and the files may carry
at most 30 MiB in total. A missing file, a missing `package.json` and files over
the cap are all refused before any runner is resolved or launched, so a typo
costs nothing.

The call answers with a run id as soon as the run is accepted. **The outcome is
not in that answer**, it is in the `run-status` stream, whose entries carry
Expand Down
10 changes: 5 additions & 5 deletions src/domains/interactiveRunner/collectFiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,24 @@ import { collectRunFiles } from "./collectFiles.js";
import { makeTestDeps } from "./deps.testUtils.js";

describe("collectRunFiles", () => {
it("collects what the working directory ships", async () => {
const collected = await collectRunFiles(makeTestDeps());
it("collects what the flow reaches", async () => {
const collected = await collectRunFiles(makeTestDeps(), ["flow.ts"]);

expect(collected).toEqual({
files: { "flow.ts": "export default {};", "package.json": "{}" },
ok: true,
unresolvedImports: [],
});
});

// Every shippable file under the working directory is read, so a broken symlink
// named foo.ts or a file the current user cannot open stops the run. Naming the
// path is what turns it into something to fix.
it("names the file it could not read", async () => {
const unreadable = Object.assign(new Error("EACCES: permission denied"), {
path: "/workspace/flows/locked.ts",
});

const collected = await collectRunFiles(
makeTestDeps({ collectRunFiles: () => Promise.reject(unreadable) }),
["flow.ts"],
);

expect(collected.ok).toBe(false);
Expand All @@ -34,6 +33,7 @@ describe("collectRunFiles", () => {
it("still reports a failure that names no path", async () => {
const collected = await collectRunFiles(
makeTestDeps({ collectRunFiles: () => Promise.reject(Error("EMFILE")) }),
["flow.ts"],
);

expect(collected.ok).toBe(false);
Expand Down
14 changes: 7 additions & 7 deletions src/domains/interactiveRunner/collectFiles.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,21 @@
import type { RunFiles } from "@qawolf/api-contracts/v1";
import type { CollectedRunFiles } from "~/shell/interactiveRunner/collectRunFiles.js";

import { errorMessage } from "~/core/errors.js";
import { interactiveRunnerMessages } from "~/core/messages/index.js";

import type { InteractiveRunnerDeps } from "./deps.js";

/**
* Every shippable file under the working directory is read, so any one of them
* being unreadable stops the run — a file the current user cannot open, or one
* deleted mid-walk. Caught here rather than left to the catch-all, which exits 1
* and tells CI the flow failed.
* A file the graph reaches but the current user cannot open, or one deleted
* mid-walk, stops the run here rather than at the catch-all, which exits 1 and
* tells CI the flow failed.
*/
export async function collectRunFiles(
deps: InteractiveRunnerDeps,
): Promise<{ ok: true; files: RunFiles } | { ok: false; error: string }> {
roots: readonly string[],
): Promise<({ ok: true } & CollectedRunFiles) | { ok: false; error: string }> {
try {
return { files: await deps.collectRunFiles(), ok: true };
return { ...(await deps.collectRunFiles(roots)), ok: true };
} catch (error) {
const path = readErrorPath(error);
return {
Expand Down
2 changes: 1 addition & 1 deletion src/domains/interactiveRunner/deps.testUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export function makeTestDeps(
},
};
return {
collectRunFiles: async () => files,
collectRunFiles: async () => ({ files, unresolvedImports: [] }),
cwd: testCwd,
env: {},
makeRunnerId: () => "cli-minted",
Expand Down
13 changes: 7 additions & 6 deletions src/domains/interactiveRunner/deps.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import type { RunFiles } from "@qawolf/api-contracts/v1";

import { sleep as defaultSleep } from "~/core/sleep.js";
import type { Fs } from "~/shell/fs.js";
import { collectRunFiles } from "~/shell/interactiveRunner/collectRunFiles.js";
import {
type CollectedRunFiles,
collectRunFiles,
} from "~/shell/interactiveRunner/collectRunFiles.js";
import { makeRunnerId } from "~/shell/interactiveRunner/makeRunnerId.js";
import {
type RunnerStore,
Expand All @@ -20,7 +21,7 @@ import { readStdin } from "~/shell/stdin.js";
* input it chose and a sleep that does not wait.
*/
export type InteractiveRunnerDeps = {
collectRunFiles: () => Promise<RunFiles>;
collectRunFiles: (roots: readonly string[]) => Promise<CollectedRunFiles>;
cwd: string;
env: Record<string, string | undefined>;
makeRunnerId: () => string;
Expand All @@ -40,8 +41,8 @@ export function makeInteractiveRunnerDeps(options: {
fs: Fs;
}): InteractiveRunnerDeps {
return {
collectRunFiles: () =>
collectRunFiles({ cwd: options.cwd, fs: options.fs }),
collectRunFiles: (roots) =>
collectRunFiles({ cwd: options.cwd, fs: options.fs, roots }),
cwd: options.cwd,
env: options.env,
makeRunnerId,
Expand Down
5 changes: 4 additions & 1 deletion src/domains/interactiveRunner/evaluateSnippet.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,10 @@ describe("handleRunnerExec", () => {
ctx,
{ contextFile: "flow.ts", runner: "ci", source: "-" },
makeTestDeps({
collectRunFiles: async () => ({ "flow.ts": "export default {};" }),
collectRunFiles: async () => ({
files: { "flow.ts": "export default {};" },
unresolvedImports: [],
}),
readStdin: async () => "await signIn()",
}),
);
Expand Down
2 changes: 1 addition & 1 deletion src/domains/interactiveRunner/evaluateSnippet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ async function resolveScope(
return { filePath: undefined, files: undefined, ok: true };
}
const filePath = toCollectedPath(deps.cwd, contextFile);
const files = await deps.collectRunFiles();
const { files } = await deps.collectRunFiles([filePath]);
const check = checkSnippetFiles(files, filePath);
if (check.type !== "ok") {
return { error: describeRunFilesCheck(check), ok: false };
Expand Down
5 changes: 4 additions & 1 deletion src/domains/interactiveRunner/importPackage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,10 @@ const manifest = JSON.stringify({

function depsWithManifest(content = manifest) {
return makeTestDeps({
collectRunFiles: async () => ({ "package.json": content }),
collectRunFiles: async () => ({
files: { "package.json": content },
unresolvedImports: [],
}),
readFile: async () => content,
});
}
Expand Down
16 changes: 11 additions & 5 deletions src/domains/interactiveRunner/prepareRun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,16 @@ export async function prepareRun(
},
deps: InteractiveRunnerDeps,
): Promise<PreparedRun> {
const collected = await collectRunFiles(deps);
const linesFilePath =
options.linesFile === undefined
? undefined
: toCollectedPath(deps.cwd, options.linesFile);
const roots =
linesFilePath === undefined || linesFilePath === options.entryPointPath
? [options.entryPointPath]
: [options.entryPointPath, linesFilePath];

const collected = await collectRunFiles(deps, roots);
if (!collected.ok) return refused(collected.error, exitCodes.config);

const files = collected.files;
Expand All @@ -71,10 +80,7 @@ export async function prepareRun(
);
}

const path =
options.linesFile === undefined
? options.entryPointPath
: toCollectedPath(deps.cwd, options.linesFile);
const path = linesFilePath ?? options.entryPointPath;
if (!Object.hasOwn(files, path)) {
return refused(
interactiveRunnerMessages.fileNotCollected(path),
Expand Down
7 changes: 5 additions & 2 deletions src/domains/interactiveRunner/runFlow.environment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,11 @@ describe("handleRunnerRun with --env-file", () => {
const envFileDeps = (content: string) =>
makeTestDeps({
collectRunFiles: async () => ({
"flow.ts": "export default {};",
"package.json": "{}",
files: {
"flow.ts": "export default {};",
"package.json": "{}",
},
unresolvedImports: [],
}),
readFile: async (path) => {
if (basename(path) === ".env") return content;
Expand Down
86 changes: 86 additions & 0 deletions src/domains/interactiveRunner/runFlow.files.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, it } from "bun:test";

import { makeAuthCtx, makeTestDeps } from "./deps.testUtils.js";
import { handleRunnerRun } from "./runFlow.js";

describe("handleRunnerRun file refusals", () => {
it("refuses a file that is not among the ones that travel, without a request", async () => {
const { callPublicApi, ctx } = makeAuthCtx();

const result = await handleRunnerRun(
ctx,
{
entryPoint: "flows/missing.ts",
follow: false,
envFile: undefined,
lines: undefined,
linesFile: undefined,
runner: "ci",
timeout: undefined,
logs: false,
recorderEvents: false,
runEvents: false,
},
makeTestDeps(),
);

expect(result?.error).toContain("flows/missing.ts");
expect(result?.exitCode).toBe(2);
expect(callPublicApi).not.toHaveBeenCalled();
});

it("refuses a directory carrying no package.json, without a request", async () => {
const { callPublicApi, ctx } = makeAuthCtx();

const result = await handleRunnerRun(
ctx,
{
entryPoint: "flow.ts",
follow: false,
envFile: undefined,
lines: undefined,
linesFile: undefined,
runner: "ci",
timeout: undefined,
logs: false,
recorderEvents: false,
runEvents: false,
},
makeTestDeps({
collectRunFiles: async () => ({
files: { "flow.ts": "export default {};" },
unresolvedImports: [],
}),
}),
);

expect(result?.error).toContain("package.json");
expect(callPublicApi).not.toHaveBeenCalled();
});

it("refuses a misspelled flow without launching a runner", async () => {
const { callPublicApi, ctx } = makeAuthCtx();
const deps = makeTestDeps();

const result = await handleRunnerRun(
ctx,
{
entryPoint: "flows/chekcout.flow.ts",
follow: false,
envFile: undefined,
lines: undefined,
linesFile: undefined,
runner: undefined,
timeout: undefined,
logs: false,
recorderEvents: false,
runEvents: false,
},
deps,
);

expect(result?.exitCode).toBe(2);
expect(callPublicApi).not.toHaveBeenCalled();
expect(await deps.store.readDefaultRunnerId()).toBeUndefined();
});
});
9 changes: 6 additions & 3 deletions src/domains/interactiveRunner/runFlow.selection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,12 @@ async function runWith(options: {
},
makeTestDeps({
collectRunFiles: async () => ({
"flow.ts": "export default {};",
"package.json": "{}",
"pages/login.ts": "export const login = () => {};",
files: {
"flow.ts": "export default {};",
"package.json": "{}",
"pages/login.ts": "export const login = () => {};",
},
unresolvedImports: [],
}),
}),
);
Expand Down
Loading
Loading