diff --git a/bun.lock b/bun.lock index f40498975..1285f80ed 100644 --- a/bun.lock +++ b/bun.lock @@ -21,6 +21,7 @@ "superjson": "2.2.6", "tar": "7.5.21", "tinyglobby": "0.2.17", + "typescript": "6.0.3", "zod": "4.4.3", }, "devDependencies": { @@ -38,7 +39,6 @@ "oxfmt": "0.54.0", "oxlint": "1.69.0", "oxlint-tsgolint": "0.23.0", - "typescript": "6.0.3", "webdriverio": "9.27.1", }, }, diff --git a/package.json b/package.json index a6d9bcdcf..c4d11ea2c 100644 --- a/package.json +++ b/package.json @@ -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": { @@ -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": { diff --git a/skills/qawolf-cli/references/runner.md b/skills/qawolf-cli/references/runner.md index cb3b5a2b0..7221f45aa 100644 --- a/skills/qawolf-cli/references/runner.md +++ b/skills/qawolf-cli/references/runner.md @@ -153,14 +153,20 @@ so the snippet can use your own page objects and helpers. ## Running a flow -`qawolf runner run ` 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 ` 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 diff --git a/src/domains/interactiveRunner/collectFiles.test.ts b/src/domains/interactiveRunner/collectFiles.test.ts index d0301a922..b12a5e010 100644 --- a/src/domains/interactiveRunner/collectFiles.test.ts +++ b/src/domains/interactiveRunner/collectFiles.test.ts @@ -4,18 +4,16 @@ 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", @@ -23,6 +21,7 @@ describe("collectRunFiles", () => { const collected = await collectRunFiles( makeTestDeps({ collectRunFiles: () => Promise.reject(unreadable) }), + ["flow.ts"], ); expect(collected.ok).toBe(false); @@ -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); diff --git a/src/domains/interactiveRunner/collectFiles.ts b/src/domains/interactiveRunner/collectFiles.ts index ba7301aa9..b853016d9 100644 --- a/src/domains/interactiveRunner/collectFiles.ts +++ b/src/domains/interactiveRunner/collectFiles.ts @@ -1,4 +1,4 @@ -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"; @@ -6,16 +6,16 @@ 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 { diff --git a/src/domains/interactiveRunner/deps.testUtils.ts b/src/domains/interactiveRunner/deps.testUtils.ts index accdd5759..5ef0f3fa2 100644 --- a/src/domains/interactiveRunner/deps.testUtils.ts +++ b/src/domains/interactiveRunner/deps.testUtils.ts @@ -80,7 +80,7 @@ export function makeTestDeps( }, }; return { - collectRunFiles: async () => files, + collectRunFiles: async () => ({ files, unresolvedImports: [] }), cwd: testCwd, env: {}, makeRunnerId: () => "cli-minted", diff --git a/src/domains/interactiveRunner/deps.ts b/src/domains/interactiveRunner/deps.ts index 977891a38..c1aab79cf 100644 --- a/src/domains/interactiveRunner/deps.ts +++ b/src/domains/interactiveRunner/deps.ts @@ -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, @@ -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; + collectRunFiles: (roots: readonly string[]) => Promise; cwd: string; env: Record; makeRunnerId: () => string; @@ -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, diff --git a/src/domains/interactiveRunner/evaluateSnippet.test.ts b/src/domains/interactiveRunner/evaluateSnippet.test.ts index 1587c5837..1ddc1ebb8 100644 --- a/src/domains/interactiveRunner/evaluateSnippet.test.ts +++ b/src/domains/interactiveRunner/evaluateSnippet.test.ts @@ -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()", }), ); diff --git a/src/domains/interactiveRunner/evaluateSnippet.ts b/src/domains/interactiveRunner/evaluateSnippet.ts index f0a83b55a..3616f0bde 100644 --- a/src/domains/interactiveRunner/evaluateSnippet.ts +++ b/src/domains/interactiveRunner/evaluateSnippet.ts @@ -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 }; diff --git a/src/domains/interactiveRunner/importPackage.test.ts b/src/domains/interactiveRunner/importPackage.test.ts index 061509614..2305bc334 100644 --- a/src/domains/interactiveRunner/importPackage.test.ts +++ b/src/domains/interactiveRunner/importPackage.test.ts @@ -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, }); } diff --git a/src/domains/interactiveRunner/prepareRun.ts b/src/domains/interactiveRunner/prepareRun.ts index 3e74ca711..f897398d4 100644 --- a/src/domains/interactiveRunner/prepareRun.ts +++ b/src/domains/interactiveRunner/prepareRun.ts @@ -47,7 +47,16 @@ export async function prepareRun( }, deps: InteractiveRunnerDeps, ): Promise { - 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; @@ -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), diff --git a/src/domains/interactiveRunner/runFlow.environment.test.ts b/src/domains/interactiveRunner/runFlow.environment.test.ts index 37fee1d2b..a0f688939 100644 --- a/src/domains/interactiveRunner/runFlow.environment.test.ts +++ b/src/domains/interactiveRunner/runFlow.environment.test.ts @@ -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; diff --git a/src/domains/interactiveRunner/runFlow.files.test.ts b/src/domains/interactiveRunner/runFlow.files.test.ts new file mode 100644 index 000000000..ff9200b44 --- /dev/null +++ b/src/domains/interactiveRunner/runFlow.files.test.ts @@ -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(); + }); +}); diff --git a/src/domains/interactiveRunner/runFlow.selection.test.ts b/src/domains/interactiveRunner/runFlow.selection.test.ts index 2474ea751..273a37f00 100644 --- a/src/domains/interactiveRunner/runFlow.selection.test.ts +++ b/src/domains/interactiveRunner/runFlow.selection.test.ts @@ -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: [], }), }), ); diff --git a/src/domains/interactiveRunner/runFlow.test.ts b/src/domains/interactiveRunner/runFlow.test.ts index 99daad801..bf6b43464 100644 --- a/src/domains/interactiveRunner/runFlow.test.ts +++ b/src/domains/interactiveRunner/runFlow.test.ts @@ -62,57 +62,6 @@ describe("handleRunnerRun", () => { }); }); - 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 () => ({ "flow.ts": "export default {};" }), - }), - ); - - expect(result?.error).toContain("package.json"); - expect(callPublicApi).not.toHaveBeenCalled(); - }); - // An outcome the CLI does not know must not read as success. it("reports an outcome it does not recognize rather than exiting 0", async () => { const { result } = await runWith({ outcome: "queued" }); @@ -148,32 +97,6 @@ describe("handleRunnerRun", () => { // Checking the files costs nothing and resolving a runner may launch and bill // one, so a misspelled flow name must be answered before any of that. - 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(); - }); - it("announces the runner it had to launch, naming it", async () => { const { callPublicApi, ctx } = makeAuthCtx(); callPublicApi diff --git a/src/shell/interactiveRunner/collectRunFiles.test.ts b/src/shell/interactiveRunner/collectRunFiles.test.ts index baf629aab..bf15df009 100644 --- a/src/shell/interactiveRunner/collectRunFiles.test.ts +++ b/src/shell/interactiveRunner/collectRunFiles.test.ts @@ -15,16 +15,16 @@ import { collectRunFiles } from "./collectRunFiles.js"; // A real directory rather than the in-memory Fs: the walk is tinyglobby's, and // what is under test is exactly which real paths it hands to the predicate. -const roots: string[] = []; +const workspaces: string[] = []; afterEach(() => { - for (const root of roots.splice(0)) + for (const root of workspaces.splice(0)) rmSync(root, { recursive: true, force: true }); }); function makeWorkspace(files: Record): string { const root = mkdtempSync(join(tmpdir(), "qawolf-collect-")); - roots.push(root); + workspaces.push(root); for (const [path, content] of Object.entries(files)) { const absolute = join(root, path); mkdirSync(dirname(absolute), { recursive: true }); @@ -33,100 +33,195 @@ function makeWorkspace(files: Record): string { return root; } -async function collect(files: Record): Promise { - const collected = await collectRunFiles({ +const flowPath = "src/flows/checkout.flow.ts"; + +async function collect( + files: Record, + roots: readonly string[] = [flowPath], +) { + return collectRunFiles({ cwd: makeWorkspace(files), fs: makeDefaultFs(), + roots, }); - return Object.keys(collected); } +const pathsOf = async ( + files: Record, + roots?: readonly string[], +) => Object.keys((await collect(files, roots)).files).sort(); + describe("collectRunFiles", () => { - it("collects source and configuration, with contents", async () => { - const cwd = makeWorkspace({ - "flows/checkout.flow.ts": "export default {};", - "package.json": '{"name":"project"}', - "tsconfig.json": "{}", - }); + it("collects the entry point, what it imports, and the two fixed files", async () => { + expect( + await pathsOf({ + "package.json": "{}", + "src/flows/checkout.flow.ts": 'import "../pages/login";', + "src/pages/login.ts": "export const login = 1;", + "tsconfig.json": "{}", + }), + ).toEqual([ + "package.json", + "src/flows/checkout.flow.ts", + "src/pages/login.ts", + "tsconfig.json", + ]); + }); - const collected = await collectRunFiles({ cwd, fs: makeDefaultFs() }); + it("leaves behind a file the flow does not reach", async () => { + expect( + await pathsOf({ + "package.json": "{}", + "src/flows/checkout.flow.ts": "export default {};", + "src/flows/unrelated.flow.ts": "export default {};", + "src/pages/never.ts": "export const never = 1;", + }), + ).toEqual(["package.json", "src/flows/checkout.flow.ts"]); + }); - expect(collected).toEqual({ - "flows/checkout.flow.ts": "export default {};", - "package.json": '{"name":"project"}', - "tsconfig.json": "{}", - }); + it("follows a tsconfig path alias", async () => { + expect( + await pathsOf({ + "package.json": "{}", + "src/flows/checkout.flow.ts": 'import "~/pages/login";', + "src/pages/login.ts": "export const login = 1;", + "tsconfig.json": '{"compilerOptions":{"paths":{"~/*":["src/*"]}}}', + }), + ).toEqual([ + "package.json", + "src/flows/checkout.flow.ts", + "src/pages/login.ts", + "tsconfig.json", + ]); + }); + + it("terminates on a cycle", async () => { + expect( + await pathsOf({ + "package.json": "{}", + "src/flows/checkout.flow.ts": 'import "../pages/a";', + "src/pages/a.ts": 'import "./b";', + "src/pages/b.ts": 'import "./a";', + }), + ).toEqual([ + "package.json", + "src/flows/checkout.flow.ts", + "src/pages/a.ts", + "src/pages/b.ts", + ]); + }); + + it("walks past the first level", async () => { + expect( + await pathsOf({ + "package.json": "{}", + "src/flows/checkout.flow.ts": 'import "../pages/one";', + "src/pages/one.ts": 'import "./two";', + "src/pages/three.ts": "export const three = 3;", + "src/pages/two.ts": 'import "./three";', + }), + ).toEqual([ + "package.json", + "src/flows/checkout.flow.ts", + "src/pages/one.ts", + "src/pages/three.ts", + "src/pages/two.ts", + ]); }); - it("leaves behind what the travel rule refuses", async () => { + it("resolves an import written with the other supported extension", async () => { expect( - await collect({ - ".env": "SECRET=1", - ".qawolf/staging/cached.ts": "export default {};", - "README.md": "docs", - "flow.ts": "export default {};", - "node_modules/left-pad/index.js": "module.exports = 1;", + await pathsOf({ "package.json": "{}", - "screenshot.png": "binary", + "src/flows/checkout.flow.ts": 'import "../pages/login.js";', + "src/pages/login.ts": "export const login = 1;", }), - ).toEqual(["flow.ts", "package.json"]); + ).toEqual([ + "package.json", + "src/flows/checkout.flow.ts", + "src/pages/login.ts", + ]); }); - it("collects every extension a runner can read", async () => { + it("leaves npm packages to the runner to install", async () => { expect( - await collect({ - "a.cjs": "1", - "b.js": "1", - "c.json": "1", - "d.mjs": "1", - "e.ts": "1", - "f.tsx": "1", + await pathsOf({ + "package.json": "{}", + "src/flows/checkout.flow.ts": + 'import "playwright";\nimport "@qawolf/flows";', }), - ).toEqual(["a.cjs", "b.js", "c.json", "d.mjs", "e.ts", "f.tsx"]); + ).toEqual(["package.json", "src/flows/checkout.flow.ts"]); }); - // Everything else the travel rule refuses is refused by the glob first, so this - // is the case that proves the predicate is what decides. A control character in - // a name is matched by `**/*.ts` and rejected only by `isShippableRunFilePath`. - // - // Windows filenames cannot hold a control character at all, so there the file - // cannot be written to test with. Every other path the predicate alone refuses - // is likewise unwritable there — a backslash separates, `.` and `..` name - // directories — which leaves nothing to assert on that platform. - it.skipIf(process.platform === "win32")( - "leaves behind a path only the predicate refuses", - async () => { - expect( - await collect({ - [`bad${String.fromCharCode(1)}name.ts`]: "export default {};", - "flow.ts": "export default {};", + it("reports an import it could not resolve rather than dropping it", async () => { + const collected = await collect({ + "package.json": "{}", + "src/flows/checkout.flow.ts": 'import "../pages/missing";', + }); + + expect(collected.unresolvedImports).toEqual([ + { + importPath: "../pages/missing", + importingFilePath: "src/flows/checkout.flow.ts", + }, + ]); + }); + + it("fails when a file the graph reaches cannot be read", async () => { + expect( + collect({ "package.json": "{}" }, ["src/flows/gone.flow.ts"]), + ).rejects.toThrow(); + }); + + it("takes more than one root, for a range in another file", async () => { + expect( + await pathsOf( + { "package.json": "{}", - }), - ).toEqual(["flow.ts", "package.json"]); - }, - ); + "src/flows/checkout.flow.ts": "export default {};", + "src/pages/login.ts": "export const login = 1;", + }, + [flowPath, "src/pages/login.ts"], + ), + ).toEqual([ + "package.json", + "src/flows/checkout.flow.ts", + "src/pages/login.ts", + ]); + }); + + it("leaves node_modules and dot directories out of the path set", async () => { + const collected = await collect({ + ".hidden/secret.ts": "export const secret = 1;", + "node_modules/dep/index.ts": "export const dep = 1;", + "package.json": "{}", + "src/flows/checkout.flow.ts": 'import "../../node_modules/dep";', + }); - it("collects nothing from an empty directory", async () => { - expect(await collect({})).toEqual([]); + expect(Object.keys(collected.files).sort()).toEqual([ + "package.json", + "src/flows/checkout.flow.ts", + ]); + expect(collected.unresolvedImports).toHaveLength(1); }); - // A symlink reads as whatever it points at, which can be outside the working - // directory entirely. A run must not ship files from elsewhere on the machine. - it.skipIf(process.platform === "win32")( - "leaves behind a symbolic link to a file outside the directory", - async () => { - const outside = mkdtempSync(join(tmpdir(), "qawolf-outside-")); - roots.push(outside); - writeFileSync(join(outside, "secret.ts"), "export const secret = 1;"); - const cwd = makeWorkspace({ - "flow.ts": "export default {};", - "package.json": "{}", - }); - symlinkSync(join(outside, "secret.ts"), join(cwd, "linked.ts")); + it("does not follow a symbolic link out of the working directory", async () => { + const outside = makeWorkspace({ + "outside.ts": "export const outside = 1;", + }); + const root = makeWorkspace({ + "package.json": "{}", + "src/flows/checkout.flow.ts": 'import "../pages/linked";', + }); + mkdirSync(join(root, "src/pages"), { recursive: true }); + symlinkSync(join(outside, "outside.ts"), join(root, "src/pages/linked.ts")); - const collected = await collectRunFiles({ cwd, fs: makeDefaultFs() }); + const collected = await collectRunFiles({ + cwd: root, + fs: makeDefaultFs(), + roots: [flowPath], + }); - expect(Object.keys(collected)).toEqual(["flow.ts", "package.json"]); - }, - ); + expect(Object.keys(collected.files)).not.toContain("src/pages/linked.ts"); + }); }); diff --git a/src/shell/interactiveRunner/collectRunFiles.ts b/src/shell/interactiveRunner/collectRunFiles.ts index 97e376200..40ba36f33 100644 Binary files a/src/shell/interactiveRunner/collectRunFiles.ts and b/src/shell/interactiveRunner/collectRunFiles.ts differ