Skip to content

Commit 11016d9

Browse files
authored
Merge pull request #1390 from qawolf/chajac/outer-hop-validate
fix(runner): validate outer hop `node_modules` against declared deps
2 parents 160004e + 812ee56 commit 11016d9

18 files changed

Lines changed: 716 additions & 37 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@qawolf/cli": patch
3+
---
4+
5+
Fix `ERR_MODULE_NOT_FOUND` for correctly declared flow dependencies: dependency discovery now validates an ancestor `node_modules` before reusing it, and falls back to installing the project's declared deps when none satisfies them (reported as `Installing N project dependencies…`).

src/commands/flows/buildFlowsRunDeps.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ type BuildFlowsRunDepsArgs = {
2222
android: ReturnType<typeof createAndroidDeps>;
2323
runWebFlowDeps: RunWebFlowDeps;
2424
flags: FlowsRunFlags;
25+
projectDir: string | undefined;
2526
};
2627

2728
/**
@@ -31,7 +32,7 @@ type BuildFlowsRunDepsArgs = {
3132
* the injection point for tests) and passed in already awaited.
3233
*/
3334
export function buildFlowsRunDeps(args: BuildFlowsRunDepsArgs): FlowsRunDeps {
34-
const { ctx, resolvedDir, android, runWebFlowDeps, flags } = args;
35+
const { ctx, resolvedDir, android, runWebFlowDeps, flags, projectDir } = args;
3536
return {
3637
peekFlowMeta: makePeekFlowMeta(ctx.fs),
3738
installBrowsers: (innerCtx, browsers) =>
@@ -55,6 +56,7 @@ export function buildFlowsRunDeps(args: BuildFlowsRunDepsArgs): FlowsRunDeps {
5556
fs: ctx.fs,
5657
stdout: { write: (text: string) => ctx.ui.write(text) },
5758
stderr: { write: (text: string) => ctx.ui.write(text) },
59+
...(projectDir !== undefined ? { projectDir } : {}),
5860
}),
5961
now: () => Date.now(),
6062
};

src/commands/flows/buildRunReporter.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ export type BuildRunReporterDeps = {
1515
fs: Fs;
1616
stdout?: WriteSink;
1717
stderr?: WriteSink;
18+
projectDir?: string;
1819
};
1920

