diff --git a/.changeset/fix-ses-star-export-cycle-rename.md b/.changeset/fix-ses-star-export-cycle-rename.md new file mode 100644 index 0000000000..02b0895df5 --- /dev/null +++ b/.changeset/fix-ses-star-export-cycle-rename.md @@ -0,0 +1,11 @@ +--- +'ses': patch +--- + +Fix a star-export cycle defect where a module reached more than once via `export *` and a renaming reexport with a different exported name (`export { y as x } from ...`) raised a spurious `SyntaxError: ... does not provide an export named 'X'` (latterly `TypeError: notify is not a function`). +The reexport wire-up now installs a deferred forwarding notifier that resolves through the upstream's notifier table on first subscription, so cyclic star-export fixed-points converge. + +Additionally, enforce ECMA-262 temporal-dead-zone semantics for cross-module reads through a module namespace import during cycle evaluation, for both the `export *` and `export { y } from` reexport forms. +Previously, when the importing side of a cycle observed the upstream's binding through a namespace import (`r.y`) while the upstream's body was still on the evaluation stack and the binding's declaration had not yet been evaluated, the read returned the uninitialized slot value instead of raising; SES now matches Node.js's reference behavior and raises `ReferenceError` for `const` and `let` bindings during the TDZ window, while `var` bindings continue to read `undefined` because the hoisting preamble pre-initializes them before any downstream observation. + +Resolves endojs/endo#59. diff --git a/packages/compartment-mapper/designs/subpath-pattern-replacement.md b/packages/compartment-mapper/designs/subpath-pattern-replacement.md index a0711b5d76..11d6815e76 100644 --- a/packages/compartment-mapper/designs/subpath-pattern-replacement.md +++ b/packages/compartment-mapper/designs/subpath-pattern-replacement.md @@ -209,14 +209,16 @@ Assertions are shared via `_subpath-patterns-assertions.js`, so parity is verified by construction: if both test suites pass, the behaviors are equivalent. -- `subpath-patterns-node-parity.test.js` runs fixtures under plain - Node.js using dynamic `import()`. +- `subpath-patterns.test.js` runs fixtures both through the `scaffold()` + harness (the SES treatment, exercising `loadLocation`, `importLocation`, + `makeArchive`, `parseArchive`, `writeArchive`, `loadArchive`, and + `importArchive`) and under plain Node.js via dynamic `import()` (the + Node.js parity treatment). + Both treatments are registered in the same module, back-to-back per + scenario where the scenario has both sides. - `subpath-patterns-node-condition.node-condition.test.js` runs under `--conditions=blue-moon` via ses-ava (`nodeArguments: ['-C', 'blue-moon']` in `_ava-node-condition.config.js`). -- `subpath-patterns.test.js` runs fixtures through the `scaffold()` - harness, exercising `loadLocation`, `importLocation`, `makeArchive`, - `parseArchive`, `writeArchive`, `loadArchive`, and `importArchive`. ### Unit Tests (`pattern-replacement.test.js`) diff --git a/packages/compartment-mapper/test/_cycle-cjs-reexporter-assertions.js b/packages/compartment-mapper/test/_cycle-cjs-reexporter-assertions.js new file mode 100644 index 0000000000..31870617b2 --- /dev/null +++ b/packages/compartment-mapper/test/_cycle-cjs-reexporter-assertions.js @@ -0,0 +1,55 @@ +/** + * Shared assertion logic for the cyclic CommonJS reexporter scenario. + * Both the Node.js parity test and the Compartment Mapper test import from + * this module so the expected values live in exactly one place. If both + * tests pass, parity with Node.js is verified by construction. + * + * The fixture under fixtures-cycle-cjs-reexporter/node_modules/app/ exercises + * this arrangement, all three modules being CommonJS: + * + * star-reexporter.cjs: Object.assign(exports, require('./export-renamer.cjs')); + * export-renamer.cjs: require('./star-reexporter.cjs'); + * Object.defineProperty(exports, 'x', { + * get() { return module.exports.y; }, enumerable: true }); + * exports.y = 45; + * main.js: const reexp = require('./star-reexporter.cjs'); + * const ren = require('./export-renamer.cjs'); + * exports.captured = reexp.x; + * exports.namespace1 = { x: reexp.x, y: reexp.y }; + * exports.namespace2 = { x: ren.x, y: ren.y }; + * + * In a pure-CommonJS cycle, the reexporter's `Object.assign` reads the + * renamer's `x` getter after the renamer has set `y = 45`, so the copied + * value is 45. Both namespaces project { x: 45, y: 45 }. Node.js and the + * compartment mapper agree on this shape, so the same assertions apply to + * both layers. + * + * The companion divergence scenario (ESM module participating in a cycle + * with a CommonJS module) is exercised by fixtures-cycle-esm-in-cjs and + * its tests; Node.js rejects that topology with ERR_REQUIRE_CYCLE_MODULE + * while SES allows it. + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +export const expectedCaptured = 45; +export const expectedNamespace1 = { x: 45, y: 45 }; +export const expectedNamespace2 = { x: 45, y: 45 }; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertCycleCjsReexporter = (t, namespace) => { + t.is(namespace.captured, expectedCaptured); + t.deepEqual( + { x: namespace.namespace1.x, y: namespace.namespace1.y }, + expectedNamespace1, + ); + t.deepEqual( + { x: namespace.namespace2.x, y: namespace.namespace2.y }, + expectedNamespace2, + ); +}; diff --git a/packages/compartment-mapper/test/_cycle-rename-assertions.js b/packages/compartment-mapper/test/_cycle-rename-assertions.js new file mode 100644 index 0000000000..7038e5a385 --- /dev/null +++ b/packages/compartment-mapper/test/_cycle-rename-assertions.js @@ -0,0 +1,45 @@ +/** + * Shared assertion logic for the cyclic star-export with renaming reexport + * regression (endojs/endo#59). Both the Node.js parity test and the + * Compartment Mapper test import from this module so the expected values + * live in exactly one place. If both tests pass, parity with Node.js is + * verified by construction. + * + * The fixture under fixtures-cycle-rename/node_modules/app/ exercises this + * arrangement: + * + * star-reexporter.js: export * from './export-renamer.js'; + * export-renamer.js: export { y as x } from './star-reexporter.js'; + * export var y = 45; + * main.js: import { x } from './star-reexporter.js'; + * import * as ns1 from './star-reexporter.js'; + * import * as ns2 from './export-renamer.js'; + * export const captured = x; + * export const namespace1 = { x: ns1.x, y: ns1.y }; + * export const namespace2 = { x: ns2.x, y: ns2.y }; + * + * Before the fix, the SES linker visited star-reexporter while its + * star-imported notifier for `y` had not yet been wired. The synchronous + * wireUp at the cycle's back-edge then passed `undefined` as the upstream + * notifier, manifesting as `TypeError: notify is not a function`. Node.js + * does not exhibit the defect, so the parity test pinned both layers to a + * single expected shape. + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +export const expectedCaptured = 45; +export const expectedNamespace1 = { x: 45, y: 45 }; +export const expectedNamespace2 = { x: 45, y: 45 }; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertCycleRename = (t, namespace) => { + t.is(namespace.captured, expectedCaptured); + t.deepEqual(namespace.namespace1, expectedNamespace1); + t.deepEqual(namespace.namespace2, expectedNamespace2); +}; diff --git a/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js b/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js new file mode 100644 index 0000000000..4bdd44b094 --- /dev/null +++ b/packages/compartment-mapper/test/_cycle-rename-unused-assertions.js @@ -0,0 +1,48 @@ +/** + * Shared assertion logic for the unused-live-binding shape of the cyclic + * star-export with renaming reexport regression. This is the companion to + * the populated shape exercised by `_cycle-rename-assertions.js`; the only + * difference is that the renamer's `export var y` here has no initializer, + * so the live binding is declared but never updated. Every projection of the + * cycle therefore reads `undefined`. Both the Node.js parity test and the + * Compartment Mapper test import from this module so the expected values + * live in exactly one place; if both tests pass, parity with Node.js is + * verified by construction. + * + * The fixture under fixtures-cycle-rename-unused/node_modules/app/ exercises + * this arrangement: + * + * star-reexporter.js: export * from './export-renamer.js'; + * export-renamer.js: export { y as x } from './star-reexporter.js'; + * export var y; + * main.js: import { x } from './star-reexporter.js'; + * import * as ns1 from './star-reexporter.js'; + * import * as ns2 from './export-renamer.js'; + * export const captured = x; + * export const namespace1 = { x: ns1.x, y: ns1.y }; + * export const namespace2 = { x: ns2.x, y: ns2.y }; + * + * The deferring closure introduced by the cyclic-star-export fix queues + * subscribers until the upstream notifier resolves, then forwards them. + * With no initializer the upstream's value never updates, so every read is + * `undefined`. Node.js exhibits the same shape, so the parity test pins + * both layers to a single expected projection. + * + * @module + */ + +/** @import {ExecutionContext} from 'ava' */ + +export const expectedCaptured = undefined; +export const expectedNamespace1 = { x: undefined, y: undefined }; +export const expectedNamespace2 = { x: undefined, y: undefined }; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + */ +export const assertCycleRenameUnused = (t, namespace) => { + t.is(namespace.captured, expectedCaptured); + t.deepEqual(namespace.namespace1, expectedNamespace1); + t.deepEqual(namespace.namespace2, expectedNamespace2); +}; diff --git a/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js b/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js new file mode 100644 index 0000000000..ae3d37b422 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-cjs-reexporter.test.js @@ -0,0 +1,56 @@ +/** + * Cyclic CommonJS reexporter scenario exercised twice in this module, + * back-to-back: once through the compartment-mapper test scaffold (the SES + * treatment) and once through plain Node.js (the parity treatment). Both + * treatments target the same fixture and assert the same expected values + * through the shared assertion module. The paired registration makes the + * shared coverage legible at a glance and pins the compartment mapper's + * CommonJS cycle behavior to Node.js's reference behavior. + * + * This is the pure-CommonJS counterpart to the ESM-in-CJS-cycle divergence + * exercised by cycle-esm-in-cjs.test.js, where Node.js rejects the topology + * with ERR_REQUIRE_CYCLE_MODULE but SES allows it. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { assertCycleCjsReexporter } from './_cycle-cjs-reexporter-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-cjs-reexporter/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 3; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleCjsReexporter(t, namespace); +}; + +// SES treatment: load through the compartment-mapper scaffold, which +// exercises loadLocation, importLocation, and the archive paths. +scaffold( + 'cycle-cjs-reexporter (ses)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); + +// Node.js parity treatment: dynamically import the same `main.js` directly +// under plain Node.js (no SES, no compartment mapper) and assert the same +// expected values. Node exposes a CommonJS module's `module.exports` as the +// namespace's default export, so the shared assertion module is reused by +// projecting through `default`. +test('cycle-cjs-reexporter (node parity)', async t => { + t.plan(3); + const moduleNamespace = await import(fixture); + assertCycleCjsReexporter(t, moduleNamespace.default); +}); diff --git a/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js b/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js new file mode 100644 index 0000000000..8b494400ef --- /dev/null +++ b/packages/compartment-mapper/test/cycle-esm-in-cjs.test.js @@ -0,0 +1,83 @@ +/** + * Cyclic ESM-in-CommonJS divergence scenario exercised twice in this module, + * back-to-back: once through the compartment-mapper test scaffold (the SES + * treatment, where the topology loads and `main.bridgeValue` resolves to 42) + * and once through plain Node.js (the parity treatment, where Node rejects + * the topology with ERR_REQUIRE_CYCLE_MODULE). The paired registration + * verifies the divergence programmatically rather than narratively: SES + * allows the topology that Node rejects. + * + * Topology (under fixtures-cycle-esm-in-cjs/node_modules/app/): + * + * main.mjs: import * as bridge from './bridge.cjs'; + * export const bridgeValue = bridge.value; + * bridge.cjs: const m = require('./peer.mjs'); + * exports.value = m.value; + * peer.mjs: import { value as bridgeValue } from './bridge.cjs'; + * export const value = 42; + * + * On the SES side, bridge.cjs reads `m.value` from peer.mjs's namespace + * after the cycle's back-edge has reached peer.mjs (which then re-entered + * bridge.cjs). Because the ESM side resolves through live bindings, by the + * time main reads bridge.value the snapshot capture in bridge.cjs sees + * peer.mjs's `value = 42`. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import process from 'process'; +import { spawnSync } from 'child_process'; +import { fileURLToPath } from 'url'; +import { scaffold } from './scaffold.js'; + +const fixtureUrl = new URL( + 'fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs', + import.meta.url, +); +const fixture = fixtureUrl.toString(); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + t.is(namespace.bridgeValue, 42); +}; + +// SES treatment: load through the compartment-mapper scaffold. SES allows +// the topology Node rejects and exposes the cycle's snapshot / live-binding +// shape on the namespace. +scaffold( + 'cycle-esm-in-cjs divergence (ses)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); + +// Node.js parity treatment: spawn a fresh Node process to execute the same +// fixture. The expected outcome is a non-zero exit with +// ERR_REQUIRE_CYCLE_MODULE printed on stderr. Spawning isolates the failure +// from the test runner's own module graph and keeps the rest of the suite +// running. Together with the SES treatment above, this pins the divergence +// programmatically: SES allows what Node rejects. +test('cycle-esm-in-cjs divergence (node parity)', t => { + t.plan(2); + const result = spawnSync(process.execPath, [fileURLToPath(fixtureUrl)], { + encoding: 'utf8', + }); + t.not( + result.status, + 0, + `Expected Node to reject ESM-in-CJS-cycle, got exit ${result.status}`, + ); + t.regex( + result.stderr, + /ERR_REQUIRE_CYCLE_MODULE/, + `Expected ERR_REQUIRE_CYCLE_MODULE in stderr, got:\n${result.stderr}`, + ); +}); diff --git a/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js new file mode 100644 index 0000000000..be9c665b14 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js @@ -0,0 +1,231 @@ +/** + * TDZ-observation matrix for the cyclic star-export and named-reexport + * scenarios. Each row in the inline `SCENARIOS` table below corresponds to + * one cell of the matrix and to one fixture directory under + * `packages/compartment-mapper/test/` (named by the scenario's `fixture` + * field). Each fixture exports a `probe` value captured by the + * star-reexporter (or named-reexporter) during its top-level evaluation: + * the probe reads the renamer's binding `y` through a namespace import + * (`r.y`) inside a try block and records either the value (when the + * binding is already initialized) or the error name (when the read raises + * during the temporal dead zone window). + * + * The matrix axes are: + * + * 1. Which module main.js imports first (the export-renamer or the + * star-reexporter or the named-reexporter). The first-imported module + * starts evaluating first; depth-first traversal of the cycle then + * determines which module's body runs while the other is on the + * evaluation stack with bindings not yet initialized. + * 2. The renamer's binding form for `y` (`const`, `let`, or `var`). Under + * ECMA-262 semantics, `const` and `let` create a binding that is in + * the temporal dead zone until its declaration is evaluated, so a + * read raises ReferenceError; `var` is hoisted and reads `undefined` + * until the assignment runs. + * 3. Whether the upstream side of the cycle is reached through `export *` + * (the six star-reexport cells) or through `export { y } from` (the + * single named-reexport cell). + * + * Each scenario is exercised twice in this module, back-to-back: once + * through the compartment-mapper scaffold (the SES treatment) and once + * through plain Node.js (the parity treatment). Both treatments assert the + * same expected probe value, declared once per row in `expectedProbe`. The + * paired registration makes the shared coverage of each fixture legible at + * a glance: a fixture appears in this module exactly twice, with the same + * expected probe, and SES is confirmed to enforce the same TDZ on the + * cross-module namespace path that Node.js enforces natively. + * + * The companion in-process scenarios live in + * `packages/ses/test/import-gauntlet.test.js` as the seven matrix cells + * starting at "cyclic star export with renaming reexport, renamer imported + * first, const binding observes ReferenceError during temporal dead zone". + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; + +/** + * @typedef {object} CycleRenameTdzScenario + * @property {string} name + * Short identifier used in test titles and the fixture directory's + * final path component. Each name is unique across the matrix. + * @property {string} fixture + * The fixture directory name under + * `packages/compartment-mapper/test/`. Its `node_modules/app/main.js` + * is the import target for both the SES treatment (through the + * compartment-mapper scaffold) and the Node.js parity treatment. + * @property {'star'|'named'} reexportForm + * Whether the upstream side of the cycle is reached through + * `export *` (`star`) or through `export { y } from` (`named`). + * @property {'const'|'let'|'var'} binding + * The renamer's binding form for `y`. `const` and `let` participate in + * the temporal dead zone; `var` is hoisted. + * @property {'renamer-first'|'star-first'} order + * Which module main.js imports first. The first-imported module starts + * evaluating first; the second is the one that observes the cycle + * partner mid-evaluation. + * @property {{kind: 'error', name: string}|{kind: 'value', value: unknown}} expectedProbe + * What the probe is expected to record: + * - `{ kind: 'error', name: 'ReferenceError' }` for cells where the + * cross-module namespace read lands during the TDZ window of a + * lexically-bound (`const` or `let`) declaration that has not yet + * been initialized. + * - `{ kind: 'value', value: }` for cells where the read + * either follows depth-first cycle resolution of the renamer's body + * (the star-first cells, expected `42`) or observes the hoisting + * preamble's pre-initialization of a `var` binding (renamer-first + * plus `var`, expected `undefined`). + */ + +/** @type {ReadonlyArray} */ +const SCENARIOS = Object.freeze([ + // Renamer imported first: depth-first traversal evaluates the + // star-reexporter's body while the renamer is on the evaluation stack + // with its binding `y` not yet initialized. Under ECMA-262, `const` and + // `let` raise ReferenceError on read; `var` reads undefined (the + // hoisting preamble clears the upstream TDZ before the downstream + // observes). + Object.freeze({ + name: 'star const renamer-first', + fixture: 'fixtures-cycle-rename-tdz-const-renamer-first', + reexportForm: 'star', + binding: 'const', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), + Object.freeze({ + name: 'star let renamer-first', + fixture: 'fixtures-cycle-rename-tdz-let-renamer-first', + reexportForm: 'star', + binding: 'let', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), + Object.freeze({ + name: 'star var renamer-first', + fixture: 'fixtures-cycle-rename-tdz-var-renamer-first', + reexportForm: 'star', + binding: 'var', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: undefined, + }), + }), + // Star-reexporter imported first: depth-first cycle resolution + // evaluates the renamer's body to completion before the star-reexporter + // body runs, so the probe captures the assigned value for every binding + // form. The "star reexporter imported first" cases therefore have no + // TDZ window to observe; they are the expected non-observation that + // completes the matrix. + Object.freeze({ + name: 'star const star-first', + fixture: 'fixtures-cycle-rename-tdz-const-star-first', + reexportForm: 'star', + binding: 'const', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + Object.freeze({ + name: 'star let star-first', + fixture: 'fixtures-cycle-rename-tdz-let-star-first', + reexportForm: 'star', + binding: 'let', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + Object.freeze({ + name: 'star var star-first', + fixture: 'fixtures-cycle-rename-tdz-var-star-first', + reexportForm: 'star', + binding: 'var', + order: 'star-first', + expectedProbe: Object.freeze({ + kind: 'value', + value: 42, + }), + }), + // Named-reexport variant with renamer imported first: the cycle is + // reached through `export { y } from` rather than `export *`. The TDZ + // semantics live with the binding, not with the reexport form, so the + // const cell raises ReferenceError just as it does for the + // star-reexport cell. This confirms the gap is not specific to + // `export *`. + Object.freeze({ + name: 'named const renamer-first', + fixture: 'fixtures-cycle-named-reexport-tdz-const-renamer-first', + reexportForm: 'named', + binding: 'const', + order: 'renamer-first', + expectedProbe: Object.freeze({ + kind: 'error', + name: 'ReferenceError', + }), + }), +]); + +const fixtureAssertionCount = 1; + +/** + * @param {ExecutionContext} t + * @param {object} namespace + * @param {CycleRenameTdzScenario['expectedProbe']} expectedProbe + */ +const assertCycleRenameTdz = (t, namespace, expectedProbe) => { + t.deepEqual(namespace.probe, expectedProbe); +}; + +// Register one SES test (through the compartment-mapper scaffold) and one +// Node.js parity test for each scenario, back-to-back, so the shared +// coverage of each fixture is legible at a glance. Both treatments target +// the same `main.js` and assert the same `expectedProbe`. +for (const scenario of SCENARIOS) { + const fixture = new URL( + `${scenario.fixture}/node_modules/app/main.js`, + import.meta.url, + ).toString(); + + /** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ + const assertFixture = (t, { namespace }) => { + assertCycleRenameTdz(t, namespace, scenario.expectedProbe); + }; + + // SES treatment: load through the compartment-mapper scaffold, which + // exercises loadLocation, importLocation, and the archive paths. + scaffold( + `cycle-rename-tdz ${scenario.name} (ses)`, + test, + fixture, + assertFixture, + fixtureAssertionCount, + ); + + // Node.js parity treatment: import the same `main.js` directly under + // plain Node.js (no SES, no compartment mapper) and assert the same + // probe. If both treatments pass, SES enforces the same + // temporal-dead-zone (or hoisting, or cycle-resolution) semantics on + // the cross-module namespace read as Node.js for this cell. + test(`cycle-rename-tdz ${scenario.name} (node parity)`, async t => { + t.plan(1); + const namespace = await import(fixture); + assertCycleRenameTdz(t, namespace, scenario.expectedProbe); + }); +} diff --git a/packages/compartment-mapper/test/cycle-rename-unused.test.js b/packages/compartment-mapper/test/cycle-rename-unused.test.js new file mode 100644 index 0000000000..aefe64652d --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename-unused.test.js @@ -0,0 +1,53 @@ +/** + * Companion to cycle-rename.test.js covering the unused-live-binding shape + * of the cyclic star-export regression (endojs/endo#59). The renamer's + * `export var y` has no initializer; every projection of the cycle reads + * `undefined`. Exercised twice in this module, back-to-back: once through + * the compartment-mapper test scaffold (the SES treatment) and once through + * plain Node.js (the parity treatment). Both treatments target the same + * fixture and assert the same expected values through the shared assertion + * module. The paired registration makes the shared coverage legible at a + * glance and pins the compartment mapper's behavior for this shape to + * Node.js's reference behavior. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { assertCycleRenameUnused } from './_cycle-rename-unused-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename-unused/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 3; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRenameUnused(t, namespace); +}; + +// SES treatment: load through the compartment-mapper scaffold, which +// exercises loadLocation, importLocation, and the archive paths. +scaffold( + 'cycle-rename-unused unused live binding (ses)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); + +// Node.js parity treatment: dynamically import the same `main.js` directly +// under plain Node.js (no SES, no compartment mapper) and assert the same +// expected values. +test('cycle-rename-unused unused live binding (node parity)', async t => { + t.plan(3); + const namespace = await import(fixture); + assertCycleRenameUnused(t, namespace); +}); diff --git a/packages/compartment-mapper/test/cycle-rename.test.js b/packages/compartment-mapper/test/cycle-rename.test.js new file mode 100644 index 0000000000..40a445b073 --- /dev/null +++ b/packages/compartment-mapper/test/cycle-rename.test.js @@ -0,0 +1,51 @@ +/** + * Regression for endojs/endo#59 (cyclic star export with renaming reexport) + * exercised twice in this module, back-to-back: once through the + * compartment-mapper test scaffold (the SES treatment) and once through + * plain Node.js (the parity treatment). Both treatments target the same + * three-module fixture and assert the same expected values through the + * shared assertion module. The paired registration makes the shared + * coverage legible at a glance and pins the compartment mapper's linker + * behavior to Node.js's reference behavior. + */ + +/** @import {ExecutionContext} from 'ava' */ + +import 'ses'; +import test from 'ava'; +import { scaffold } from './scaffold.js'; +import { assertCycleRename } from './_cycle-rename-assertions.js'; + +const fixture = new URL( + 'fixtures-cycle-rename/node_modules/app/main.js', + import.meta.url, +).toString(); + +const fixtureAssertionCount = 3; + +/** + * @param {ExecutionContext} t + * @param {{namespace: object}} result + */ +const assertFixture = (t, { namespace }) => { + assertCycleRename(t, namespace); +}; + +// SES treatment: load through the compartment-mapper scaffold, which +// exercises loadLocation, importLocation, and the archive paths. +scaffold( + 'cycle-rename (ses)', + test, + fixture, + assertFixture, + fixtureAssertionCount, +); + +// Node.js parity treatment: dynamically import the same `main.js` directly +// under plain Node.js (no SES, no compartment mapper) and assert the same +// expected values. +test('cycle-rename (node parity)', async t => { + t.plan(3); + const namespace = await import(fixture); + assertCycleRename(t, namespace); +}); diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/export-renamer.cjs b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/export-renamer.cjs new file mode 100644 index 0000000000..869e3b9671 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/export-renamer.cjs @@ -0,0 +1,14 @@ +// CommonJS analog of `export { y as x } from './star-reexporter.cjs'; export +// var y = 45`: expose `x` as a live getter onto our own `y`. The require to +// star-reexporter participates in the cycle; the value returned is the cached +// (partial) exports object the reexporter had at the moment it dispatched to +// us, but our reads do not depend on its contents. +require('./star-reexporter.cjs'); +Object.defineProperty(exports, 'x', { + get() { + return module.exports.y; + }, + enumerable: true, + configurable: true, +}); +exports.y = 45; diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/main.js new file mode 100644 index 0000000000..e7274051fc --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/main.js @@ -0,0 +1,6 @@ +const reexp = require('./star-reexporter.cjs'); +const ren = require('./export-renamer.cjs'); + +exports.captured = reexp.x; +exports.namespace1 = { x: reexp.x, y: reexp.y }; +exports.namespace2 = { x: ren.x, y: ren.y }; diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/package.json new file mode 100644 index 0000000000..2f5b17bcfe --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "commonjs", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/star-reexporter.cjs b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/star-reexporter.cjs new file mode 100644 index 0000000000..2700929ad7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-cjs-reexporter/node_modules/app/star-reexporter.cjs @@ -0,0 +1,6 @@ +// CommonJS analog of `export * from './export-renamer.cjs'`: eagerly copy the +// renamer's own enumerable properties onto our exports. Because both modules +// participate in a cycle, the values observed here are whatever the renamer +// had set on its exports by the re-entry instant. Live-binding shapes (the +// renamer's `x` getter) project their current value at copy time. +Object.assign(exports, require('./export-renamer.cjs')); diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/bridge.cjs b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/bridge.cjs new file mode 100644 index 0000000000..b1b0465152 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/bridge.cjs @@ -0,0 +1,9 @@ +// A CommonJS module that requires an ESM module which itself imports back +// from this CommonJS module. Node.js rejects this topology with +// ERR_REQUIRE_CYCLE_MODULE because the ESM module participating in the cycle +// must be evaluated synchronously to satisfy require(), but the cycle's +// back-edge re-enters before that evaluation can complete. SES allows the +// topology, treating the cycle's back-edge with snapshot semantics on the +// CommonJS side and live-binding semantics on the ESM side. +const m = require('./peer.mjs'); +exports.value = m.value; diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs new file mode 100644 index 0000000000..3846307702 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/main.mjs @@ -0,0 +1,3 @@ +import * as bridge from './bridge.cjs'; + +export const bridgeValue = bridge.value; diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/package.json new file mode 100644 index 0000000000..9556405374 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.mjs", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/peer.mjs b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/peer.mjs new file mode 100644 index 0000000000..067d75ed47 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-esm-in-cjs/node_modules/app/peer.mjs @@ -0,0 +1,10 @@ +// An ESM module that imports from a CommonJS module that itself requires +// this ESM module. The cycle's back-edge (from bridge.cjs into this module) +// is what Node.js rejects when bridge.cjs require()s us, because evaluating +// us synchronously would re-enter bridge.cjs whose evaluation has not yet +// returned. SES allows the topology, treating the back-edge with snapshot +// semantics on the CommonJS side and live-binding semantics on the ESM +// side. +import { value as bridgeValue } from './bridge.cjs'; + +export const value = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..937e0389d8 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './named-reexporter.js'; +export const y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..889143f312 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './named-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/named-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/named-reexporter.js new file mode 100644 index 0000000000..43d1164dec --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/named-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export { y } from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-named-reexport-tdz-const-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..d4fa0d42d0 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export const y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..d1dfdb36b9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './star-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-renamer-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..d4fa0d42d0 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export const y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js new file mode 100644 index 0000000000..309be48ded --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as s from './star-reexporter.js'; +import * as r from './export-renamer.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-const-star-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..b4e3e31aae --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export let y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..d1dfdb36b9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './star-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-renamer-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..b4e3e31aae --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export let y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js new file mode 100644 index 0000000000..309be48ded --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as s from './star-reexporter.js'; +import * as r from './export-renamer.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-let-star-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..73bac4a4c7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js new file mode 100644 index 0000000000..d1dfdb36b9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as r from './export-renamer.js'; +import * as s from './star-reexporter.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-renamer-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..73bac4a4c7 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y = 42; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js new file mode 100644 index 0000000000..309be48ded --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/main.js @@ -0,0 +1,4 @@ +import * as s from './star-reexporter.js'; +import * as r from './export-renamer.js'; + +export const probe = s.probe; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..0ef935591e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-tdz-var-star-first/node_modules/app/star-reexporter.js @@ -0,0 +1,9 @@ +import * as r from './export-renamer.js'; +export * from './export-renamer.js'; +export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } +})(); diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..ee50094c9e --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/main.js new file mode 100644 index 0000000000..29d03f9ff9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/main.js @@ -0,0 +1,7 @@ +import { x } from './star-reexporter.js'; +import * as ns1 from './star-reexporter.js'; +import * as ns2 from './export-renamer.js'; + +export const captured = x; +export const namespace1 = { x: ns1.x, y: ns1.y }; +export const namespace2 = { x: ns2.x, y: ns2.y }; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..be4dc803c1 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename-unused/node_modules/app/star-reexporter.js @@ -0,0 +1 @@ +export * from './export-renamer.js'; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/export-renamer.js b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/export-renamer.js new file mode 100644 index 0000000000..3961028771 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/export-renamer.js @@ -0,0 +1,2 @@ +export { y as x } from './star-reexporter.js'; +export var y = 45; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/main.js b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/main.js new file mode 100644 index 0000000000..29d03f9ff9 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/main.js @@ -0,0 +1,7 @@ +import { x } from './star-reexporter.js'; +import * as ns1 from './star-reexporter.js'; +import * as ns2 from './export-renamer.js'; + +export const captured = x; +export const namespace1 = { x: ns1.x, y: ns1.y }; +export const namespace2 = { x: ns2.x, y: ns2.y }; diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/package.json b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/package.json new file mode 100644 index 0000000000..4cf2fe3106 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/package.json @@ -0,0 +1,9 @@ +{ + "name": "app", + "version": "1.0.0", + "type": "module", + "main": "main.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +} diff --git a/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/star-reexporter.js b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/star-reexporter.js new file mode 100644 index 0000000000..be4dc803c1 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cycle-rename/node_modules/app/star-reexporter.js @@ -0,0 +1 @@ +export * from './export-renamer.js'; diff --git a/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js b/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js deleted file mode 100644 index fbc03d3990..0000000000 --- a/packages/compartment-mapper/test/subpath-patterns-node-parity.test.js +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Node.js parity test for subpath pattern replacement. - * - * This test runs the fixtures under plain Node.js to verify they are valid - * Node.js packages. The same expected values are asserted in the Compartment - * Mapper test (subpath-patterns.test.js), so parity is verified by - * construction: if both tests pass, the behaviors are equivalent. - */ -import test from 'ava'; -import { - assertMain, - assertConditionalDefault, - assertPrecedence, - assertImportsEdgeCasesDefault, -} from './_subpath-patterns-assertions.js'; - -const fixtureBase = new URL( - 'fixtures-package-imports-exports/node_modules/app/', - import.meta.url, -); - -test('subpath patterns - node parity', async t => { - const ns = await import(new URL('main.js', fixtureBase).href); - assertMain(t, ns); -}); - -test('null-target patterns are excluded by Node.js', async t => { - // The file exists on disk but the null-target export prevents resolution. - await t.throwsAsync( - () => - import( - new URL( - 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', - import.meta.url, - ).href - ), - { - code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', - }, - ); -}); - -test('conditional patterns - default condition in Node.js', async t => { - // Without --conditions=blue-moon, "default" is selected. - const ns = await import(new URL('conditional-import.js', fixtureBase).href); - assertConditionalDefault(t, ns); -}); - -test('multi-star patterns are not resolved by Node.js', async t => { - // Node.js restricts subpath patterns to exactly one `*` per side. - // Entries with multiple `*` are silently ignored (never match). - // This test will fail if Node.js begins to support multi-star patterns, - // signaling that we should revisit our implementation. - const fixtureDir = new URL( - 'fixtures-package-imports-exports/node_modules/', - import.meta.url, - ); - // The main export (no wildcards) should still work. - const main = await import( - new URL('multi-star-lib/src/main.js', fixtureDir).href - ); - t.is(main.main, 'main'); - - // The multi-star subpath pattern should NOT resolve. - await t.throwsAsync( - () => import(new URL('app/multi-star-import.js', fixtureDir).href), - { - code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', - }, - ); -}); - -test('imports edge cases - node parity', async t => { - // Non-wildcard alias (#helper) and conditional import (#cond under default - // conditions) resolve correctly under Node.js. - const ns = await import( - new URL( - 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', - import.meta.url, - ).href - ); - assertImportsEdgeCasesDefault(t, ns); -}); - -test('array imports field is silently ignored by Node.js', async t => { - // Node.js silently ignores an invalid array `imports` field and resolves - // the package via `exports` instead. The compartment-mapper is stricter - // and throws. This test documents the Node.js behavior. - const ns = await import( - new URL( - 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', - import.meta.url, - ).href - ); - t.is(ns.value, 'should not reach here'); -}); - -test('exports edge cases - node parity', async t => { - const ns = await import( - new URL( - 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', - import.meta.url, - ).href - ); - t.is(ns.main, 'exports-edge-cases-main'); - t.is(ns.nested, 'nested-esm'); -}); - -test('non-object exports field is rejected by Node.js', async t => { - // Node.js rejects the invalid numeric exports field. The error code varies - // by version: ERR_PACKAGE_PATH_NOT_EXPORTED on 18/20, ERR_MODULE_NOT_FOUND - // on 22+. We just verify it throws. - await t.throwsAsync( - () => - import( - new URL( - 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', - import.meta.url, - ).href - ), - ); -}); - -test('globstar patterns are not resolved by Node.js', async t => { - // Node.js does not support globstar (**) in subpath patterns. - // Entries with ** are silently ignored (never match). - // This test will fail if Node.js begins to support globstar patterns, - // signaling that we should revisit our implementation. - const fixtureDir = new URL( - 'fixtures-package-imports-exports/node_modules/', - import.meta.url, - ); - // The main export (no wildcards) should still work. - const main = await import( - new URL('globstar-lib/src/main.js', fixtureDir).href - ); - t.is(main.main, 'main'); - - // The globstar subpath pattern should NOT resolve. - await t.throwsAsync( - () => import(new URL('app/globstar-import.js', fixtureDir).href), - { - code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', - }, - ); -}); - -test('absolute path in subpath pattern is rejected by Node.js', async t => { - // A package whose exports map "./smuggle/*.js" to "/etc/*.js" should not - // allow importing absolute paths. Node.js rejects this because the - // resolved target does not start with "./". - await t.throwsAsync( - () => - import( - new URL( - 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', - import.meta.url, - ).href - ), - { - code: 'ERR_INVALID_PACKAGE_TARGET', - }, - ); -}); - -test('Node prefers the longer full pattern key on equal prefix length', async t => { - // This exercises Node's pattern key ordering with overlapping keys: - // "./tie/*" and "./tie/*.js". Node resolves "patterns-lib/tie/bar.js" - // through "./tie/*.js", not the broader "./tie/*" entry. - const ns = await import(new URL('precedence-import.js', fixtureBase).href); - assertPrecedence(t, ns); -}); diff --git a/packages/compartment-mapper/test/subpath-patterns.test.js b/packages/compartment-mapper/test/subpath-patterns.test.js index 6fb5c760ae..cf24d4de6a 100644 --- a/packages/compartment-mapper/test/subpath-patterns.test.js +++ b/packages/compartment-mapper/test/subpath-patterns.test.js @@ -1,12 +1,13 @@ /** - * Compartment Mapper test for subpath pattern replacement. - * - * Uses the scaffold harness to exercise the fixture through all execution - * paths (loadLocation, importLocation, makeArchive, parseArchive, etc.). - * - * The expected values match those asserted in node-parity-subpath-patterns.test.js, - * so if both tests pass, the Compartment Mapper has parity with Node.js for - * these cases. + * Subpath pattern replacement scenarios exercised in this module in two + * treatments side-by-side: the SES treatment (through the compartment-mapper + * scaffold and the package's import / archive surfaces) and the Node.js + * parity treatment (through plain Node.js imports). Where a scenario has a + * direct Node-side analog the two tests are registered back-to-back; where + * the scenario only exists on one side (SES-only archive shape checks, + * Node-only multi-star / globstar exclusions) the test is registered once. + * Together the SES and Node.js treatments verify that the compartment mapper + * has parity with Node.js where the fixtures support both. */ /** @import {ExecutionContext} from 'ava' */ @@ -21,6 +22,7 @@ import { assertConditionalDefault, assertPrecedence, assertImportsEdgeCasesDev, + assertImportsEdgeCasesDefault, } from './_subpath-patterns-assertions.js'; const fixture = new URL( @@ -28,6 +30,11 @@ const fixture = new URL( import.meta.url, ).toString(); +const fixtureBase = new URL( + 'fixtures-package-imports-exports/node_modules/app/', + import.meta.url, +); + const fixtureAssertionCount = 1; /** @@ -38,14 +45,22 @@ const assertFixture = (t, { namespace }) => { assertMain(t, namespace); }; +// Main subpath pattern resolution: SES treatment through the scaffold, then +// the Node.js parity treatment importing the same main.js directly. scaffold( - 'subpath-patterns', + 'subpath-patterns (ses)', test, fixture, assertFixture, fixtureAssertionCount, ); +test('subpath-patterns (node parity)', async t => { + const ns = await import(new URL('main.js', fixtureBase).href); + assertMain(t, ns); +}); + +// Archive shape: SES-only. The Node.js side has no archive analog. test('patterns are stripped from archived compartment-map.json', async t => { const archive = await makeArchive(readPowers, fixture, { modules: {}, @@ -68,7 +83,10 @@ test('patterns are stripped from archived compartment-map.json', async t => { } }); -test('conditional pattern resolves under user-specified condition', async t => { +// Conditional patterns: SES exercises the explicit blue-moon condition; the +// Node.js parity sibling exercises the default fall-through (Node has no +// API for user-specified conditions in this scaffold). +test('conditional pattern resolves under user-specified condition (ses)', async t => { const conditionalFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/conditional-import.js', import.meta.url, @@ -79,7 +97,7 @@ test('conditional pattern resolves under user-specified condition', async t => { assertConditionalBlue(t, namespace); }); -test('conditional pattern falls back to default without user condition', async t => { +test('conditional pattern falls back to default without user condition (ses)', async t => { const conditionalFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/conditional-import.js', import.meta.url, @@ -88,6 +106,13 @@ test('conditional pattern falls back to default without user condition', async t assertConditionalDefault(t, namespace); }); +test('conditional patterns - default condition (node parity)', async t => { + // Without --conditions=blue-moon, "default" is selected. + const ns = await import(new URL('conditional-import.js', fixtureBase).href); + assertConditionalDefault(t, ns); +}); + +// Policy gating: SES-only. test('policy allows pattern-matched imports when package is permitted', async t => { const policy = { entry: { packages: { 'patterns-lib': true } }, @@ -105,7 +130,9 @@ test('policy rejects pattern-matched imports when package is not permitted', asy await t.throwsAsync(() => importLocation(readPowers, fixture, { policy })); }); -test('array imports field in package.json causes an exception', async t => { +// Array imports field: the compartment-mapper throws; Node.js silently +// ignores and resolves through exports. +test('array imports field in package.json causes an exception (ses)', async t => { const arrayImportsFixture = new URL( 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', import.meta.url, @@ -115,7 +142,24 @@ test('array imports field in package.json causes an exception', async t => { }); }); -test('imports edge cases: non-wildcard alias, conditional, null, invalid key, bad value, mismatched wildcard', async t => { +test('array imports field is silently ignored by Node.js (node parity)', async t => { + // Node.js silently ignores an invalid array `imports` field and resolves + // the package via `exports` instead. The compartment-mapper is stricter + // and throws (see the SES treatment above). This test documents the + // Node.js behavior. + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/array-imports-app/main.js', + import.meta.url, + ).href + ); + t.is(ns.value, 'should not reach here'); +}); + +// Imports edge cases: SES exercises the development condition (which yields +// the assertImportsEdgeCasesDev shape); Node.js exercises the default +// condition (assertImportsEdgeCasesDefault). +test('imports edge cases: non-wildcard alias, conditional, null, invalid key, bad value, mismatched wildcard (ses)', async t => { const edgeCasesFixture = new URL( 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', import.meta.url, @@ -132,6 +176,20 @@ test('imports edge cases: non-wildcard alias, conditional, null, invalid key, ba // - "#mismatched/*" / "./mismatched-export/*": mismatched wildcard count }); +test('imports edge cases (node parity)', async t => { + // Non-wildcard alias (#helper) and conditional import (#cond under default + // conditions) resolve correctly under Node.js. + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/imports-edge-cases-app/main.js', + import.meta.url, + ).href + ); + assertImportsEdgeCasesDefault(t, ns); +}); + +// Browser field handling: SES-only (Node's loader does not consult the +// browser field). test('browser field and commonjs default module', async t => { const browserCjsFixture = new URL( 'fixtures-package-imports-exports/node_modules/browser-cjs-app/main.js', @@ -158,7 +216,9 @@ test('browser field as string remaps main export', async t => { t.is(namespace.env, 'browser-string'); }); -test('exports edge cases: ./ key skipped, nested subpath with name != "."', async t => { +// Exports edge cases: SES and Node parity assertions are essentially +// identical for this fixture; register them back-to-back. +test('exports edge cases: ./ key skipped, nested subpath with name != "." (ses)', async t => { const exportsEdgeCasesFixture = new URL( 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', import.meta.url, @@ -171,7 +231,20 @@ test('exports edge cases: ./ key skipped, nested subpath with name != "."', asyn t.is(namespace.nested, 'nested-esm'); }); -test('non-object exports field causes an exception', async t => { +test('exports edge cases (node parity)', async t => { + const ns = await import( + new URL( + 'fixtures-package-imports-exports/node_modules/exports-edge-cases-app/main.js', + import.meta.url, + ).href + ); + t.is(ns.main, 'exports-edge-cases-main'); + t.is(ns.nested, 'nested-esm'); +}); + +// Non-object exports field: the compartment-mapper produces a specific +// message; Node.js produces a version-dependent code. +test('non-object exports field causes an exception (ses)', async t => { const badExportsFixture = new URL( 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', import.meta.url, @@ -181,6 +254,23 @@ test('non-object exports field causes an exception', async t => { }); }); +test('non-object exports field is rejected by Node.js (node parity)', async t => { + // Node.js rejects the invalid numeric exports field. The error code varies + // by version: ERR_PACKAGE_PATH_NOT_EXPORTED on 18/20, ERR_MODULE_NOT_FOUND + // on 22+. We just verify it throws. + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/bad-exports-app/main.js', + import.meta.url, + ).href + ), + ); +}); + +// Non-string non-object browser field: SES-only (Node's loader does not +// consult the browser field). test('non-string non-object browser field causes an exception', async t => { const badBrowserFixture = new URL( 'fixtures-package-imports-exports/node_modules/bad-browser-app/main.js', @@ -197,7 +287,9 @@ test('non-string non-object browser field causes an exception', async t => { ); }); -test('null-target pattern excludes matching specifier', async t => { +// Null-target patterns: SES rejects with a message; Node.js rejects with +// ERR_PACKAGE_PATH_NOT_EXPORTED. +test('null-target pattern excludes matching specifier (ses)', async t => { const nullTargetFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', import.meta.url, @@ -207,7 +299,25 @@ test('null-target pattern excludes matching specifier', async t => { }); }); -test('absolute path in subpath pattern replacement is rejected', async t => { +test('null-target patterns are excluded by Node.js (node parity)', async t => { + // The file exists on disk but the null-target export prevents resolution. + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/app/null-target-import.js', + import.meta.url, + ).href + ), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); + +// Absolute path in subpath pattern: SES rejects with a "Cannot find file" +// message; Node.js rejects with ERR_INVALID_PACKAGE_TARGET. +test('absolute path in subpath pattern replacement is rejected (ses)', async t => { const absolutePatternFixture = new URL( 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', import.meta.url, @@ -220,6 +330,26 @@ test('absolute path in subpath pattern replacement is rejected', async t => { ); }); +test('absolute path in subpath pattern is rejected by Node.js (node parity)', async t => { + // A package whose exports map "./smuggle/*.js" to "/etc/*.js" should not + // allow importing absolute paths. Node.js rejects this because the + // resolved target does not start with "./". + await t.throwsAsync( + () => + import( + new URL( + 'fixtures-package-imports-exports/node_modules/absolute-pattern-app/main.js', + import.meta.url, + ).href + ), + { + code: 'ERR_INVALID_PACKAGE_TARGET', + }, + ); +}); + +// Module field ESM entry: SES-only (this exercises compartment-mapper's +// module-field handling specifically). test('module field selects ESM entry point', async t => { const moduleFieldFixture = new URL( 'fixtures-package-imports-exports/node_modules/module-field-app/main.js', @@ -232,7 +362,10 @@ test('module field selects ESM entry point', async t => { t.is(namespace.entry, 'esm'); }); -test('pattern tie-break matches Node precedence rules', async t => { +// Pattern tie-break precedence: SES exercises through importLocation; Node +// parity exercises through a direct import of the precedence-import.js +// driver. +test('pattern tie-break matches Node precedence rules (ses)', async t => { const precedenceFixture = new URL( 'fixtures-package-imports-exports/node_modules/app/precedence-import.js', import.meta.url, @@ -240,3 +373,62 @@ test('pattern tie-break matches Node precedence rules', async t => { const { namespace } = await importLocation(readPowers, precedenceFixture); assertPrecedence(t, namespace); }); + +test('Node prefers the longer full pattern key on equal prefix length (node parity)', async t => { + // This exercises Node's pattern key ordering with overlapping keys: + // "./tie/*" and "./tie/*.js". Node resolves "patterns-lib/tie/bar.js" + // through "./tie/*.js", not the broader "./tie/*" entry. + const ns = await import(new URL('precedence-import.js', fixtureBase).href); + assertPrecedence(t, ns); +}); + +// Multi-star and globstar exclusions: Node-only. The compartment-mapper has +// no analogous test on the SES side; the parity test pins Node's behavior +// so a future Node change is caught. +test('multi-star patterns are not resolved by Node.js (node parity)', async t => { + // Node.js restricts subpath patterns to exactly one `*` per side. + // Entries with multiple `*` are silently ignored (never match). + // This test will fail if Node.js begins to support multi-star patterns, + // signaling that we should revisit our implementation. + const fixtureDir = new URL( + 'fixtures-package-imports-exports/node_modules/', + import.meta.url, + ); + // The main export (no wildcards) should still work. + const main = await import( + new URL('multi-star-lib/src/main.js', fixtureDir).href + ); + t.is(main.main, 'main'); + + // The multi-star subpath pattern should NOT resolve. + await t.throwsAsync( + () => import(new URL('app/multi-star-import.js', fixtureDir).href), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); + +test('globstar patterns are not resolved by Node.js (node parity)', async t => { + // Node.js does not support globstar (**) in subpath patterns. + // Entries with ** are silently ignored (never match). + // This test will fail if Node.js begins to support globstar patterns, + // signaling that we should revisit our implementation. + const fixtureDir = new URL( + 'fixtures-package-imports-exports/node_modules/', + import.meta.url, + ); + // The main export (no wildcards) should still work. + const main = await import( + new URL('globstar-lib/src/main.js', fixtureDir).href + ); + t.is(main.main, 'main'); + + // The globstar subpath pattern should NOT resolve. + await t.throwsAsync( + () => import(new URL('app/globstar-import.js', fixtureDir).href), + { + code: 'ERR_PACKAGE_PATH_NOT_EXPORTED', + }, + ); +}); diff --git a/packages/module-source/src/functor.js b/packages/module-source/src/functor.js index e1b1ee523d..8f8535effa 100644 --- a/packages/module-source/src/functor.js +++ b/packages/module-source/src/functor.js @@ -45,15 +45,14 @@ export const buildFunctorSource = (scriptSource, sourceOptions, sourceUrl) => { preamble = `let ${preamble};`; } - preamble += `${h.HIDDEN_IMPORTS}([${keys(isrc) - .map( - src => - `[${js(src)}, [${Object.entries(isrc[src]) - .map(([exp, upds]) => `[${js(exp)},[${upds.join(',')}]]`) - .join(',')}]]`, - ) - .join(',')}]);`; - + // Hoisted declarations (function declarations and `var` initializers) + // must run before the imports call so that, when imports() walks the + // module graph and synchronously executes upstream modules during a + // cycle, those upstreams already observe this module's hoisted bindings + // as initialized rather than in the temporal dead zone. ECMA-262 model: + // function/var bindings are created and initialized during + // InitializeEnvironment, which precedes dependency evaluation in + // Module.Evaluate. preamble += sourceOptions.hoistedDecls .map(([vname, isOnce, cvname]) => { let src = ''; @@ -66,6 +65,15 @@ export const buildFunctorSource = (scriptSource, sourceOptions, sourceUrl) => { }) .join(''); + preamble += `${h.HIDDEN_IMPORTS}([${keys(isrc) + .map( + src => + `[${js(src)}, [${Object.entries(isrc[src]) + .map(([exp, upds]) => `[${js(exp)},[${upds.join(',')}]]`) + .join(',')}]]`, + ) + .join(',')}]);`; + // The outer function destructures the module calling convention's internal // variables into hidden lexical variables. // The inner function binds `this` to `undefined` and overshadows the diff --git a/packages/module-source/test/fixtures/format-preserved.txt b/packages/module-source/test/fixtures/format-preserved.txt index bf11c64e26..ec9a44fd55 100644 --- a/packages/module-source/test/fixtures/format-preserved.txt +++ b/packages/module-source/test/fixtures/format-preserved.txt @@ -1,4 +1,4 @@ -({imports:$h͏_imports,liveVar:$h͏_live,onceVar:$h͏_once,import:$h͏_import,importMeta:$h͏____meta})=>(function(){'use strict';$h͏_imports([]);Object.defineProperty(createBinop,'name',{value:"createBinop"});$h͏_once.createBinop(createBinop);// deliberately offset +({imports:$h͏_imports,liveVar:$h͏_live,onceVar:$h͏_once,import:$h͏_import,importMeta:$h͏____meta})=>(function(){'use strict';Object.defineProperty(createBinop,'name',{value:"createBinop"});$h͏_once.createBinop(createBinop);$h͏_imports([]);// deliberately offset function TokenType() {} const beforeExpr = 0; diff --git a/packages/ses/src/module-instance.js b/packages/ses/src/module-instance.js index add11b8660..a5d9c9630f 100644 --- a/packages/ses/src/module-instance.js +++ b/packages/ses/src/module-instance.js @@ -23,6 +23,7 @@ import { assign, } from './commons.js'; import { compartmentEvaluate } from './compartment-evaluate.js'; +import { makeNotifierWithResolver } from './notifier-with-resolver.js'; const { quote: q } = assert; @@ -257,6 +258,17 @@ export const makeModuleInstance = ( configurable: false, }; + // Define on exportsTarget eagerly so cross-module reads through the + // namespace import (`'*'` notifier) see the TDZ-aware getter even before + // imports() completes its sort-and-define pass. The late pass below + // redefines with the identical descriptor as a no-op, preserving the + // ECMA-262 sorted enumeration order. + defineProperty( + exportsTarget, + fixedExportName, + exportsProps[fixedExportName], + ); + notifiers[fixedExportName] = fixedGetNotify.notify; }); @@ -345,6 +357,16 @@ export const makeModuleInstance = ( configurable: false, }; + // Define on exportsTarget eagerly so cross-module reads through the + // namespace import see the TDZ-aware getter before imports() completes + // its sort-and-define pass. The late pass below redefines with the + // identical descriptor as a no-op, preserving sorted enumeration order. + defineProperty( + exportsTarget, + liveExportName, + exportsProps[liveExportName], + ); + notifiers[liveExportName] = liveGetNotify.notify; }, ); @@ -354,23 +376,73 @@ export const makeModuleInstance = ( }; notifiers['*'] = notifyStar; - const wireUpExportNotifier = (exportName, notify) => { - if (!notifiers[exportName] && notify !== false) { - notifiers[exportName] = notify; - - // exported live binding state - let value; - const update = newValue => (value = newValue); - notify(update); - exportsProps[exportName] = { - get() { - return value; - }, - set: undefined, - enumerable: true, - configurable: false, + const wireUpExportNotifier = ( + exportName, + notify, + deferredSpecifier, + deferredImportName, + ) => { + if (notifiers[exportName] || notify === false) { + return; + } + if (notify === undefined) { + if (deferredSpecifier === undefined) { + return; + } + // The upstream module did not yet expose a notifier for + // `deferredImportName` when this re-export was wired. This is the + // star-export cycle of endojs/endo#59: the upstream's notifier for + // that name may be wired only later, in its own candidate-all walk, + // after this module's `imports()` returns. Install a notifier that + // queues subscribers until the upstream resolves, then forwards them + // through. {@link makeNotifierWithResolver} is the synchronous + // variant of `Promise.withResolvers` that captures this pattern; each + // `notify` call lazily attempts to resolve against the upstream's + // notifiers. + const { notify: queueOrForward, resolve: resolveUpstream } = + makeNotifierWithResolver(); + notify = update => { + const upstreamInstance = mapGet(importedInstances, deferredSpecifier); + const upstreamNotify = upstreamInstance.notifiers[deferredImportName]; + if (upstreamNotify !== undefined) { + resolveUpstream(upstreamNotify); + } + queueOrForward(update); }; } + notifiers[exportName] = notify; + + // Re-exported live binding state. The exported getter throws + // ReferenceError until the upstream binding propagates a value through + // `notify`, mirroring the cross-module TDZ semantics of ECMA-262: + // reading a re-export of an upstream `const` or `let` binding during a + // cycle's linked-but-not-yet-evaluated window raises a ReferenceError + // rather than silently returning the cached `undefined`. An upstream + // `var` binding clears its TDZ as part of its hoisting preamble (see + // `transform-analyze.js`), so for `var` the updater fires before the + // downstream's getter is observed and the getter returns `undefined`. + let value; + let tdz = true; + const update = newValue => { + value = newValue; + tdz = false; + }; + notify(update); + exportsProps[exportName] = { + get() { + if (tdz) { + throw ReferenceError(`binding ${q(exportName)} not yet initialized`); + } + return value; + }, + set: undefined, + enumerable: true, + configurable: false, + }; + // Define on exportsTarget eagerly so cross-module namespace reads see + // the TDZ-aware getter before imports() completes; the late pass below + // redefines with the identical descriptor as a no-op. + defineProperty(exportsTarget, exportName, exportsProps[exportName]); }; // Per the calling convention for the moduleFunctor generated from @@ -428,7 +500,12 @@ export const makeModuleInstance = ( if (reexportMap[specifier]) { // Set up reexport notifiers instantly so they are available in cycles. for (const [localName, exportedName] of reexportMap[specifier]) { - wireUpExportNotifier(exportedName, importNotifiers[localName]); + wireUpExportNotifier( + exportedName, + importNotifiers[localName], + specifier, + localName, + ); } } } diff --git a/packages/ses/src/notifier-with-resolver.js b/packages/ses/src/notifier-with-resolver.js new file mode 100644 index 0000000000..dd5acb755c --- /dev/null +++ b/packages/ses/src/notifier-with-resolver.js @@ -0,0 +1,57 @@ +/** + * @module Synchronous variant of `Promise.withResolvers` for the + * star-export notifier wiring used by `module-instance.js`. Provides a + * `notify` / `resolve` pair where subscribers attached before the + * resolver is settled are queued and replayed once a target notifier is + * supplied. + */ + +import { arrayPush } from './commons.js'; + +/** + * Creates a notifier that defers subscribers until a resolver is invoked, + * then forwards all subsequent subscribers to a target notifier. + * + * This is a synchronous variant of `Promise.withResolvers`: `notify` is the + * "subscribe" side and `resolve` is the "settle" side. Subscribers attached + * via `notify(update)` before `resolve(targetNotify)` is called are queued; + * once `resolve` is called, queued updaters are replayed to the target + * notifier and subsequent `notify(update)` calls forward directly through. + * + * `resolve` is one-shot: the first call settles the resolver to its target, + * and subsequent calls are no-ops. (This matches `Promise.withResolvers` + * semantics: a promise settles once.) Callers that need to discover the + * target lazily may safely call `resolve` again on each `notify`; only the + * first call has effect. + * + * @returns {{ + * notify: (update: (value: any) => void) => void, + * resolve: (targetNotify: (update: (value: any) => void) => void) => void, + * }} + */ +export const makeNotifierWithResolver = () => { + /** @type {Array<(value: any) => void>} */ + const pendingUpdaters = []; + /** @type {((update: (value: any) => void) => void) | undefined} */ + let resolvedTargetNotify; + + const notify = update => { + if (resolvedTargetNotify === undefined) { + arrayPush(pendingUpdaters, update); + } else { + resolvedTargetNotify(update); + } + }; + + const resolve = targetNotify => { + if (resolvedTargetNotify === undefined) { + resolvedTargetNotify = targetNotify; + for (const pending of pendingUpdaters) { + targetNotify(pending); + } + pendingUpdaters.length = 0; + } + }; + + return { notify, resolve }; +}; diff --git a/packages/ses/test/import-cjs.test.js b/packages/ses/test/import-cjs.test.js index bdf752cc0d..86e95d3621 100644 --- a/packages/ses/test/import-cjs.test.js +++ b/packages/ses/test/import-cjs.test.js @@ -26,6 +26,10 @@ function heuristicAnalysis(moduleSource) { }; } +// TODO(endojs/endo#3220): replace this in-test mock with the AST-based +// `CjsModuleSource` that endojs/endo#3220 will export from +// `@endo/module-source`. Until that lands, this heuristic-regex mock is the +// only synchronous CommonJS module source available to the SES test suite. const CjsModuleSource = (moduleSource, moduleLocation) => { if (typeof moduleSource !== 'string') { throw TypeError( @@ -667,6 +671,89 @@ test('importNow handles a cycle in CommonJS modules', t => { t.is(namespace.B.A.a, 42); }); +// In-process SES regression for the ESM-in-CommonJS-cycle shape of issue +// endojs/endo#59, exercised directly through the Compartment API with +// inline ModuleSources. The fixture places a CommonJS module in the cycle +// as the "star reexporter": it captures the renamer's exports by property +// assignment (`exports.x = r.x; exports.y = r.y`) at the moment its own +// `require('./export-renamer.mjs')` returns. The ESM renamer re-exports +// from the CJS reexporter with `export { y as x } from './star-reexporter.cjs'`. +// Because the renamer's `x` resolves to its own `y` via live ESM binding, +// the namespace projection at the renamer is { x: 45, y: 45 }; on the CJS +// side, the property capture happened before `r.x` had any value, so the +// reexporter's namespace is { x: undefined, y: 45 }. Both shapes are pinned. +// +// Node.js rejects this ESM-in-CJS-cycle topology with ERR_REQUIRE_CYCLE_MODULE; +// the divergence is verified programmatically in +// packages/compartment-mapper/test/cycle-esm-in-cjs.test.js (one module +// registering both the SES treatment, where the topology loads, and the +// Node.js parity treatment, which spawns Node and asserts the +// ERR_REQUIRE_CYCLE_MODULE rejection). For the parity case where Node and +// SES agree on a pure-CommonJS cyclic reexporter, see +// packages/compartment-mapper/test/cycle-cjs-reexporter.test.js (one module +// registering both the SES treatment and the Node.js parity treatment +// back-to-back). +test('cyclic star-export with CommonJS reexporter', async t => { + t.plan(3); + + const resolveHook = resolveNode; + const importHook = async specifier => { + if (specifier === './star-reexporter.cjs') { + // CommonJS analog of `export * from './export-renamer.mjs'`: capture + // the renamer's properties by assignment. Because both are required + // from each other in a cycle, the values read here are whatever the + // renamer had set on its `exports` by the re-entry instant. + return CjsModuleSource( + ` + const r = require('./export-renamer.mjs'); + exports.x = r.x; + exports.y = r.y; + `, + 'https://example.com/star-reexporter.cjs', + ); + } + if (specifier === './export-renamer.mjs') { + return new ModuleSource( + ` + export { y as x } from './star-reexporter.cjs'; + export var y = 45; + `, + 'https://example.com/export-renamer.mjs', + ); + } + if (specifier === './main.mjs') { + return new ModuleSource( + ` + import { x } from './star-reexporter.cjs'; + import * as ns1 from './star-reexporter.cjs'; + import * as ns2 from './export-renamer.mjs'; + export const captured = x; + export const namespace1 = { x: ns1.x, y: ns1.y }; + export const namespace2 = { x: ns2.x, y: ns2.y }; + `, + 'https://example.com/main.mjs', + ); + } + throw Error(`Cannot load module ${specifier}`); + }; + + const compartment = new Compartment({ + resolveHook, + importHook, + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.mjs'); + + // The CJS reexporter captured `r.x` when the renamer had not yet set its + // own `x`, so the property holds `undefined`. The renamer's `x` resolves + // (live, via the re-export wiring) to its own `y`, which is 45. + t.is(namespace.captured, undefined); + t.deepEqual(namespace.namespace1, { x: undefined, y: 45 }); + t.deepEqual(namespace.namespace2, { x: 45, y: 45 }); +}); + test('importNowHook only called if specifier was not imported before', async t => { t.plan(1); diff --git a/packages/ses/test/import-gauntlet.test.js b/packages/ses/test/import-gauntlet.test.js index 2a21f4a385..340cbee34b 100644 --- a/packages/ses/test/import-gauntlet.test.js +++ b/packages/ses/test/import-gauntlet.test.js @@ -239,6 +239,437 @@ test('export name as default', async t => { await compartment.import('./main.js'); }); +// Regression for endojs/endo#59. A module reached more than once via +// `export *` and a renaming reexport with a different `exported` name +// formerly raised a spurious "does not provide an export named X" +// SyntaxError (latterly a `TypeError: notify is not a function`): +// the export-renamer's `export { y as x } from './star-reexporter.js'` +// was wired before star-reexporter's star-import from export-renamer had +// populated star-reexporter's notifier for `y`. The same fixture shape is +// also exercised through compartment-mapper's scaffold and pinned to +// Node.js's reference behavior; see +// packages/compartment-mapper/test/cycle-rename.test.js (one module +// registering both the SES treatment and the Node.js parity treatment +// back-to-back). +test('cyclic star export with renaming reexport (issue #59)', async t => { + t.plan(3); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + export * from './export-renamer.js'; + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y = 45; + `, + 'https://example.com/main.js': ` + import { x } from './star-reexporter.js'; + import * as ns1 from './star-reexporter.js'; + import * as ns2 from './export-renamer.js'; + export const captured = x; + export const namespace1 = { x: ns1.x, y: ns1.y }; + export const namespace2 = { x: ns2.x, y: ns2.y }; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.is(namespace.captured, 45); + t.deepEqual(namespace.namespace1, { x: 45, y: 45 }); + t.deepEqual(namespace.namespace2, { x: 45, y: 45 }); +}); + +// Companion regression for endojs/endo#59 covering the unused-live-binding +// shape of the cyclic star-export. The export-renamer's +// `export { y as x } from './star-reexporter.js'` re-exports a live binding +// from the star reexporter, but the renamer's own `y` is declared without +// initialization (`export var y;`), so the binding is never updated. +// Every projection of the cycle reads `undefined`. Node.js agrees: the +// in-process SES linker behavior matches Node.js's reference behavior for +// this shape, which is the parity property the test pins. The same fixture +// shape is also exercised through compartment-mapper's scaffold and pinned +// to Node.js's reference behavior with a shared assertion module; see +// packages/compartment-mapper/test/cycle-rename-unused.test.js (one module +// registering both the SES treatment and the Node.js parity treatment +// back-to-back). +test('cyclic star export with renaming reexport, unused live binding', async t => { + t.plan(3); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + export * from './export-renamer.js'; + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y; + `, + 'https://example.com/main.js': ` + import { x } from './star-reexporter.js'; + import * as ns1 from './star-reexporter.js'; + import * as ns2 from './export-renamer.js'; + export const captured = x; + export const namespace1 = { x: ns1.x, y: ns1.y }; + export const namespace2 = { x: ns2.x, y: ns2.y }; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.is(namespace.captured, undefined); + t.deepEqual(namespace.namespace1, { x: undefined, y: undefined }); + t.deepEqual(namespace.namespace2, { x: undefined, y: undefined }); +}); + +// The next six tests vary the cyclic star-export fixture along two axes +// and observe the renamer's binding during the window when the renamer is +// linked into the cycle but its body has not yet executed past the +// binding's declaration. The axes are: +// +// 1. Whether main.js imports the export-renamer first or the +// star-reexporter first. The first-imported module starts evaluating +// first; depth-first traversal of the cycle then determines which +// module's body runs while the other is on the evaluation stack with +// bindings not yet initialized. +// +// 2. Whether the renamer's `y` is declared with `const`, `let`, or `var`. +// Under ECMA-262 semantics, `const` and `let` create a binding that is +// in the temporal dead zone until its declaration is evaluated, so a +// read raises a ReferenceError; `var` is hoisted and reads `undefined` +// until the assignment runs. +// +// The observation is performed by the star-reexporter at the top of its +// body: it reads `r.y` through its own namespace import of the renamer and +// captures the result (the assigned value, or the error name when reading +// raises) as a probe export. main.js reads the probe through the +// star-reexporter's namespace. +// +// When the renamer is imported first from main.js, the star-reexporter's +// body runs while the renamer is on the evaluation stack with `y` not yet +// initialized. The expected observation under ECMA-262 semantics is +// ReferenceError for `const` and `let` and `undefined` for `var`. When the +// star-reexporter is imported first, depth-first cycle resolution evaluates +// the renamer's body to completion before the star-reexporter's body runs, +// so the probe captures the assigned value for every binding form. The +// "star reexporter imported first" cases therefore have no TDZ window to +// observe (the maintainer's "in all cases that it is possible" caveat); +// they are recorded as the expected non-observation that completes the +// matrix. +// +// All six cells now match Node.js after the fix that lands the eager +// exportsTarget property definitions in `module-instance.js` and reorders +// the hoisted declarations to run before the imports call in +// `module-source/src/transform-analyze.js`. The renamer-first plus `const` +// and renamer-first plus `let` cells raise `ReferenceError` (the +// fixed-binding and live-binding TDZ-aware getters now run against the +// cross-module namespace access path), while renamer-first plus `var` +// continues to read `undefined` because the hoisting preamble clears the +// upstream's TDZ before the downstream observes. +// +// Each cell is also exercised through the compartment-mapper scaffold and +// pinned to Node.js's reference behavior side-by-side in the same module. +// The six star-reexport cells and the named-reexport cell are enumerated +// in a single SCENARIOS table in +// packages/compartment-mapper/test/cycle-rename-tdz-matrix.test.js, which +// registers a SES treatment (through the compartment-mapper scaffold) and +// a Node.js parity treatment back-to-back for each scenario. Each row's +// `fixture` field names its directory under +// packages/compartment-mapper/test/fixtures-cycle-{rename,named-reexport}-tdz-*/. + +test('cyclic star export with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export const y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './star-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); +}); + +test('cyclic star export with renaming reexport, renamer imported first, let binding observes ReferenceError during temporal dead zone', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export let y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './star-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); +}); + +test('cyclic star export with renaming reexport, renamer imported first, var binding observes undefined while hoisted but unassigned', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './star-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: undefined }); +}); + +test('cyclic star export with renaming reexport, star reexporter imported first, const binding observes the assigned value', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export const y = 42; + `, + 'https://example.com/main.js': ` + import * as s from './star-reexporter.js'; + import * as r from './export-renamer.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: 42 }); +}); + +test('cyclic star export with renaming reexport, star reexporter imported first, let binding observes the assigned value', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export let y = 42; + `, + 'https://example.com/main.js': ` + import * as s from './star-reexporter.js'; + import * as r from './export-renamer.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: 42 }); +}); + +test('cyclic star export with renaming reexport, star reexporter imported first, var binding observes the assigned value', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/star-reexporter.js': ` + import * as r from './export-renamer.js'; + export * from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './star-reexporter.js'; + export var y = 42; + `, + 'https://example.com/main.js': ` + import * as s from './star-reexporter.js'; + import * as r from './export-renamer.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'value', value: 42 }); +}); + +// Companion to the renamer-first + const star-export case above with the +// star reexport replaced by a named reexport: the upstream module +// `named-reexporter.js` writes `export { y } from './export-renamer.js'` +// instead of `export * from './export-renamer.js'`. The cycle has the same +// shape (the named reexporter and the export renamer reference each +// other), and the observation of `r.y` through the namespace import lands +// during the same linked-but-not-yet-bound window when main.js imports the +// renamer first. Node.js raises ReferenceError for `const y = 42` here, +// matching the star-reexport case, because the temporal dead zone +// semantics live with the binding, not with the reexport form. After the +// fix to `module-instance.js` and `transform-analyze.js`, SES enforces the +// same TDZ on the namespace path through `wireUpExportNotifier` whether +// the reexport is reached through `export *` or through `export { y } +// from`. This case confirms the gap is not specific to `export *` +// (kriskowal follow-up on issue-comment 4675471286). +test('cyclic named reexport with renaming reexport, renamer imported first, const binding observes ReferenceError during temporal dead zone', async t => { + t.plan(1); + + const makeImportHook = makeNodeImporter({ + 'https://example.com/named-reexporter.js': ` + import * as r from './export-renamer.js'; + export { y } from './export-renamer.js'; + export const probe = (() => { + try { + return { kind: 'value', value: r.y }; + } catch (e) { + return { kind: 'error', name: e.name }; + } + })(); + `, + 'https://example.com/export-renamer.js': ` + export { y as x } from './named-reexporter.js'; + export const y = 42; + `, + 'https://example.com/main.js': ` + import * as r from './export-renamer.js'; + import * as s from './named-reexporter.js'; + export const probe = s.probe; + `, + }); + + const compartment = new Compartment({ + resolveHook: resolveNode, + importHook: makeImportHook('https://example.com'), + __noNamespaceBox__: true, + __options__: true, + }); + + const namespace = await compartment.import('./main.js'); + + t.deepEqual(namespace.probe, { kind: 'error', name: 'ReferenceError' }); +}); + test('export-as with duplicated export name', async t => { t.plan(4);