Skip to content

Commit 6496268

Browse files
committed
test(types): pin the ESM-condition declared leg (#13330)
1 parent 9e660ae commit 6496268

1 file changed

Lines changed: 248 additions & 1 deletion

File tree

‎packages/types/src/node.test.ts‎

Lines changed: 248 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
3030
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
3131
import * as NodeModule from 'node:module';
3232
import { tmpdir } from 'node:os';
33-
import { join } from 'node:path';
33+
import { dirname, join } from 'node:path';
3434
import { pathToFileURL } from 'node:url';
3535
import {
3636
createHostImporter,
@@ -575,3 +575,250 @@ describe('the undeclared fallback resolves from the CALLER (#10943)', () => {
575575
expect(withBase.message).not.toMatch(/did not pass `fallbackImport`/);
576576
});
577577
});
578+
579+
/**
580+
* #13330 — the DECLARED leg used to load the CommonJS build of a dual-published
581+
* package, giving the process a SECOND instance of everything that package
582+
* brings with it.
583+
*
584+
* The defect was invisible to every test that imports things the normal way,
585+
* because "the same module" is only ever one instance in a suite that never
586+
* crosses the seam. It surfaced as a shipped EE boot: `os serve` loaded
587+
* `@objectstack/service-cluster-redis` through this leg, the driver's load-time
588+
* `registerClusterDriver('redis', …)` ran against the CJS copy of
589+
* `@objectstack/service-cluster`, and the ESM Runtime read the ESM copy and
590+
* found nothing — `Cluster driver "redis" is not registered`, about a package
591+
* that was installed, declared and resolvable.
592+
*
593+
* The fixtures below are a miniature of exactly that: a dual-published package
594+
* holding module-scope state, and a second dual-published package whose only
595+
* job is a load-time write into it. What is asserted is the SHARED INSTANCE,
596+
* not the file name — a test that only checked which path was imported would
597+
* pass on a fix that loaded the right file into the wrong instance.
598+
*/
599+
describe('the declared leg loads the `import` build, not the `require` one (#13330)', () => {
600+
const REGISTRY = '@fixture/instance-registry';
601+
const DRIVER = '@fixture/instance-registry-driver';
602+
const CJS_ONLY = '@fixture/require-only';
603+
const SUBPATHS = '@fixture/dual-subpaths';
604+
605+
/** Module-scope state, published as both builds — the `tsup` dual-build shape. */
606+
const REGISTRY_ESM = `export const BUILD = 'esm';
607+
const registered = [];
608+
export function register(name) { registered.push(name); }
609+
export function listRegistered() { return [...registered]; }
610+
`;
611+
const REGISTRY_CJS = `const registered = [];
612+
exports.BUILD = 'cjs';
613+
exports.register = (name) => { registered.push(name); };
614+
exports.listRegistered = () => [...registered];
615+
`;
616+
/** A driver package: its entire contract is the load-time side effect. */
617+
const DRIVER_ESM = `import { register } from '${REGISTRY}';
618+
register('probe');
619+
export const BUILD = 'esm';
620+
`;
621+
const DRIVER_CJS = `const { register } = require('${REGISTRY}');
622+
register('probe');
623+
exports.BUILD = 'cjs';
624+
`;
625+
626+
const roots: string[] = [];
627+
628+
function writePackage(root: string, name: string, exportsField: unknown, files: Record<string, string>): void {
629+
const dir = join(root, 'node_modules', ...name.split('/'));
630+
mkdirSync(dir, { recursive: true });
631+
writeFileSync(
632+
join(dir, 'package.json'),
633+
JSON.stringify({
634+
name,
635+
version: '0.0.0-fixture',
636+
type: 'module',
637+
main: 'dist/index.js',
638+
exports: exportsField,
639+
}),
640+
'utf8',
641+
);
642+
for (const rel of Object.keys(files)) {
643+
const target = join(dir, rel);
644+
mkdirSync(dirname(target), { recursive: true });
645+
writeFileSync(target, files[rel] as string, 'utf8');
646+
}
647+
}
648+
649+
/**
650+
* The condition map `tsup` emits: nested, with `types` first, so the
651+
* resolution under test has to walk INTO the `import` branch rather than
652+
* match a flat string.
653+
*/
654+
const DUAL: unknown = {
655+
'.': {
656+
import: { types: './dist/index.d.ts', default: './dist/index.js' },
657+
require: { types: './dist/index.d.cts', default: './dist/index.cjs' },
658+
},
659+
};
660+
661+
/**
662+
* A fresh app per case. The ESM module cache is keyed by absolute URL, so a
663+
* shared fixture directory would let one case's load answer the next one's
664+
* question — the failure mode this whole suite exists to detect.
665+
*/
666+
function app(tag: string): string {
667+
const root = mkdtempSync(join(tmpdir(), `os-instance-split-${tag}-`));
668+
roots.push(root);
669+
writeFileSync(
670+
join(root, 'package.json'),
671+
JSON.stringify({
672+
name: 'dual-build-host-fixture',
673+
type: 'module',
674+
dependencies: {
675+
[REGISTRY]: '0.0.0-fixture',
676+
[DRIVER]: '0.0.0-fixture',
677+
[CJS_ONLY]: '0.0.0-fixture',
678+
[SUBPATHS]: '0.0.0-fixture',
679+
},
680+
}),
681+
'utf8',
682+
);
683+
writePackage(root, REGISTRY, DUAL, {
684+
'dist/index.js': REGISTRY_ESM,
685+
'dist/index.cjs': REGISTRY_CJS,
686+
});
687+
writePackage(root, DRIVER, DUAL, {
688+
'dist/index.js': DRIVER_ESM,
689+
'dist/index.cjs': DRIVER_CJS,
690+
});
691+
// Publishes ONE build, under the `require` condition only.
692+
writePackage(root, CJS_ONLY, { '.': { require: './dist/index.cjs' } }, {
693+
'dist/index.cjs': "exports.BUILD = 'cjs-only';\n",
694+
});
695+
writePackage(
696+
root,
697+
SUBPATHS,
698+
{
699+
'.': { import: './dist/index.js', require: './dist/index.cjs' },
700+
'./named': { import: './dist/named.js', require: './dist/named.cjs' },
701+
'./deep/*': { import: './dist/deep/*.js', require: './dist/deep/*.cjs' },
702+
},
703+
{
704+
'dist/index.js': "export const WHERE = 'root-esm';\n",
705+
'dist/index.cjs': "exports.WHERE = 'root-cjs';\n",
706+
'dist/named.js': "export const WHERE = 'named-esm';\n",
707+
'dist/named.cjs': "exports.WHERE = 'named-cjs';\n",
708+
'dist/deep/leaf.js': "export const WHERE = 'leaf-esm';\n",
709+
'dist/deep/leaf.cjs': "exports.WHERE = 'leaf-cjs';\n",
710+
},
711+
);
712+
return root;
713+
}
714+
715+
/** The instance an ESM consumer chain holds — what the Runtime reads. */
716+
function esmInstance(root: string): Promise<{ BUILD: string; listRegistered: () => string[]; register: (n: string) => void }> {
717+
return import(
718+
pathToFileURL(join(root, 'node_modules', ...REGISTRY.split('/'), 'dist', 'index.js')).href
719+
);
720+
}
721+
722+
/** The instance a CommonJS load lands in — where the registration used to go. */
723+
function cjsInstance(root: string): Promise<{ BUILD: string; listRegistered: () => string[]; register: (n: string) => void }> {
724+
return import(
725+
pathToFileURL(join(root, 'node_modules', ...REGISTRY.split('/'), 'dist', 'index.cjs')).href
726+
);
727+
}
728+
729+
// No `fallbackImport`: every fixture here is DECLARED, so the undeclared leg
730+
// (the only consumer of that base) is never reached.
731+
const importer = (root: string) => createHostImporter(root);
732+
733+
afterAll(() => {
734+
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
735+
});
736+
737+
it('CONTROL: the reader can see BOTH answers, so an empty registry is a reading', async () => {
738+
// Every assertion below rests on `listRegistered()` being able to come back
739+
// non-empty. A probe that could only ever return `[]` would make the whole
740+
// describe pass on a broken fix — so it is proved here, on the same
741+
// instrument, before anything is measured with it.
742+
const root = app('control');
743+
const esm = await esmInstance(root);
744+
const cjs = await cjsInstance(root);
745+
746+
expect(esm.BUILD).toBe('esm');
747+
expect(cjs.BUILD).toBe('cjs');
748+
expect(esm.listRegistered()).toEqual([]);
749+
750+
// And the two really are separate instances: writing one leaves the other
751+
// untouched. That is the split; without it there would be no defect to fix.
752+
cjs.register('control-probe');
753+
expect(cjs.listRegistered()).toEqual(['control-probe']);
754+
expect(esm.listRegistered()).toEqual([]);
755+
});
756+
757+
it('PRECONDITION: host CJS resolution still answers the `require` condition', () => {
758+
// The cause, pinned separately from the fix. `hostRequire.resolve` is still
759+
// the host-anchored half of the answer and is deliberately unchanged; if a
760+
// future Node stopped returning the `require` entry here, this test says so
761+
// rather than leaving the fix looking like a no-op.
762+
const root = app('precondition');
763+
expect(createHostRequire(root).resolve(DRIVER)).toMatch(/dist[/\\]index\.cjs$/);
764+
});
765+
766+
it('loads the `import` build of a declared package', async () => {
767+
const root = app('import-condition');
768+
expect((await importer(root)(DRIVER)).BUILD).toBe('esm');
769+
});
770+
771+
it("a driver's load-time registration lands in the instance an ESM caller reads", async () => {
772+
// The defect, stated as its consequence. Before the fix this was `[]`.
773+
const root = app('visible');
774+
expect((await esmInstance(root)).listRegistered()).toEqual([]);
775+
await importer(root)(DRIVER);
776+
expect((await esmInstance(root)).listRegistered()).toEqual(['probe']);
777+
});
778+
779+
it('and no longer lands in the CommonJS instance nothing reads', async () => {
780+
// The other direction: the registration MOVED, it was not duplicated. A
781+
// fix that loaded both builds would satisfy the previous case and still
782+
// leave a process holding two live copies of the package's state.
783+
const root = app('cjs-empty');
784+
await importer(root)(DRIVER);
785+
expect((await cjsInstance(root)).listRegistered()).toEqual([]);
786+
expect((await esmInstance(root)).listRegistered()).toEqual(['probe']);
787+
});
788+
789+
it('a package publishing only a `require` condition still loads', async () => {
790+
// Narrowness. There is no import entry to prefer, so the resolved CJS path
791+
// is used exactly as before — the fix may not turn a working load into a
792+
// failure.
793+
const root = app('cjs-only');
794+
expect((await importer(root)(CJS_ONLY)).BUILD).toBe('cjs-only');
795+
});
796+
797+
it('a package with no `exports` map at all is untouched', async () => {
798+
// `main` is the only entry such a package publishes and CJS resolution
799+
// already returned it; there is nothing to re-decide.
800+
const root = app('no-exports');
801+
writeFixturePackage(root, '@fixture/no-exports-map', 'export const BUILD = "main";\n');
802+
writeFileSync(
803+
join(root, 'package.json'),
804+
JSON.stringify({
805+
name: 'dual-build-host-fixture',
806+
type: 'module',
807+
dependencies: { '@fixture/no-exports-map': '0.0.0-fixture' },
808+
}),
809+
'utf8',
810+
);
811+
expect((await importer(root)('@fixture/no-exports-map')).BUILD).toBe('main');
812+
});
813+
814+
it('resolves a declared SUBPATH under the import condition', async () => {
815+
const root = app('subpath');
816+
expect((await importer(root)(SUBPATHS)).WHERE).toBe('root-esm');
817+
expect((await importer(root)(`${SUBPATHS}/named`)).WHERE).toBe('named-esm');
818+
});
819+
820+
it('resolves a wildcard subpath pattern under the import condition', async () => {
821+
const root = app('pattern');
822+
expect((await importer(root)(`${SUBPATHS}/deep/leaf`)).WHERE).toBe('leaf-esm');
823+
});
824+
});

0 commit comments

Comments
 (0)