Skip to content

Commit 8adadc4

Browse files
committed
fix: shift to best-effort instrumentation on node < 24.13
1 parent 00f8e77 commit 8adadc4

4 files changed

Lines changed: 99 additions & 15 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/utils/repl-module-hooks.ts

Lines changed: 69 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as nodeModule from "node:module";
2-
import type { LoadFnOutput, ModuleHooks, ModuleSource } from "node:module";
2+
import type { LoadFnOutput, LoadHookContext, ModuleHooks, ModuleSource } from "node:module";
33
import { Command } from "@gemini-testing/commander";
44
import { readFileSync } from "node:fs";
55
import path from "node:path";
@@ -39,13 +39,21 @@ export const registerReplModuleHooks = (): void => {
3939
function registerNodeModuleHooks(): { revert: () => void } | null {
4040
const registerHooks = nodeModule.registerHooks;
4141

42-
if (typeof registerHooks !== "function" || hasBrokenMixedModuleHookValidation()) {
42+
if (typeof registerHooks !== "function") {
4343
return null;
4444
}
4545

4646
const hooks: ModuleHooks = registerHooks({
4747
load(url, context, nextLoad) {
48-
const result = nextLoad(url, context);
48+
let result: LoadFnOutput;
49+
50+
try {
51+
result = nextLoad(url, context);
52+
} catch (err) {
53+
// https://github.com/nodejs/node/issues/57327
54+
// Fixed for REPL instrumentation in Node 24.13.0; older versions mishandle CJS sources with mixed sync/async hooks.
55+
return instrumentLoadAfterFailedLoad(url, context, err);
56+
}
4957

5058
return instrumentLoadResult(url, result);
5159
},
@@ -54,14 +62,6 @@ function registerNodeModuleHooks(): { revert: () => void } | null {
5462
return { revert: () => hooks.deregister() };
5563
}
5664

57-
function hasBrokenMixedModuleHookValidation(): boolean {
58-
const [major, minor] = process.versions.node.split(".").map(Number);
59-
60-
// https://github.com/nodejs/node/issues/57327
61-
// Fixed for REPL instrumentation in Node 24.13.0; older versions mishandle CJS sources with mixed sync/async hooks.
62-
return major < 24 || (major === 24 && minor < 13);
63-
}
64-
6565
function registerPiratesHook(): { revert: () => void } {
6666
const revert = addHook((code, sourceFile) => safeInstrumentRepl(code, sourceFile), {
6767
exts: TRANSFORM_CODE_EXTENSIONS,
@@ -71,6 +71,26 @@ function registerPiratesHook(): { revert: () => void } {
7171
return { revert };
7272
}
7373

74+
function instrumentLoadAfterFailedLoad(url: string, context: LoadHookContext, err: unknown): LoadFnOutput {
75+
const sourceFile = getSourceFileFromUrl(url);
76+
77+
if (!isInvalidLoadSourceError(err) || !sourceFile || !shouldReadSourceFile(sourceFile)) {
78+
throw err;
79+
}
80+
81+
const source = readSourceFile(sourceFile);
82+
83+
if (source === null) {
84+
throw err;
85+
}
86+
87+
return {
88+
format: getFallbackFormat(context, sourceFile),
89+
shortCircuit: true,
90+
source: shouldHandleSourceFile(sourceFile) ? safeInstrumentRepl(source, sourceFile) : source,
91+
};
92+
}
93+
7494
function instrumentLoadResult(url: string, result: LoadFnOutput): LoadFnOutput {
7595
const sourceFile = getSourceFileFromUrl(url);
7696

@@ -108,6 +128,43 @@ function readSourceFile(sourceFile: string): string | null {
108128
}
109129
}
110130

131+
function isInvalidLoadSourceError(err: unknown): boolean {
132+
if (!(err instanceof Error)) {
133+
return false;
134+
}
135+
136+
const error = err as Error & { code?: string };
137+
138+
return (
139+
error.code === "ERR_INVALID_RETURN_PROPERTY_VALUE" &&
140+
error.message.includes('"source"') &&
141+
error.message.includes('"load" hook')
142+
);
143+
}
144+
145+
function shouldReadSourceFile(sourceFile: string): boolean {
146+
return TRANSFORM_CODE_EXTENSIONS.includes(path.extname(sourceFile));
147+
}
148+
149+
function getFallbackFormat(context: LoadHookContext, sourceFile: string): string {
150+
if (context.format) {
151+
return context.format;
152+
}
153+
154+
switch (path.extname(sourceFile)) {
155+
case ".mjs":
156+
return "module";
157+
case ".mts":
158+
return "module-typescript";
159+
case ".ts":
160+
case ".tsx":
161+
case ".cts":
162+
return "commonjs-typescript";
163+
default:
164+
return "commonjs";
165+
}
166+
}
167+
111168
function getSourceFileFromUrl(url: string): string | null {
112169
try {
113170
return url.startsWith("file:") ? fileURLToPath(url) : null;
@@ -118,8 +175,7 @@ function getSourceFileFromUrl(url: string): string | null {
118175

119176
function shouldHandleSourceFile(sourceFile: string): boolean {
120177
return (
121-
TRANSFORM_CODE_EXTENSIONS.includes(path.extname(sourceFile)) &&
122-
!sourceFile.includes(`${path.sep}node_modules${path.sep}`)
178+
shouldReadSourceFile(sourceFile) && !sourceFile.includes(`${path.sep}node_modules${path.sep}`)
123179
);
124180
}
125181

test/e2e/repl/fixture-project/testplane.config.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ module.exports = {
44
gridUrl: "local",
55
headless: "new",
66
sets: {
7-
default: { files: ["tests/**/*.test.[jt]s"] },
7+
default: { files: ["tests/**/*.test.[jt]s", "tests/**/*.test.cts"] },
88
},
99
browsers: {
1010
chrome: {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import assert from "node:assert/strict";
2+
import path from "node:path";
3+
import { pathToFileURL } from "node:url";
4+
5+
type TestContext = {
6+
browser: any;
7+
};
8+
9+
// Bound here so REPL e2e assertions can read it through generated context.
10+
const rootValue: number = 1000;
11+
void rootValue;
12+
13+
const pageUrl: string = pathToFileURL(path.join(process.cwd(), "page.html")).href;
14+
15+
// @ts-expect-error Testplane passes a context object into Mocha test callbacks.
16+
it("opens repl from test code", async ({ browser }: TestContext): Promise<void> => {
17+
// Bound here so REPL e2e assertions can read it through generated context.
18+
const localValue: number = 234;
19+
void localValue;
20+
21+
await browser.url(pageUrl);
22+
await browser.switchToRepl();
23+
const anotherValue = 12222;
24+
console.log(anotherValue);
25+
await browser.$("#action").click();
26+
27+
assert.equal(await browser.$("#message").getText(), "Clicked from fixture");
28+
});

0 commit comments

Comments
 (0)