| 1 | import { afterEach, describe, expect, it } from "bun:test"; |
| 2 | import { spawnSync } from "node:child_process"; |
| 3 | import { |
| 4 | existsSync, |
| 5 | mkdirSync, |
| 6 | mkdtempSync, |
| 7 | readdirSync, |
| 8 | readFileSync, |
| 9 | realpathSync, |
| 10 | rmSync, |
| 11 | writeFileSync, |
| 12 | } from "node:fs"; |
| 13 | import os from "node:os"; |
| 14 | import path from "node:path"; |
| 15 | import { pathToFileURL } from "node:url"; |
| 16 | |
| 17 | const temporaryDirectories: string[] = []; |
| 18 | |
| 19 | afterEach(() => { |
| 20 | for (const directory of temporaryDirectories.splice(0)) { |
| 21 | rmSync(directory, { force: true, recursive: true }); |
| 22 | } |
| 23 | }); |
| 24 | |
| 25 | const 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 | */ |
| 37 | const 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 | |
| 125 | const registerUrl = pathToFileURL( |
| 126 | path.resolve(import.meta.dir, "../src/register-oxc.ts"), |
| 127 | ).href; |
| 128 | |
| 129 | const 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 | |
| 141 | describe("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 | |
| 249 | describe("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 | |
| 330 | describe("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 | }); |