diff --git a/docs/release-1.0.0.md b/docs/release-1.0.0.md new file mode 100644 index 0000000..52f3040 --- /dev/null +++ b/docs/release-1.0.0.md @@ -0,0 +1,52 @@ +# @knighted/module 1.0.0 release notes + +## Status + +- Current candidate: 1.0.0-rc.0 (tag `latest` until final 1.0.0). +- Stability: API surface locked for 1.0.x; only bug fixes expected before GA. + +## Requirements + +- Node >= 22.21.1 (tested primarily on 22.x). Older LTS are not guaranteed; add matrix coverage if support is desired. + +## Highlights + +- Symmetric ESM↔CJS transforms with optioned behaviors: + - `topLevelAwait`: `error` (default), `wrap`, `preserve` for CJS targets. + - `importMetaMain`: `shim` (default), `warn`, `error` with Node version gate. + - `liveBindings`: `strict` (default), `loose`, `off` for ESM→CJS. + - `cjsDefault`: `auto` (default), `module-exports`, `none` for default import interop. + - `rewriteSpecifier`: extension or callback-based specifier rewriting for both directions. + - `requireSource`: `builtin` (default) or `create-require` fallback. +- Defensive handling: reject `with` / unshadowed `eval` when raising to ESM; reject shadowed `module`/`exports` in CJS lowering. +- Runtime coverage: fixtures and integration tests for **filename/**dirname/import.meta globals, `require.main` rewrites, TLA wrapping, namespace/default interop, export hoisting, and project-level conversions. + +## Behavior guarantees + +- Live bindings are strict by default for ESM→CJS; CJS→ESM rewrites avoid unsound cases by throwing on unsupported constructs. +- `import.meta` properties shimmed when targeting CJS; `import.meta.main` guarded by version-aware warnings/errors. +- Top-level await in CJS targets defaults to `error`; wrapping/preserve are opt-in and keep exports intact. + +## Packaging and metadata + +- Confirm `package.json` exports/fields (`main`, `exports`, `types`) match the build outputs. +- Dependencies: `oxc-parser`, `@babel/parser/traverse`, `magic-string`, `@knighted/specifier`, `@knighted/walk`, `node-module-type`. +- Consider source maps (not emitted today); document absence if deferring. + +## Testing + +- Lint: `npm run lint` (oxlint) — clean. +- Tests: `npm test` — ~98% statements, ~93% branches; format.ts 100% line coverage, remaining branches are defensive/unreachable. +- Add Node matrix if supporting additional runtimes before GA. + +## Release steps to GA + +1. Publish 1.0.0-rc.0 as `latest` (per plan); keep `next` if needed for future prereleases. +2. Monitor for regressions; if none, tag 1.0.0 with the same artifacts. +3. Update README and docs links to reference this release note; remove any “remaining gaps” language now that coverage is closed. +4. Add changelog entry summarizing changes since 1.0.0-alpha.8/1.0.0-beta.5. + +## Open questions + +- Do we want official support for Node 20/18? If yes, run matrix and adjust shims accordingly. +- CLI packaging: still library-only; document intentionally if not providing a CLI. diff --git a/package-lock.json b/package-lock.json index 098553e..ac530e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/module", - "version": "1.0.0-beta.5", + "version": "1.0.0-rc.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.0.0-beta.5", + "version": "1.0.0-rc.0", "license": "MIT", "dependencies": { "@knighted/specifier": "^2.0.9", diff --git a/package.json b/package.json index cad359b..a6db513 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/module", - "version": "1.0.0-beta.5", + "version": "1.0.0-rc.0", "description": "Transforms differences between ES modules and CommonJS.", "type": "module", "main": "dist/module.js", diff --git a/test/fixtures/arrayValue.cjs b/test/fixtures/arrayValue.cjs new file mode 100644 index 0000000..8ede5e6 --- /dev/null +++ b/test/fixtures/arrayValue.cjs @@ -0,0 +1 @@ +module.exports = [1, 2, 3] diff --git a/test/fixtures/bareModuleExports.cjs b/test/fixtures/bareModuleExports.cjs new file mode 100644 index 0000000..bf0409c --- /dev/null +++ b/test/fixtures/bareModuleExports.cjs @@ -0,0 +1,2 @@ +module.exports +exports.foo = 1 diff --git a/test/fixtures/cjsDefaultModuleExports.mjs b/test/fixtures/cjsDefaultModuleExports.mjs new file mode 100644 index 0000000..0636874 --- /dev/null +++ b/test/fixtures/cjsDefaultModuleExports.mjs @@ -0,0 +1,3 @@ +import foo from './values.cjs' + +export const value = foo.foo diff --git a/test/fixtures/evalOnly.cjs b/test/fixtures/evalOnly.cjs new file mode 100644 index 0000000..3692909 --- /dev/null +++ b/test/fixtures/evalOnly.cjs @@ -0,0 +1 @@ +eval('exports.ok = true') diff --git a/test/fixtures/exportDefaultAnon.mjs b/test/fixtures/exportDefaultAnon.mjs new file mode 100644 index 0000000..5966c0f --- /dev/null +++ b/test/fixtures/exportDefaultAnon.mjs @@ -0,0 +1,3 @@ +export default function () { + return 'anon' +} diff --git a/test/fixtures/exportDefaultAnonTla.mjs b/test/fixtures/exportDefaultAnonTla.mjs new file mode 100644 index 0000000..77c9d42 --- /dev/null +++ b/test/fixtures/exportDefaultAnonTla.mjs @@ -0,0 +1,5 @@ +await Promise.resolve('tla') + +export default function () { + return 'tla-anon' +} diff --git a/test/fixtures/exportDefaultNamed.mjs b/test/fixtures/exportDefaultNamed.mjs new file mode 100644 index 0000000..3983b4b --- /dev/null +++ b/test/fixtures/exportDefaultNamed.mjs @@ -0,0 +1,3 @@ +export default function namedNoTla() { + return 'named-no-tla' +} diff --git a/test/fixtures/exportDefaultTlaNamed.mjs b/test/fixtures/exportDefaultTlaNamed.mjs new file mode 100644 index 0000000..4b9902e --- /dev/null +++ b/test/fixtures/exportDefaultTlaNamed.mjs @@ -0,0 +1,4 @@ +await Promise.resolve() +export default function named() { + return 'tla-named' +} diff --git a/test/fixtures/exportNamedFunction.mjs b/test/fixtures/exportNamedFunction.mjs new file mode 100644 index 0000000..5d154f4 --- /dev/null +++ b/test/fixtures/exportNamedFunction.mjs @@ -0,0 +1,7 @@ +export function greet() { + return 'greet' +} + +export class Box { + value = 1 +} diff --git a/test/fixtures/exportNamespaceAll.mjs b/test/fixtures/exportNamespaceAll.mjs new file mode 100644 index 0000000..d64d19e --- /dev/null +++ b/test/fixtures/exportNamespaceAll.mjs @@ -0,0 +1 @@ +export * as bag from './values.mjs' diff --git a/test/fixtures/exportNamespaceSpecifier.mjs b/test/fixtures/exportNamespaceSpecifier.mjs new file mode 100644 index 0000000..fe58f94 --- /dev/null +++ b/test/fixtures/exportNamespaceSpecifier.mjs @@ -0,0 +1 @@ +export * as ns from './exportDefaultAnon.mjs' diff --git a/test/fixtures/exportReexportDefault.mjs b/test/fixtures/exportReexportDefault.mjs new file mode 100644 index 0000000..1eec862 --- /dev/null +++ b/test/fixtures/exportReexportDefault.mjs @@ -0,0 +1,4 @@ +export { default as anon } from './exportDefaultAnon.mjs' +export { default as named } from './exportDefaultNamed.mjs' + +export const label = 'reexport' diff --git a/test/fixtures/importSideEffect.mjs b/test/fixtures/importSideEffect.mjs new file mode 100644 index 0000000..8b70a00 --- /dev/null +++ b/test/fixtures/importSideEffect.mjs @@ -0,0 +1,3 @@ +import './values.cjs' + +export const loaded = true diff --git a/test/fixtures/liveBindingsOff.mjs b/test/fixtures/liveBindingsOff.mjs new file mode 100644 index 0000000..87d6f27 --- /dev/null +++ b/test/fixtures/liveBindingsOff.mjs @@ -0,0 +1,4 @@ +export const foo = 1 +export function inc(x) { + return x + 1 +} diff --git a/test/fixtures/projects/cjs-app/index.cjs b/test/fixtures/projects/cjs-app/index.cjs new file mode 100644 index 0000000..8e75239 --- /dev/null +++ b/test/fixtures/projects/cjs-app/index.cjs @@ -0,0 +1,15 @@ +const { basename, bump, value } = require('./lib.cjs') +const { join } = require('node:path') + +const here = __dirname + +module.exports.main = () => ({ + name: basename(__filename), + bumped: bump(2), + value, + path: join(here, 'lib.cjs'), +}) + +if (require.main === module) { + console.log(JSON.stringify(module.exports.main())) +} diff --git a/test/fixtures/projects/cjs-app/lib.cjs b/test/fixtures/projects/cjs-app/lib.cjs new file mode 100644 index 0000000..6789628 --- /dev/null +++ b/test/fixtures/projects/cjs-app/lib.cjs @@ -0,0 +1,10 @@ +const path = require('node:path') + +exports.value = 1 +exports.basename = file => path.basename(file) +exports.bump = v => v + exports.value +exports.dynamicLoad = id => require(id).value + +if (require.main === module) { + console.log(exports.bump(2)) +} diff --git a/test/fixtures/projects/esm-app/index.mjs b/test/fixtures/projects/esm-app/index.mjs new file mode 100644 index 0000000..e63c59b --- /dev/null +++ b/test/fixtures/projects/esm-app/index.mjs @@ -0,0 +1,13 @@ +import { add, inc } from './lib.mjs' + +const boot = await Promise.resolve(2) + +export const ready = boot +export const run = async () => { + const next = inc() + return { sum: add(boot, 0), after: next, main: import.meta.main ?? false } +} + +if (import.meta.main) { + run().then(res => console.log(JSON.stringify(res))) +} diff --git a/test/fixtures/projects/esm-app/lib.mjs b/test/fixtures/projects/esm-app/lib.mjs new file mode 100644 index 0000000..c3d25ec --- /dev/null +++ b/test/fixtures/projects/esm-app/lib.mjs @@ -0,0 +1,6 @@ +export const add = (a, b) => a + b +export let counter = 0 +export const inc = () => { + counter += 1 + return counter +} diff --git a/test/fixtures/requireArray.cjs b/test/fixtures/requireArray.cjs new file mode 100644 index 0000000..75269ce --- /dev/null +++ b/test/fixtures/requireArray.cjs @@ -0,0 +1,6 @@ +const [first] = require('./arrayValue.cjs') +const path = './arrayValue.cjs' +require(path) + +// keep a runtime observable side effect +globalThis.__requireArrayFirst = first diff --git a/test/fixtures/requireMainNotEq.cjs b/test/fixtures/requireMainNotEq.cjs new file mode 100644 index 0000000..4ce58aa --- /dev/null +++ b/test/fixtures/requireMainNotEq.cjs @@ -0,0 +1,5 @@ +if (require.main !== module) { + module.exports = { main: false } +} else { + module.exports = { main: true } +} diff --git a/test/fixtures/requireMainReversed.cjs b/test/fixtures/requireMainReversed.cjs new file mode 100644 index 0000000..5ab6c6c --- /dev/null +++ b/test/fixtures/requireMainReversed.cjs @@ -0,0 +1,5 @@ +if (module === require.main) { + module.exports = { main: true } +} else { + module.exports = { main: false } +} diff --git a/test/fixtures/tlaError.mjs b/test/fixtures/tlaError.mjs new file mode 100644 index 0000000..3ab8473 --- /dev/null +++ b/test/fixtures/tlaError.mjs @@ -0,0 +1,2 @@ +await Promise.resolve(1) +export const foo = 1 diff --git a/test/fixtures/weirdExport.cjs b/test/fixtures/weirdExport.cjs new file mode 100644 index 0000000..8e144f8 --- /dev/null +++ b/test/fixtures/weirdExport.cjs @@ -0,0 +1,3 @@ +exports['foo-bar'] = 1 +module.exports['baz+qux'] = 2 +exports['123num'] = 3 diff --git a/test/module.ts b/test/module.ts index 2654b20..89c989c 100644 --- a/test/module.ts +++ b/test/module.ts @@ -260,6 +260,15 @@ describe('@knighted/module', () => { ) }) + it('throws on eval when raising to esm', async () => { + const fixturePath = join(fixtures, 'evalOnly.cjs') + + await assert.rejects( + () => transform(fixturePath, { target: 'module' }), + /eval is not supported/i, + ) + }) + it('keeps nested requires via createRequire when lowering to esm', async t => { const fixturePath = join(fixtures, 'nestedRequire.cjs') const outFile = join(fixtures, 'nestedRequire.mjs') @@ -383,6 +392,27 @@ describe('@knighted/module', () => { assert.equal((mod as any).default.bag['foo-bar'], 'fb') }) + it('exports non-identifier keys when raising to esm', async t => { + const fixturePath = join(fixtures, 'weirdExport.cjs') + const outFile = join(fixtures, 'weirdExport.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + assert.ok(result.includes('__export_foo_bar')) + assert.ok(result.includes('__export_baz_qux')) + assert.ok(result.includes('__export__123num')) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any)['foo-bar'], 1) + assert.equal((mod as any)['baz+qux'], 2) + assert.equal((mod as any)['123num'], 3) + }) + it('rewrites require.main to import.meta.main', async t => { const fixturePath = join(fixtures, 'requireMain.cjs') const outFile = join(fixtures, 'requireMain.mjs') @@ -403,6 +433,46 @@ describe('@knighted/module', () => { assert.equal((mod as any).default.main, false) }) + it('rewrites module === require.main to import.meta.main', async t => { + const fixturePath = join(fixtures, 'requireMainReversed.cjs') + const outFile = join(fixtures, 'requireMainReversed.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + assert.ok(result.includes('import.meta.main')) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.main, false) + }) + + it('rewrites require.main inequality to negated import.meta.main', async t => { + const fixturePath = join(fixtures, 'requireMainNotEq.cjs') + const outFile = join(fixtures, 'requireMainNotEq.mjs') + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { target: 'module' }) + await writeFile(outFile, result) + + assert.ok(result.includes('!import.meta.main')) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).default.main, false) + }) + it('lifts exports inside control flow when lowering to esm', async t => { const fixturePath = join(fixtures, 'exportsControlFlow.cjs') const outFile = join(fixtures, 'exportsControlFlow.mjs') @@ -466,6 +536,15 @@ describe('@knighted/module', () => { assert.equal((mod as any).default.commonjs, true) }) + it('uses createRequire for non-hoistable static require patterns when raising to esm', async () => { + const fixturePath = join(fixtures, 'requireArray.cjs') + + const result = await transform(fixturePath, { target: 'module' }) + + assert.ok(result.includes('createRequire')) + assert.equal(result.includes('__requireArrayFirst'), true) + }) + it('throws when module or exports is shadowed in cjs to esm lowering', async () => { const fixturePath = join(fixtures, 'shadowedExports.cjs') @@ -493,6 +572,13 @@ describe('@knighted/module', () => { return { exportsObj, result } } + it('lowers side-effect import when targeting commonjs', async t => { + const { exportsObj, result } = await transformEsmToCjs(t, 'importSideEffect.mjs') + + assert.equal((exportsObj as any).loaded, true) + assert.ok(result.includes("require('./values.cjs');")) + }) + it('lowers default import with interop when targeting commonjs', async t => { const { exportsObj, result } = await transformEsmToCjs(t, 'esmDefault.mjs') @@ -518,6 +604,37 @@ describe('@knighted/module', () => { assert.equal(exportsObj.ns.bar, 'bar-val') }) + it('exports named function and class when lowering to commonjs', async t => { + const { exportsObj } = await transformEsmToCjs(t, 'exportNamedFunction.mjs') + + assert.equal(exportsObj.greet(), 'greet') + const box = new (exportsObj as any).Box() + assert.equal(box.value, 1) + }) + + it('exports named default function without top-level await when lowering to commonjs', async t => { + const { exportsObj } = await transformEsmToCjs(t, 'exportDefaultNamed.mjs') + + assert.equal(typeof exportsObj, 'function') + assert.equal((exportsObj as any)(), 'named-no-tla') + }) + + it('handles default re-export with interop when lowering to commonjs', async t => { + const { exportsObj, result } = await transformEsmToCjs(t, 'exportReexportDefault.mjs') + + assert.equal(exportsObj.anon(), 'anon') + assert.equal(exportsObj.named(), 'named-no-tla') + assert.equal(exportsObj.label, 'reexport') + assert.ok(result.includes('__interopDefault')) + }) + + it('handles export namespace specifier when lowering to commonjs', async t => { + const { exportsObj } = await transformEsmToCjs(t, 'exportNamespaceSpecifier.mjs') + + assert.equal(typeof exportsObj.ns.default, 'function') + assert.equal(exportsObj.ns.default(), 'anon') + }) + it('preserves re-exports and live bindings from commonjs sources', async t => { const { exportsObj } = await transformEsmToCjs(t, 'esmReexport.mjs') @@ -798,6 +915,254 @@ describe('@knighted/module', () => { ) }) + it('exports anonymous default function when lowering to commonjs', async () => { + const fixturePath = join(fixtures, 'exportDefaultAnon.mjs') + const result = await transform(fixturePath, { target: 'commonjs' }) + const outFile = join(fixtures, 'exportDefaultAnon.cjs') + const requireCjs = createRequire(import.meta.url) + + try { + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = requireCjs(outFile) + assert.equal(typeof mod, 'function') + assert.equal(mod(), 'anon') + } finally { + await rm(outFile, { force: true }) + } + }) + + it('handles export namespace all when lowering to commonjs', async () => { + const fixturePath = join(fixtures, 'exportNamespaceAll.mjs') + const result = await transform(fixturePath, { target: 'commonjs' }) + const outFile = join(fixtures, 'exportNamespaceAll.cjs') + const requireCjs = createRequire(import.meta.url) + + try { + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = requireCjs(outFile) + assert.equal(mod.bag.esmodule, true) + assert.equal(mod.bag.foo, 'bar') + assert.equal(typeof mod.bag.obj, 'object') + } finally { + await rm(outFile, { force: true }) + } + }) + + it('respects cjsDefault option when lowering default import', async t => { + const fixturePath = join(fixtures, 'esmDefault.mjs') + const outFile = join(fixtures, 'esmDefault.cjs') + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { + target: 'commonjs', + cjsDefault: 'none', + }) + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const requireCjs = createRequire(import.meta.url) + const mod = requireCjs(outFile) + assert.equal(mod, 'default-val') + }) + + it('honors cjsDefault module-exports when lowering to commonjs', async t => { + const fixturePath = join(fixtures, 'cjsDefaultModuleExports.mjs') + const outFile = join(fixtures, 'cjsDefaultModuleExports.cjs') + const requireCjs = createRequire(import.meta.url) + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { + target: 'commonjs', + cjsDefault: 'module-exports', + }) + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = requireCjs(outFile) + assert.equal(mod.value, 'bar') + assert.equal(result.includes('__interopDefault'), false) + }) + + it('respects liveBindings off when lowering to commonjs', async t => { + const fixturePath = join(fixtures, 'liveBindingsOff.mjs') + const outFile = join(fixtures, 'liveBindingsOff.cjs') + const requireCjs = createRequire(import.meta.url) + + t.after(() => { + rm(outFile, { force: true }) + }) + + const result = await transform(fixturePath, { + target: 'commonjs', + liveBindings: 'off', + }) + await writeFile(outFile, result) + assert.ok(result.includes('exports.foo = foo;')) + assert.ok(result.includes('exports.inc = inc;')) + + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = requireCjs(outFile) + assert.equal(mod.foo, 1) + assert.equal(mod.inc(2), 3) + }) + + it('throws on top-level await when targeting commonjs with error policy', async () => { + const fixturePath = join(fixtures, 'tlaError.mjs') + await assert.rejects( + () => transform(fixturePath, { target: 'commonjs', topLevelAwait: 'error' }), + /Top-level await is not supported/i, + ) + }) + + it('wraps default function export when TLA present', async () => { + const fixturePath = join(fixtures, 'exportDefaultTlaNamed.mjs') + const result = await transform(fixturePath, { + target: 'commonjs', + topLevelAwait: 'wrap', + }) + const outFile = join(fixtures, 'exportDefaultTlaNamed.cjs') + const requireCjs = createRequire(import.meta.url) + + try { + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = requireCjs(outFile) + assert.equal(typeof mod.__tla?.then, 'function') + await mod.__tla + assert.equal(mod.default(), 'tla-named') + } finally { + await rm(outFile, { force: true }) + } + }) + + it('wraps anonymous default export when TLA present', async () => { + const fixturePath = join(fixtures, 'exportDefaultAnonTla.mjs') + const result = await transform(fixturePath, { + target: 'commonjs', + topLevelAwait: 'wrap', + }) + const outFile = join(fixtures, 'exportDefaultAnonTla.cjs') + const requireCjs = createRequire(import.meta.url) + + try { + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = requireCjs(outFile) + assert.equal(typeof mod.__tla?.then, 'function') + await mod.__tla + assert.equal(mod.default(), 'tla-anon') + } finally { + await rm(outFile, { force: true }) + } + }) + + it('strips bare module.exports expression when raising to esm', async () => { + const fixturePath = join(fixtures, 'bareModuleExports.cjs') + const result = await transform(fixturePath, { target: 'module' }) + assert.equal(result.includes('module.exports;'), false) + const outFile = join(fixtures, 'bareModuleExports.mjs') + + try { + await writeFile(outFile, result) + const { status } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(status, 0) + const mod = await import(pathToFileURL(outFile).href) + assert.equal((mod as any).foo, 1) + } finally { + await rm(outFile, { force: true }) + } + }) + + it('converts a small commonjs project to esm', async t => { + const projectRoot = join(fixtures, 'projects', 'cjs-app') + const entry = join(projectRoot, 'index.cjs') + const lib = join(projectRoot, 'lib.cjs') + const outEntry = join(projectRoot, 'index.mjs') + const outLib = join(projectRoot, 'lib.mjs') + + t.after(() => { + rm(outEntry, { force: true }) + rm(outLib, { force: true }) + }) + + const [entryResult, libResult] = await Promise.all([ + transform(entry, { + target: 'module', + rewriteSpecifier: '.mjs', + }), + transform(lib, { + target: 'module', + rewriteSpecifier: '.mjs', + }), + ]) + + await Promise.all([writeFile(outEntry, entryResult), writeFile(outLib, libResult)]) + + const { status } = spawnSync('node', [outEntry], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = await import(pathToFileURL(outEntry).href) + const result = (mod as any).main() + assert.equal(result.bumped, 3) + assert.equal(result.value, 1) + + // Dynamic require path should still work under createRequire fallback + const libMod = await import(pathToFileURL(outLib).href) + assert.equal(libMod.dynamicLoad('./lib.mjs'), 1) + }) + + it('converts a small esm project to commonjs with top-level await wrapping', async t => { + const projectRoot = join(fixtures, 'projects', 'esm-app') + const entry = join(projectRoot, 'index.mjs') + const lib = join(projectRoot, 'lib.mjs') + const outEntry = join(projectRoot, 'index.cjs') + const outLib = join(projectRoot, 'lib.cjs') + const requireCjs = createRequire(import.meta.url) + + t.after(() => { + rm(outEntry, { force: true }) + rm(outLib, { force: true }) + }) + + const [entryResult, libResult] = await Promise.all([ + transform(entry, { + target: 'commonjs', + rewriteSpecifier: '.cjs', + topLevelAwait: 'wrap', + importMetaMain: 'warn', + }), + transform(lib, { target: 'commonjs', rewriteSpecifier: '.cjs' }), + ]) + + await Promise.all([writeFile(outEntry, entryResult), writeFile(outLib, libResult)]) + + const { status } = spawnSync('node', [outEntry], { stdio: 'inherit' }) + assert.equal(status, 0) + + const mod = requireCjs(outEntry) + assert.equal(typeof mod.__tla?.then, 'function') + await mod.__tla + const result = await mod.run() + assert.equal(result.sum, 2) + assert.ok(result.after >= 1) + + // importMetaMain warn should be embedded in generated code + assert.ok(entryResult.includes('import.meta.main is not supported')) + }) + it('writes transformed source to a file when option enabled', async t => { const mjs = join(fixtures, 'transformed.mjs') const cjs = join(fixtures, 'transformed.cjs') diff --git a/test/utils.ts b/test/utils.ts index 6cc7064..ecf28f6 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -6,6 +6,7 @@ import { spawnSync } from 'node:child_process' import { parse } from '#parse' import { collectModuleIdentifiers } from '#utils/identifiers.js' +import { collectCjsExports } from '#utils/exports.js' import { getLangFromExt } from '#utils/lang.js' // Use fixtures to more easily track character offsets and line numbers in test cases. @@ -193,6 +194,63 @@ describe('collectModuleIdentifiers', () => { }) }) +describe('collectCjsExports', () => { + it('collects aliases, literals, getters, and reassignments', async () => { + const source = ` + const alias = exports; + const mod = module.exports; + const dyn = 'dyn'; + const num = 42; + const tmpl = \`tmpl\`; + let local = 1; + const getter = function getter() { return local }; + const getter2 = getter; + exports[tmpl] = local; + alias.foo = local; + mod['bar'] = num; + exports[dyn] = 2; + module.exports.baz = function baz() {}; + ({ value: module.exports.qux } = { value: 3 }); + Object.assign(exports, { assignVal: local, aliasAssign: num }); + Object.defineProperty(exports, 'dp', { get: getter }); + Object.defineProperties(module.exports, { + multi: { value: num }, + got: { get: getter2 }, + }); + exports.fromLocal = local; + local = 5; + ` + + const ast = parse('file.cjs', source).program + const exportsMap = await collectCjsExports(ast) + + const keys = Array.from(exportsMap.keys()).sort() + assert.deepEqual(keys, [ + 'aliasAssign', + 'assignVal', + 'bar', + 'baz', + 'dp', + 'dyn', + 'foo', + 'fromLocal', + 'got', + 'multi', + 'qux', + 'tmpl', + ]) + + assert.equal(exportsMap.get('dyn')?.fromIdentifier, undefined) + assert.equal(exportsMap.get('foo')?.fromIdentifier, 'local') + assert.equal(exportsMap.get('bar')?.via.has('module.exports'), true) + assert.equal(exportsMap.get('dp')?.hasGetter, true) + assert.equal(exportsMap.get('got')?.hasGetter, true) + assert.equal(exportsMap.get('multi')?.hasGetter ?? false, false) + assert.equal(exportsMap.get('fromLocal')?.reassignments.length, 1) + assert.equal(exportsMap.get('tmpl')?.writes.length ?? 0, 1) + }) +}) + describe('getLangFromExt', () => { it('returns language for js-like extensions', () => { assert.equal(getLangFromExt('file.js'), 'js')