2021
/**
@@ -28,7 +29,11 @@ export function buildRunReporter(
2829
): Reporter {
2930
const stdout = deps.stdout ?? process.stdout;
3031
const stderr = deps.stderr ?? process.stderr;
31-
const console = createConsoleReporter({ stdout, stderr });
32+
const console = createConsoleReporter({
33+
stdout,
34+
stderr,
35+
...(deps.projectDir !== undefined ? { projectDir: deps.projectDir } : {}),
36+
});
3237
// `undefined` means the flag was absent; an empty string (`--junit=`) still
3338
// enables it and resolves to the default path.
3439
if (flags.junit === undefined) return console;

src/commands/flows/hybridRun.test.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ beforeEach(() => {
4646
prepareRunDirMock.mockResolvedValue({
4747
files: [],
4848
runDir: "/mock/run",
49+
outerHop: { mode: "none" },
4950
cleanup: async () => {},
5051
});
5152
configureTestkitMock.mockResolvedValue(undefined);
@@ -121,6 +122,7 @@ describe("handleHybridFlowsRun", () => {
121122
prepareRunDirMock.mockResolvedValue({
122123
files: ["/mock/.qawolf/my-env/login.flow.ts"],
123124
runDir: "/mock/run",
125+
outerHop: { mode: "none" },
124126
cleanup: async () => {},
125127
});
126128

@@ -165,12 +167,14 @@ describe("handleHybridFlowsRun", () => {
165167
deps,
166168
);
167169

168-
expect(prepareRunDirMock).toHaveBeenCalledWith({
169-
files: [`${envDir}/login.flow.ts`],
170-
projectDir: undefined,
171-
depsRoot: "/managed",
172-
runRoot: runStagingRoot(),
173-
});
170+
expect(prepareRunDirMock).toHaveBeenCalledWith(
171+
expect.objectContaining({
172+
files: [`${envDir}/login.flow.ts`],
173+
projectDir: undefined,
174+
depsRoot: "/managed",
175+
runRoot: runStagingRoot(),
176+
}),
177+
);
174178
});
175179

176180
it("passes staged files from prepareRunDir to flowsRun", async () => {
@@ -182,6 +186,7 @@ describe("handleHybridFlowsRun", () => {
182186
prepareRunDirMock.mockResolvedValue({
183187
files: ["/mock/run/exec/login.flow.ts"],
184188
runDir: "/mock/run",
189+
outerHop: { mode: "none" },
185190
cleanup: async () => {},
186191
});
187192

@@ -211,6 +216,7 @@ describe("handleHybridFlowsRun", () => {
211216
prepareRunDirMock.mockResolvedValue({
212217
files: ["/mock/.qawolf/my-env/login.flow.ts"],
213218
runDir: "/mock/run",
219+
outerHop: { mode: "none" },
214220
cleanup,
215221
});
216222

@@ -234,6 +240,7 @@ describe("handleHybridFlowsRun", () => {
234240
prepareRunDirMock.mockResolvedValue({
235241
files: ["/mock/.qawolf/my-env/login.flow.ts"],
236242
runDir: "/mock/run",
243+
outerHop: { mode: "none" },
237244
cleanup: async () => {},
238245
});
239246

@@ -305,6 +312,7 @@ describe("handleHybridFlowsRun", () => {
305312
"/mock/.qawolf/my-env/b.flow.ts",
306313
],
307314
runDir: "/mock/run",
315+
outerHop: { mode: "none" },
308316
cleanup: async () => {},
309317
});
310318

src/commands/flows/runDefaults.handle.test.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ beforeEach(() => {
9292
prepareRunDirMock.mockResolvedValue({
9393
files: [],
9494
runDir: "/mock/run",
95+
outerHop: { mode: "none" },
9596
cleanup: async () => {},
9697
});
9798
configureTestkitMock.mockResolvedValue(undefined);
@@ -243,19 +244,22 @@ describe("handleFlowsRun", () => {
243244

244245
await handleFlowsRun(makeCtx(), undefined, defaultFlags(), makeDeps());
245246

246-
expect(prepareRunDirMock).toHaveBeenCalledWith({
247-
files: ["/some/flow.ts"],
248-
projectDir: undefined,
249-
depsRoot: "/managed",
250-
runRoot: runStagingRoot(),
251-
});
247+
expect(prepareRunDirMock).toHaveBeenCalledWith(
248+
expect.objectContaining({
249+
files: ["/some/flow.ts"],
250+
projectDir: undefined,
251+
depsRoot: "/managed",
252+
runRoot: runStagingRoot(),
253+
}),
254+
);
252255
});
253256

254257
it("passes staged files from prepareRunDir to flowsRun", async () => {
255258
expandPatternsMock.mockResolvedValue(["/some/flow.ts"]);
256259
prepareRunDirMock.mockResolvedValue({
257260
files: ["/mock/run/exec/flow.ts"],
258261
runDir: "/mock/run",
262+
outerHop: { mode: "none" },
259263
cleanup: async () => {},
260264
});
261265

@@ -276,6 +280,7 @@ describe("handleFlowsRun", () => {
276280
prepareRunDirMock.mockResolvedValue({
277281
files: ["/some/flow.ts"],
278282
runDir: "/mock/run",
283+
outerHop: { mode: "none" },
279284
cleanup,
280285
});
281286

src/commands/flows/runDefaults.reporterWiring.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ function makeDeps(
7070
prepareRunDir: async (args) => ({
7171
files: args.files,
7272
runDir: "/mock/run",
73+
outerHop: { mode: "none" },
7374
cleanup: async () => {},
7475
}),
7576
configureTestkit: async () => {},

src/commands/flows/runStagedFlows.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const flowsRunMock = mock<StagedRunDeps["flowsRun"]>();
1919
const runWebFlowDepsMock = mock<(...args: unknown[]) => Promise<unknown>>();
2020
const cleanupMock = mock<() => Promise<void>>();
2121
const uiInfoMock = mock<(message: string) => void>();
22+
const debugMock = mock<(message: string) => void>();
2223

2324
const trackedMocks = [
2425
resolveDepsRootMock,
@@ -28,6 +29,7 @@ const trackedMocks = [
2829
runWebFlowDepsMock,
2930
cleanupMock,
3031
uiInfoMock,
32+
debugMock,
3133
];
3234

3335
function makeDeps(): StagedRunDeps {
@@ -64,7 +66,7 @@ function makeCtx(): CommandContext {
6466
isInteractive: false,
6567
signals: noopSignals,
6668
fs: makeMemoryFs(),
67-
log: () => makeNoopLogger(),
69+
log: () => ({ ...makeNoopLogger(), debug: debugMock }),
6870
ui: { ...makeFakeUI("human"), info: uiInfoMock },
6971
} as unknown as CommandContext;
7072
}
@@ -80,6 +82,7 @@ beforeEach(() => {
8082
prepareRunDirMock.mockResolvedValue({
8183
files: ["/mock/run/exec/flow.ts"],
8284
runDir: "/mock/run",
85+
outerHop: { mode: "none" },
8386
cleanup: cleanupMock,
8487
});
8588
configureTestkitMock.mockResolvedValue(undefined);
@@ -188,4 +191,46 @@ describe("runStagedFlows", () => {
188191
overrideDir: "/custom/deps",
189192
});
190193
});
194+
195+
it("renders a status line when the outer-hop fallback install starts", async () => {
196+
await runStagedFlows({
197+
ctx: makeCtx(),
198+
files: ["/some/flow.ts"],
199+
flags: defaultFlags(),
200+
deps: makeDeps(),
201+
});
202+
203+
const call = prepareRunDirMock.mock.calls[0]?.[0];
204+
expect(call?.onInstallStart).toBeDefined();
205+
call?.onInstallStart?.(3);
206+
expect(uiInfoMock).toHaveBeenCalledWith(
207+
runnerMessages.installingProjectDeps(3),
208+
);
209+
});
210+
211+
it("debug-logs rejected outer-hop candidates when the fallback install ran", async () => {
212+
prepareRunDirMock.mockResolvedValue({
213+
files: ["/mock/run/exec/flow.ts"],
214+
runDir: "/mock/run",
215+
outerHop: {
216+
mode: "install",
217+
depCount: 1,
218+
rejected: [{ dir: "/host/node_modules", missing: ["date-fns"] }],
219+
},
220+
cleanup: cleanupMock,
221+
});
222+
223+
await runStagedFlows({
224+
ctx: makeCtx(),
225+
files: ["/some/flow.ts"],
226+
flags: defaultFlags(),
227+
deps: makeDeps(),
228+
});
229+
230+
expect(debugMock).toHaveBeenCalledWith(
231+
runnerMessages.outerHopCandidateRejected("/host/node_modules", [
232+
"date-fns",
233+
]),
234+
);
235+
});
191236
});

src/commands/flows/runStagedFlows.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,22 @@ export async function runStagedFlows(
7979
projectDir,
8080
depsRoot: runtimeEnv.depsRoot,
8181
runRoot: runStagingRoot(),
82+
onInstallStart: (depCount) =>
83+
ctx.ui.info(runnerMessages.installingProjectDeps(depCount)),
8284
});
8385

86+
if (staged.outerHop.mode === "install") {
87+
const log = ctx.log("runner");
88+
for (const candidate of staged.outerHop.rejected) {
89+
log.debug(
90+
runnerMessages.outerHopCandidateRejected(
91+
candidate.dir,
92+
candidate.missing,
93+
),
94+
);
95+
}
96+
}
97+
8498
// Register cleanup before the setup calls below so a throw in configureTestkit
8599
// / runWebFlowDeps never leaks the per-run staging directory.
86100
const unregisterCleanup = ctx.signals.register(staged.cleanup);
@@ -94,7 +108,14 @@ export async function runStagedFlows(
94108
ctx,
95109
staged.files,
96110
flags,
97-
buildFlowsRunDeps({ ctx, resolvedDir, android, runWebFlowDeps, flags }),
111+
buildFlowsRunDeps({
112+
ctx,
113+
resolvedDir,
114+
android,
115+
runWebFlowDeps,
116+
flags,
117+
projectDir,
118+
}),
98119
);
99120
} finally {
100121
unregisterCleanup();

src/core/errors.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, it } from "bun:test";
22

3-
import { errorCode, isNoEntError } from "./errors.js";
3+
import { errorCode, extractMissingPackage, isNoEntError } from "./errors.js";
44

55
describe("errorCode", () => {
66
it("returns the string code of an error-like value", () => {
@@ -36,3 +36,43 @@ describe("isNoEntError", () => {
3636
expect(isNoEntError(Error("boom"))).toBe(false);
3737
});
3838
});
39+
40+
describe("extractMissingPackage", () => {
41+
it("extracts the package name from an ESM resolution error", () => {
42+
const text =
43+
"Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'date-fns' imported from /x/y.js";
44+
expect(extractMissingPackage(text)).toBe("date-fns");
45+
});
46+
47+
it("extracts a scoped package name from a CJS resolution error", () => {
48+
expect(extractMissingPackage("Cannot find module '@faker-js/faker'")).toBe(
49+
"@faker-js/faker",
50+
);
51+
});
52+
53+
it("returns undefined for non-resolution errors", () => {
54+
expect(extractMissingPackage("locator timeout")).toBeUndefined();
55+
});
56+
57+
it("returns undefined for a relative file path specifier", () => {
58+
expect(
59+
extractMissingPackage(
60+
"Cannot find module './helper.js' imported from /x/y.js",
61+
),
62+
).toBeUndefined();
63+
});
64+
65+
it("returns undefined for an absolute file path specifier", () => {
66+
expect(
67+
extractMissingPackage(
68+
"Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/run/exec/helper.js' imported from /x/y.js",
69+
),
70+
).toBeUndefined();
71+
});
72+
73+
it("returns undefined for a Windows drive-letter path specifier", () => {
74+
expect(
75+
extractMissingPackage("Cannot find module 'C:\\flows\\helper.js'"),
76+
).toBeUndefined();
77+
});
78+
});

src/core/errors.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,20 @@ export function errorCode(err: unknown): string | undefined {
1717
export function isNoEntError(err: unknown): boolean {
1818
return errorCode(err) === "ENOENT";
1919
}
20+
21+
const missingPackagePattern = /Cannot find (?:package|module) '([^']+)'/;
22+
const pathLikeSpecifierPattern = /^(?:\.|\/|[A-Za-z]:[\\/])/;
23+
24+
/**
25+
* The package name from a Node "Cannot find package 'x'" / "Cannot find
26+
* module 'x'" resolution error text, or undefined when the text is not a
27+
* module-resolution failure or when the specifier is a file path (relative,
28+
* absolute, or Windows drive-letter prefix) rather than a bare package name.
29+
*/
30+
export function extractMissingPackage(text: string): string | undefined {
31+
const specifier = missingPackagePattern.exec(text)?.[1];
32+
if (specifier === undefined || pathLikeSpecifierPattern.test(specifier)) {
33+
return undefined;
34+
}
35+
return specifier;
36+
}

0 commit comments

Comments
 (0)