current commit
261020.1 · chore: self-maintain Node.js features
const assert = require("node:assert");
const fs = require("node:fs");
const path = require("node:path");
const repl = require("node:repl");
const { PassThrough } = require("node:stream");
const { test } = require("node:test");
const {
	findTopLevelAwaits,
	getFirstExpression,
	isRecoverableError,
	isValidSyntax,
	tokenize,
	transformModuleSyntax,
} = require("../dist/internal.js");

const AsyncFunction = Object.getPrototypeOf(async () => {}).constructor;

function validatedImport(specifier, requiredExports) {
	const checks = requiredExports
		.map(
			(name) =>
				`if (!(${JSON.stringify(name)} in m)) throw new SyntaxError(${JSON.stringify(
					`The requested module '${specifier}' does not provide an export named '${name}'`,
				)}); `,
		)
		.join("");
	return `__nodeREPLDynamicImport(${JSON.stringify(specifier)}).then((m) => { ${checks}return m; })`;
}

function execute(source, namespace) {
	return new AsyncFunction(
		"__nodeREPLDynamicImport",
		transformModuleSyntax(source).code,
	)(async () => namespace);
}

test("should transform module syntax for the Node.js REPL", () => {
	const result = transformModuleSyntax(
		'import { readFile } from "node:fs/promises";\nreadFile("file");',
	);

	assert.strictEqual(result.hadModuleSyntax, true);
	assert.match(result.code, /__nodeREPLDynamicImport/);
	assert.match(result.code, /readFile/);
});

test("should declare imported bindings instead of rewriting references", () => {
	assert.deepStrictEqual(transformModuleSyntax('import fs from "node:fs";'), {
		code: `const { default: fs } = await ${validatedImport("node:fs", ["default"])};`,
		hadModuleSyntax: true,
	});
	assert.deepStrictEqual(
		transformModuleSyntax('import { readFile as rf } from "node:fs";\nrf();'),
		{
			code: `const { readFile: rf } = await ${validatedImport("node:fs", ["readFile"])};\nrf();`,
			hadModuleSyntax: true,
		},
	);
	assert.deepStrictEqual(
		transformModuleSyntax('import def, * as ns from "mod";\ndef; ns;'),
		{
			code: `const ns = await ${validatedImport("mod", ["default"])}; const def = ns.default;\ndef; ns;`,
			hadModuleSyntax: true,
		},
	);
	assert.deepStrictEqual(
		transformModuleSyntax('import { "a-b" as c } from "mod";\nc;'),
		{
			code: `const { "a-b": c } = await ${validatedImport("mod", ["a-b"])};\nc;`,
			hadModuleSyntax: true,
		},
	);
});

test("should retain imported bindings across REPL inputs", async () => {
	const server = repl.start({
		input: new PassThrough(),
		output: new PassThrough(),
		terminal: false,
		prompt: "",
	});
	const namespace = { default: 7, value: 11, "a-b": 13 };
	server.context.__nodeREPLDynamicImport = async () => namespace;
	// Arrays come from the REPL's vm context, so copy them into this realm
	// before comparing prototypes with deepStrictEqual.
	const evaluate = (source) =>
		new Promise((resolve, reject) => {
			server.eval(
				`${transformModuleSyntax(source).code}\n`,
				server.context,
				"repl-import-test",
				(error, value) =>
					error
						? reject(error)
						: resolve(Array.isArray(value) ? [...value] : value),
			);
		});
	try {
		await evaluate('import first from "m";');
		assert.strictEqual(await evaluate("first"), 7);
		await evaluate('import { value as second, "a-b" as third } from "m";');
		assert.deepStrictEqual(
			await evaluate("[first, second, third]"),
			[7, 11, 13],
		);
		await evaluate('import another, * as ns from "m";');
		assert.deepStrictEqual(await evaluate("[another, ns.value]"), [7, 11]);
		namespace.value = 17;
		assert.deepStrictEqual(await evaluate("[second, ns.value]"), [11, 17]);
	} finally {
		server.close();
	}
});

test("should preserve bare-call this and lexical shadowing", async () => {
	// A bare call of a destructured binding must not receive the namespace
	// object as `this`, unlike the former `__nodeREPLImport0.call()` rewrite.
	const namespace = {
		call() {
			return this === namespace;
		},
		value: 1,
	};
	const { code } = transformModuleSyntax(
		'import { call, value } from "m";\nconst observed = [call(), (() => { const value = 2; return value; })(), value];',
	);
	const observed = await new AsyncFunction(
		"__nodeREPLDynamicImport",
		`${code}\nreturn observed;`,
	)(async () => namespace);
	assert.deepStrictEqual(observed, [false, 2, 1]);
});

test("should let JavaScript enforce const assignment semantics", async () => {
	for (const assignment of [
		"value = 2",
		"value++",
		"({ value } = { value: 2 })",
	]) {
		await assert.rejects(
			execute(`import { value } from "m";\n${assignment};`, { value: 1 }),
			/Assignment to constant variable\./,
		);
	}
});

test("should keep missing-export validation and handle special export names", async () => {
	await assert.rejects(
		execute('import missing from "m";', {}),
		/does not provide an export named 'default'/,
	);
	await assert.rejects(
		execute('import { missing } from "m";', {}),
		/does not provide an export named 'missing'/,
	);
	const { code } = transformModuleSyntax(
		'import { "a-b" as value, __proto__ as proto } from "m";',
	);
	const result = await new AsyncFunction(
		"__nodeREPLDynamicImport",
		`${code}\nreturn [value, proto];`,
	)(async () => ({ "a-b": 1, ["__proto__"]: 2 }));
	assert.deepStrictEqual(result, [1, 2]);
});

test("should find the first expression at an error column", () => {
	assert.strictEqual(
		getFirstExpression("a(); assert.ok(value); b()", 13),
		"assert.ok(value)",
	);
});

test("should validate syntax", () => {
	assert.strictEqual(isValidSyntax("const value: number = 1"), true);
	assert.strictEqual(isValidSyntax("function foo("), false);
});

test("should detect recoverable syntax errors", () => {
	assert.strictEqual(isRecoverableError("function foo() {"), true);
	assert.strictEqual(isRecoverableError("const value: number = 1"), false);
});

test("should tokenize input for syntax highlighting", () => {
	assert.deepStrictEqual(tokenize("const x = 1; // done"), [
		{ kind: "keyword", start: 0, end: 5 },
		{ kind: "identifier", start: 6, end: 7 },
		{ kind: "punctuator", start: 8, end: 9 },
		{ kind: "number", start: 10, end: 11 },
		{ kind: "punctuator", start: 11, end: 12 },
		{ kind: "comment", start: 13, end: 20 },
	]);
});

test("should find top-level awaits", () => {
	assert.deepStrictEqual(
		findTopLevelAwaits("await 1;\nasync function f() { await 2; }\nawait 3;"),
		[
			{ line: 1, column: 0 },
			{ line: 3, column: 0 },
		],
	);
});

const fixturesDir = path.join(__dirname, "fixtures", "nodejs");
for (const [suite, api] of [
	["tokenize", tokenize],
	["awaits", findTopLevelAwaits],
	["recoverable", isRecoverableError],
]) {
	const dir = path.join(fixturesDir, suite);
	for (const file of fs.readdirSync(dir)) {
		const cases = JSON.parse(fs.readFileSync(path.join(dir, file), "utf8"));
		for (const { source, expected } of cases) {
			test(`${suite}/${file}: ${JSON.stringify(source)}`, () => {
				assert.deepStrictEqual(api(source), expected);
			});
		}
	}
}