Skip to content

Commit 75d9bf0

Browse files
committed
test: lock in lenient ESM for ambiguous and CJS-typed packages
## Problem tsx is intentionally more lenient than Node when classifying ambiguous packages: a `.js`/`.ts` file in a `package.json` without `"type"` (or with `"type": "commonjs"`) that mixes ESM `import`/`export` syntax with explicit `require()` calls runs successfully. tsx classifies the file as CJS and esbuild rewrites the ESM imports while passing `require` through. Plain Node detection refuses these files (`ReferenceError: require is not defined in ES module scope`); tsx's existing lenient behavior is what lets a decade of pre-ESM TypeScript projects keep running. This contract was not exercised by any test. A speculative "match Node's syntax detection" refactor against an earlier head of this branch passed the full suite while silently breaking every hybrid file. These tests close that gap. ## Changes New `commonjs-mode-contracts.ts` block `lenient ESM in CommonJS-classified files`, exercising four shapes through the existing `commonJsModes` loop (`omitted type` and `explicit commonjs` shapes per test): - hybrid `import` + bare `require()` in `.ts` - TypeScript `import x = require()` interop combined with bare `require()` - hybrid `import` + bare `require()` in `.js` - `export` paired with bare `require()` of a `.cjs` sibling Each test asserts exit 0, expected stdout, and empty stderr, with the existing `onTestFail(() => console.log(label, result))` pattern so the failing shape is visible.
1 parent 1472f3e commit 75d9bf0

1 file changed

Lines changed: 164 additions & 0 deletions

File tree

tests/specs/commonjs-mode-contracts.ts

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,4 +147,168 @@ export const commonJsModeContracts = (node: NodeApis) => describe('CommonJS mode
147147
});
148148
}
149149
});
150+
151+
// tsx is intentionally more lenient than Node for ambiguous-`type` and
152+
// explicit `"commonjs"` packages: a `.js`/`.ts` file that mixes ESM
153+
// `import`/`export` syntax with explicit `require()` calls still runs,
154+
// because tsx classifies the file as CJS and esbuild rewrites the ESM
155+
// imports while leaving `require` intact. Plain Node would refuse the
156+
// same file with `ReferenceError: require is not defined in ES module
157+
// scope`. Any "match Node's syntax detection" refactor of the classifier
158+
// must keep these passing or be split behind a feature flag.
159+
describe('lenient ESM in CommonJS-classified files', () => {
160+
// Hybrid `import` + bare `require()` in a TypeScript file. The naive
161+
// failure mode is detection promoting the file to "module", which
162+
// drops `require` from scope.
163+
test('hybrid import and require() in .ts runs', async () => {
164+
for (const { label, packageJson } of commonJsModes) {
165+
await using fixture = await createFixture({
166+
'package.json': createPackageJson(packageJson),
167+
'entry.ts': `
168+
import { posix } from 'node:path';
169+
170+
const config = require('./config.json');
171+
172+
console.log(JSON.stringify({
173+
joined: posix.join('/', 'foo'),
174+
config,
175+
}));
176+
`,
177+
'config.json': JSON.stringify({ loaded: true }),
178+
});
179+
180+
const result = await node.tsx(['entry.ts'], fixture.path);
181+
onTestFail(() => {
182+
console.log(label, result);
183+
});
184+
185+
expect({
186+
failed: result.failed,
187+
exitCode: result.exitCode,
188+
stdout: result.stdout,
189+
stderr: result.stderr,
190+
}).toEqual({
191+
failed: false,
192+
exitCode: 0,
193+
stdout: '{"joined":"/foo","config":{"loaded":true}}',
194+
stderr: '',
195+
});
196+
}
197+
});
198+
199+
// TypeScript `import x = require()` interop combined with additional
200+
// bare `require()` calls. Detection promotion would break the bare
201+
// `require()` even when the `import =` form is preserved.
202+
test('TypeScript import = require() interop with bare require() runs', async () => {
203+
for (const { label, packageJson } of commonJsModes) {
204+
await using fixture = await createFixture({
205+
'package.json': createPackageJson(packageJson),
206+
'entry.ts': `
207+
import path = require('node:path');
208+
209+
const config = require('./config.json');
210+
211+
console.log(JSON.stringify({
212+
joined: path.posix.join('/', 'foo'),
213+
config,
214+
}));
215+
`,
216+
'config.json': JSON.stringify({ loaded: true }),
217+
});
218+
219+
const result = await node.tsx(['entry.ts'], fixture.path);
220+
onTestFail(() => {
221+
console.log(label, result);
222+
});
223+
224+
expect({
225+
failed: result.failed,
226+
exitCode: result.exitCode,
227+
stdout: result.stdout,
228+
stderr: result.stderr,
229+
}).toEqual({
230+
failed: false,
231+
exitCode: 0,
232+
stdout: '{"joined":"/foo","config":{"loaded":true}}',
233+
stderr: '',
234+
});
235+
}
236+
});
237+
238+
// Same hybrid shape, but in a `.js` file with no TypeScript syntax.
239+
// Detection refactors that key off ESM tokens in plain JS regress
240+
// here too -- the `.js` extension is not a hint either way without
241+
// a `"type"` field.
242+
test('hybrid import and require() in .js runs', async () => {
243+
for (const { label, packageJson } of commonJsModes) {
244+
await using fixture = await createFixture({
245+
'package.json': createPackageJson(packageJson),
246+
'entry.js': `
247+
import { posix } from 'node:path';
248+
249+
const config = require('./config.json');
250+
251+
console.log(JSON.stringify({
252+
joined: posix.join('/', 'foo'),
253+
config,
254+
}));
255+
`,
256+
'config.json': JSON.stringify({ loaded: true }),
257+
});
258+
259+
const result = await node.tsx(['entry.js'], fixture.path);
260+
onTestFail(() => {
261+
console.log(label, result);
262+
});
263+
264+
expect({
265+
failed: result.failed,
266+
exitCode: result.exitCode,
267+
stdout: result.stdout,
268+
stderr: result.stderr,
269+
}).toEqual({
270+
failed: false,
271+
exitCode: 0,
272+
stdout: '{"joined":"/foo","config":{"loaded":true}}',
273+
stderr: '',
274+
});
275+
}
276+
});
277+
278+
// `export` paired with bare `require()` against a sibling `.cjs`
279+
// module. Symmetric coverage with the `import` cases above and
280+
// pins the export-side of the lenient contract.
281+
test('export with bare require() of a .cjs sibling runs', async () => {
282+
for (const { label, packageJson } of commonJsModes) {
283+
await using fixture = await createFixture({
284+
'package.json': createPackageJson(packageJson),
285+
'entry.ts': `
286+
const local = require('./util.cjs');
287+
288+
export const result = local.value;
289+
290+
console.log('result:', result);
291+
`,
292+
'util.cjs': 'module.exports.value = "from-cjs";',
293+
});
294+
295+
const result = await node.tsx(['entry.ts'], fixture.path);
296+
onTestFail(() => {
297+
console.log(label, result);
298+
});
299+
300+
expect({
301+
failed: result.failed,
302+
exitCode: result.exitCode,
303+
stdout: result.stdout,
304+
stderr: result.stderr,
305+
}).toEqual({
306+
failed: false,
307+
exitCode: 0,
308+
stdout: 'result: from-cjs',
309+
stderr: '',
310+
});
311+
}
312+
});
313+
});
150314
});

0 commit comments

Comments
 (0)