1import { afterEach, describe, expect, it } from "bun:test";
2import { spawnSync } from "node:child_process";
3import {
4 existsSync,
5 mkdirSync,
6 mkdtempSync,
7 readdirSync,
8 readFileSync,
9 realpathSync,
10 rmSync,
11 writeFileSync,
12} from "node:fs";
13import os from "node:os";
14import path from "node:path";
15import { pathToFileURL } from "node:url";
16
17const temporaryDirectories: string[] = [];
18
19afterEach(() => {
20 for (const directory of temporaryDirectories.splice(0)) {
21 rmSync(directory, { force: true, recursive: true });
22 }
23});
24
25const write = (file: string, content: string) => {
26 mkdirSync(path.dirname(file), { recursive: true });
27 writeFileSync(file, content);
28};
29
30/**
31 * A project exercising everything tsx layers over Node that a TypeScript
32 * codebase relies on: tsconfig `paths`, emitted-extension imports, implicit
33 * extensions and directory indexes, JSON without attributes, TSX with a
34 * tsconfig-selected runtime, and `require()` from a `.cts` into ESM
35 * TypeScript.
36 */
37const makeProject = () => {
38 const root = realpathSync(
39 mkdtempSync(path.join(os.tmpdir(), "alchemy-register-oxc-")),
40 );
41 temporaryDirectories.push(root);
42 const at = (file: string) => path.join(root, file);
43 write(at("package.json"), '{"type":"module"}');
44 write(
45 at("tsconfig.json"),
46 JSON.stringify({
47 compilerOptions: {
48 jsx: "react-jsx",
49 jsxImportSource: "myjsx",
50 baseUrl: ".",
51 paths: { "@lib/*": ["src/lib/*"] },
52 },
53 }),
54 );
55 write(
56 at("node_modules/myjsx/package.json"),
57 '{"name":"myjsx","type":"module","exports":{"./jsx-runtime":"./jsx-runtime.js","./jsx-dev-runtime":"./jsx-runtime.js"}}',
58 );
59 write(
60 at("node_modules/myjsx/jsx-runtime.js"),
61 "export const jsx = (tag, props) => ({ tag, props });\nexport const jsxs = jsx;\nexport const jsxDEV = jsx;\nexport const Fragment = 'F';\n",
62 );
63 write(
64 at("node_modules/wsdep/package.json"),
65 '{"name":"wsdep","type":"module","exports":{".":"./src/index.ts"}}',
66 );
67 write(
68 at("node_modules/wsdep/src/index.ts"),
69 'export const fromWorkspaceDep: string = "ws";\n',
70 );
71 write(
72 at("node_modules/conditional/package.json"),
73 '{"name":"conditional","type":"module","exports":{".":{"bun":"./src/index.ts","import":"./lib/index.js"}}}',
74 );
75 write(
76 at("node_modules/conditional/src/index.ts"),
77 'export const selected: string = "src";\n',
78 );
79 write(
80 at("node_modules/conditional/lib/index.js"),
81 'export const selected = "lib";\n',
82 );
83 write(
84 at("src/lib/helper.ts"),
85 'export const helper = (): string => "helper";\n',
86 );
87 write(at("src/lib/helper.js"), 'export const helper = () => "emitted";\n');
88 write(at("src/dir/index.ts"), 'export const index = "dir-index";\n');
89 write(at("src/sub.ts"), 'export const sub = "sub";\n');
90 write(at("src/data.json"), '{ "answer": 42 }');
91 write(at("src/View.tsx"), 'export const view = <p id="x">hi</p>;\n');
92 write(at("cjs/package.json"), '{"type":"commonjs"}');
93 write(
94 at("cjs/esmish.ts"),
95 "export const value: number = 7;\nexport class A { constructor(readonly x: number) {} }\n",
96 );
97 write(
98 at("cjs/consumer.cts"),
99 'const { value, A } = require("./esmish.ts");\nconst sub = require("../src/sub");\nmodule.exports = { viaRequire: new A(value).x + 1, sub: sub.sub };\n',
100 );
101 write(
102 at("src/entry.ts"),
103 [
104 'import { helper } from "@lib/helper";',
105 'import { helper as helperJs } from "./lib/helper.js";',
106 'import { sub } from "./sub";',
107 'import { index } from "./dir";',
108 'import { index as index2 } from "./dir/";',
109 'import data from "./data.json";',
110 'import { view } from "./View.tsx";',
111 'import consumer from "../cjs/consumer.cts";',
112 "export const report = {",
113 " helper: helper(), helperJs: helperJs(), sub, index, index2,",
114 " data: data.answer, view: view.tag,",
115 " viaRequire: consumer.viaRequire, requiredSub: consumer.sub,",
116 "};",
117 "export const boom = (): never => {",
118 ' throw new Error("boom");',
119 "};",
120 ].join("\n"),
121 );
122 return root;
123};
124
125const registerUrl = pathToFileURL(
126 path.resolve(import.meta.dir, "../src/register-oxc.ts"),
127).href;
128
129const runNode = (
130 cwd: string,
131 script: string,
132 env: Record<string, string> = {},
133) =>
134 spawnSync("node", ["--no-warnings", "--input-type=module", "-e", script], {
135 cwd,
136 encoding: "utf8",
137 env: { ...process.env, ...env },
138 timeout: 20_000,
139 });
140
141describe("registerOxc", () => {
142 it("resolves and transpiles TypeScript the way tsx does", () => {
143 const root = makeProject();
144 const cache = path.join(root, "cache");
145 const script = `
146 const { registerOxc } = await import(${JSON.stringify(registerUrl)});
147 registerOxc();
148 const entry = await import("./src/entry.ts");
149 console.log(JSON.stringify(entry.report));
150 try { entry.boom(); } catch (error) { console.log(error.stack.split("\\n")[1].trim()); }
151 `;
152 const result = runNode(root, script, { ALCHEMY_TRANSFORM_CACHE: cache });
153 expect(result.status, result.stderr).toBe(0);
154 const [report, frame] = result.stdout.trim().split("\n");
155 expect(JSON.parse(report!)).toEqual({
156 // tsconfig paths alias
157 helper: "helper",
158 // `.js` import prefers the `.ts` source over the emitted sibling
159 helperJs: "helper",
160 // extensionless and directory imports
161 sub: "sub",
162 index: "dir-index",
163 index2: "dir-index",
164 // JSON without an import attribute
165 data: 42,
166 // TSX through the tsconfig's jsxImportSource
167 view: "p",
168 // `.cts` requiring `.ts` (parameter property) and an extensionless path
169 viaRequire: 8,
170 requiredSub: "sub",
171 });
172 // Source maps are applied to stack traces. They are not inlined: each
173 // sits next to its cache entry, without `sourcesContent`, and the module
174 // points at it by path.
175 expect(frame).toMatch(/entry\.ts:15:/);
176 const maps = readdirSync(cache).filter((name) => name.endsWith(".map"));
177 expect(maps.length).toBeGreaterThan(0);
178 for (const name of maps) {
179 const map = JSON.parse(readFileSync(path.join(cache, name), "utf8"));
180 expect(map.sourcesContent).toBeUndefined();
181 expect(map.sources[0]).toMatch(/\.(ts|tsx|cts|js)$/);
182 }
183 // The cache-hit path references the same file.
184 const hit = runNode(root, script, { ALCHEMY_TRANSFORM_CACHE: cache });
185 expect(hit.status, hit.stderr).toBe(0);
186 expect(hit.stdout.trim().split("\n")[1]).toMatch(/entry\.ts:15:/);
187 // With no cache there is nowhere to put the map, so it is inlined.
188 const inline = runNode(root, script, { ALCHEMY_TRANSFORM_CACHE: "0" });
189 expect(inline.status, inline.stderr).toBe(0);
190 expect(inline.stdout.trim().split("\n")[1]).toMatch(/entry\.ts:15:/);
191 });
192
193 it("adds configured package export conditions to project resolution", () => {
194 const root = makeProject();
195 write(
196 path.join(root, "src/conditional.ts"),
197 'export { selected } from "conditional";\n',
198 );
199 const result = runNode(
200 root,
201 `
202 const { registerOxc } = await import(${JSON.stringify(registerUrl)});
203 registerOxc({ conditions: ["bun"] });
204 const conditional = await import("./src/conditional.ts");
205 console.log(conditional.selected);
206 `,
207 );
208 expect(result.status, result.stderr).toBe(0);
209 expect(result.stdout.trim()).toBe("src");
210 });
211
212 it("isolates namespaced imports and leaves node_modules to Node with a filter", () => {
213 const root = makeProject();
214 const result = runNode(
215 root,
216 `
217 const { registerOxc } = await import(${JSON.stringify(registerUrl)});
218 const seen = [];
219 const one = registerOxc({ namespace: "one", onImport: (url) => seen.push(url.split("/").pop()) });
220 const a = await one.import("./src/sub.ts", import.meta.url);
221 const b = await one.import("./src/sub.ts", import.meta.url);
222 const two = registerOxc({ namespace: "two" });
223 const c = await two.import("./src/sub.ts", import.meta.url);
224 console.log(JSON.stringify({ same: a === b, fresh: a !== c, seen }));
225 one.unregister();
226 two.unregister();
227 registerOxc({ filter: (file) => !file.includes("/node_modules/") });
228 try {
229 await import("wsdep");
230 console.log("wsdep: loaded");
231 } catch (error) {
232 console.log("wsdep: " + error.code);
233 }
234 `,
235 );
236 expect(result.status, result.stderr).toBe(0);
237 const [scoped, filtered] = result.stdout.trim().split("\n");
238 expect(JSON.parse(scoped!)).toEqual({
239 same: true,
240 fresh: true,
241 seen: ["sub.ts"],
242 });
243 // With node_modules filtered out, the dependency's `.ts` is Node's to
244 // reject: the loader neither transpiles nor rewrites it.
245 expect(filtered).toBe("wsdep: ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING");
246 });
247});
248
249describe("transform cache", () => {
250 /** `options` is the literal passed to `registerOxc` in the child. */
251 const importSub = (options: string) => `
252 const { registerOxc } = await import(${JSON.stringify(registerUrl)});
253 registerOxc(${options});
254 const { sub } = await import("./src/sub.ts");
255 console.log(sub);
256 `;
257
258 it("reuses Oxc output across processes and keys on the tsconfig", () => {
259 const root = makeProject();
260 const cache = path.join(root, "cache");
261 const first = runNode(
262 root,
263 importSub(`{ cache: ${JSON.stringify(cache)} }`),
264 );
265 expect(first.status, first.stderr).toBe(0);
266 expect(first.stdout.trim()).toBe("sub");
267 const entries = readdirSync(cache)
268 .filter((name) => name.endsWith(".json"))
269 .map((name) => path.join(cache, name));
270 expect(entries.length).toBeGreaterThan(0);
271 // The only proof a second process READ the entry rather than
272 // transforming again: make the cached code say something the source
273 // does not.
274 const entry = entries.find((file) =>
275 (
276 JSON.parse(readFileSync(file, "utf8")) as { code: string }
277 ).code.includes('"sub"'),
278 );
279 expect(entry).toBeDefined();
280 const cached = JSON.parse(readFileSync(entry!, "utf8")) as {
281 code: string;
282 };
283 writeFileSync(
284 entry!,
285 JSON.stringify({
286 ...cached,
287 code: cached.code.replace('"sub"', '"from-cache"'),
288 }),
289 );
290 const second = runNode(
291 root,
292 importSub(`{ cache: ${JSON.stringify(cache)} }`),
293 );
294 expect(second.status, second.stderr).toBe(0);
295 expect(second.stdout.trim()).toBe("from-cache");
296 // The resolved tsconfig is part of the key: editing it is a miss.
297 const tsconfig = path.join(root, "tsconfig.json");
298 const config = JSON.parse(readFileSync(tsconfig, "utf8")) as {
299 compilerOptions: Record<string, unknown>;
300 };
301 config.compilerOptions.target = "ES2020";
302 writeFileSync(tsconfig, JSON.stringify(config));
303 const third = runNode(
304 root,
305 importSub(`{ cache: ${JSON.stringify(cache)} }`),
306 );
307 expect(third.status, third.stderr).toBe(0);
308 expect(third.stdout.trim()).toBe("sub");
309 });
310
311 it("takes its directory from the environment and can be switched off", () => {
312 const root = makeProject();
313 const fromEnv = path.join(root, "cache-from-env");
314 const viaEnv = runNode(root, importSub("{}"), {
315 ALCHEMY_TRANSFORM_CACHE: fromEnv,
316 });
317 expect(viaEnv.status, viaEnv.stderr).toBe(0);
318 expect(readdirSync(fromEnv).length).toBeGreaterThan(0);
319
320 const disabled = path.join(root, "cache-disabled");
321 const off = runNode(root, importSub("{ cache: false }"), {
322 ALCHEMY_TRANSFORM_CACHE: disabled,
323 });
324 expect(off.status, off.stderr).toBe(0);
325 expect(off.stdout.trim()).toBe("sub");
326 expect(existsSync(disabled)).toBe(false);
327 });
328});
329
330describe("compile cache", () => {
331 it("flushes lazily loaded modules to Node's compile cache", () => {
332 const root = makeProject();
333 const directory = path.join(root, "compile-cache");
334 const result = runNode(
335 root,
336 `
337 import { enableCompileCache } from "node:module";
338 import { readdirSync } from "node:fs";
339 enableCompileCache(${JSON.stringify(directory)});
340 const count = () =>
341 readdirSync(${JSON.stringify(directory)}, { recursive: true })
342 .filter((name) => name.includes("/")).length;
343 const { registerOxc } = await import(${JSON.stringify(registerUrl)});
344 registerOxc();
345 // Past Node's own post-entry persist.
346 await new Promise((resolve) => setTimeout(resolve, 300));
347 const before = count();
348 await import("./src/sub.ts");
349 await new Promise((resolve) => setTimeout(resolve, 1500));
350 console.log(JSON.stringify({ before, after: count() }));
351 // Not a clean exit: whatever is on disk got there through the flush.
352 process.kill(process.pid, "SIGKILL");
353 `,
354 );
355 expect(result.signal).toBe("SIGKILL");
356 const { before, after } = JSON.parse(result.stdout.trim()) as {
357 before: number;
358 after: number;
359 };
360 expect(after).toBeGreaterThan(before);
361 });
362});