Skip to content

test: load specs as native ES modules - #54171

Merged
MarshallOfSound merged 2 commits into
claude/spec-erasable-syntaxfrom
claude/spec-native-esm
Sep 21, 2026
Merged

MarshallOfSound merged 2 commits into
claude/spec-erasable-syntaxfrom
claude/spec-native-esm

Conversation

@MarshallOfSound

Copy link
Copy Markdown
Member

Description of Change

Before: specs were CommonJS at runtime: a require hook transpiled each spec/**/*.ts with the TypeScript compiler API as it was loaded.

After: spec/package.json declares "type": "module" and the runner and every spec load through Node's own ESM loader and built-in type stripping, with no transpile step. spec/fixtures/package.json keeps fixtures in a CommonJS scope so they behave as before.

The change is in two commits to make it reviewable. The first is purely mechanical, produced by the script below and an oxfmt run: relative imports gain their real file extension, __dirname/__filename become import.meta.dirname/.filename, and files that still call require() from module code get a createRequire prelude (code inside functions that are stringified and run in another process is left alone). The second commit is the hand-written part: spec/index.js becomes an ES module that import()s its dependencies after the app is ready and calls mocha.loadFilesAsync(); test bodies that are stringified and sent elsewhere now arrive as written rather than as CommonJS transpiler output, so the remote-control and utility-process fixtures define the plain names those bodies use instead of the old electron_1-style shims; the two specs that exercise lib/ sources require() them; and tsconfig.spec.json allows .ts extension imports, with a spec/tsconfig.json so editors pick it up.

Codemod used for the first commit
// Mechanical part of moving spec/ to native ES modules:
//  1. relative import specifiers get their real file extension
//  2. __dirname / __filename become import.meta.dirname / import.meta.filename
//  3. files that still call require() from module code get a createRequire()
//     prelude
// Code inside functions that are stringified and evaluated in another process
// (remotely(), itremote(), `${fn}` and friends) is left untouched by 2 and 3.
// Run from the repo root:
//   node spec-esm-codemod.js spec/*.ts spec/lib/*.ts && yarn oxfmt 'spec/*.ts' 'spec/lib/*.ts'
const fs = require('node:fs');
const path = require('node:path');
const ts = require(path.resolve('node_modules/typescript'));

// Helpers whose function arguments are serialised and evaluated elsewhere.
const STRINGIFIERS = new Set(['remotely', 'itremote', 'itUtility', 'runInUtility', 'remoteEval', 'callWithBindings', 'makeBindingWindow']);

let specifiers = 0;
let metas = 0;
let preludes = 0;

const addExtension = (file, spec) => {
  if (/\.(m?[jt]s|cjs|json|node)$/.test(spec)) return spec;
  const base = path.resolve(path.dirname(file), spec);
  for (const ext of ['.ts', '.js', '.mjs']) {
    if (fs.existsSync(base + ext)) return spec + ext;
  }
  for (const ext of ['.ts', '.js']) {
    if (fs.existsSync(path.join(base, 'index' + ext))) return `${spec}/index${ext}`;
  }
  throw new Error(`${file}: cannot resolve ${spec}`);
};

const calleeNames = (expr, names = []) => {
  if (ts.isIdentifier(expr)) names.push(expr.text);
  else if (ts.isPropertyAccessExpression(expr)) {
    names.push(expr.name.text);
    calleeNames(expr.expression, names);
  } else if (ts.isCallExpression(expr) || ts.isElementAccessExpression(expr)) calleeNames(expr.expression, names);
  return names;
};

const unwrap = (node) => (ts.isParenthesizedExpression(node.parent) ? unwrap(node.parent) : node);

const functionName = (fn) =>
  fn.name?.text ?? (ts.isVariableDeclaration(fn.parent) && ts.isIdentifier(fn.parent.name) ? fn.parent.name.text : null);

const isFunctionLike = (node) => ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node) || ts.isArrowFunction(node);

// Is this expression in a position whose value ends up serialised: interpolated
// into a template, .toString()ed, or passed to one of the stringifier helpers?
const inStringifiedPosition = (node, stringifiers) => {
  node = unwrap(node);
  const parent = node.parent;
  if (ts.isTemplateSpan(parent)) return true;
  if (ts.isPropertyAccessExpression(parent) && parent.name.text === 'toString') return true;
  if (ts.isCallExpression(parent) && parent.arguments.includes(node)) {
    return calleeNames(parent.expression).some((name) => stringifiers.has(name));
  }
  return false;
};

// Returns { edits: [[start, end, text]], needsRequire } for the module-scope code of a file.
const analyse = (file, text) => {
  const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
  const stringifiers = new Set(STRINGIFIERS);
  const stringifiedNames = new Set();

  // Names in this file that are bound to functions.
  const declaredFunctions = new Set();
  const collect = (node) => {
    if (ts.isFunctionDeclaration(node) && node.name) declaredFunctions.add(node.name.text);
    if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && isFunctionLike(node.initializer)) {
      declaredFunctions.add(node.name.text);
    }
    ts.forEachChild(node, collect);
  };
  collect(sf);
  const isFunctionTyped = (param) =>
    param.type && (ts.isFunctionTypeNode(param.type) || (ts.isTypeReferenceNode(param.type) && param.type.typeName.getText(sf) === 'Function'));

  // Local functions that are referenced by name in a stringified position, and
  // local helpers that stringify one of their own function-typed parameters
  // (calls to those helpers then count as stringifiers too). Iterate to a
  // fixed point.
  for (let changed = true; changed; ) {
    changed = false;
    const scan = (node, fnStack) => {
      if (isFunctionLike(node)) fnStack = [...fnStack, node];
      if (ts.isIdentifier(node) && inStringifiedPosition(node, stringifiers)) {
        if (declaredFunctions.has(node.text) && !stringifiedNames.has(node.text)) {
          stringifiedNames.add(node.text);
          changed = true;
        }
        for (const fn of fnStack) {
          if (fn.parameters.some((p) => ts.isIdentifier(p.name) && p.name.text === node.text && isFunctionTyped(p))) {
            const name = functionName(fn);
            if (name && !stringifiers.has(name)) {
              stringifiers.add(name);
              changed = true;
            }
          }
        }
      }
      ts.forEachChild(node, (child) => scan(child, fnStack));
    };
    scan(sf, []);
  }

  const isStringifiedFunction = (fn) =>
    inStringifiedPosition(fn, stringifiers) || stringifiedNames.has(functionName(fn));

  const isReference = (id) => {
    const p = id.parent;
    if ((ts.isPropertyAccessExpression(p) && p.name === id) || (ts.isPropertyAssignment(p) && p.name === id)) return false;
    if ((ts.isVariableDeclaration(p) || ts.isParameter(p) || ts.isBindingElement(p)) && p.name === id) return false;
    return true;
  };

  const edits = [];
  let needsRequire = false;
  const visit = (node, stringified) => {
    if (isFunctionLike(node) && isStringifiedFunction(node)) stringified = true;
    if (!stringified && ts.isIdentifier(node) && isReference(node)) {
      if (node.text === 'require') needsRequire = true;
      if (node.text === '__dirname') edits.push([node.getStart(sf), node.end, 'import.meta.dirname']);
      if (node.text === '__filename') edits.push([node.getStart(sf), node.end, 'import.meta.filename']);
    }
    ts.forEachChild(node, (child) => visit(child, stringified));
  };
  visit(sf, false);
  return { edits, needsRequire };
};

for (const file of process.argv.slice(2)) {
  const src = fs.readFileSync(file, 'utf8');
  let out = src.replace(/((?:from|import)\s*\(?\s*)(['"])(\.\.?\/[^'"]+)\2/g, (m, pre, q, spec) => {
    const resolved = addExtension(file, spec);
    if (resolved !== spec) specifiers++;
    return `${pre}${q}${resolved}${q}`;
  });

  const { edits, needsRequire } = analyse(file, out);
  for (const [start, end, replacement] of edits.sort((a, b) => b[0] - a[0])) {
    out = out.slice(0, start) + replacement + out.slice(end);
    metas++;
  }

  if (needsRequire && !out.includes('createRequire')) {
    const lines = out.split('\n');
    let last = -1;
    for (let i = 0; i < lines.length; i++) {
      if (/^import .*';$/.test(lines[i]) || /^} from '.*';$/.test(lines[i])) last = i;
      else if (last !== -1 && lines[i].trim() !== '' && !/^import |^  |^}/.test(lines[i])) break;
    }
    const prelude = ["import { createRequire } from 'node:module';", '', 'const require = createRequire(import.meta.url);'];
    if (last === -1) lines.unshift(...prelude, '');
    else lines.splice(last + 1, 0, ...prelude);
    out = lines.join('\n');
    preludes++;
  }

  if (out !== src) fs.writeFileSync(file, out);
}

console.log(`rewrote ${specifiers} specifiers and ${metas} __dirname/__filename uses, added ${preludes} createRequire preludes`);

Checklist

Release Notes

Notes: none


Generated by Claude Code

@MarshallOfSound
MarshallOfSound added this pull request to stack #54173 September 21, 2026 09:13
@MarshallOfSound
MarshallOfSound requested a review from a team as a code owner September 21, 2026 09:36

@ckerr ckerr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. Reviewed manually and with Astra xhigh.

Note: manual review skimmed the mechanical commit, did active review on the second commit.

stack merge was automatically disabled September 21, 2026 20:06

Pull Request is not mergeable

This is the automated half of loading spec/ as native ES modules and is
not expected to pass on its own; the hand-written half follows in the
next commit. A script (included in the pull request description) made
three changes to spec/*.ts and spec/lib/*.ts, then oxfmt was re-run:

* relative import specifiers gain their real file extension
* __dirname / __filename become import.meta.dirname / .filename
* files that still call require() from module code get a
  createRequire(import.meta.url) prelude

Code inside functions that are stringified and evaluated in another
process (remotely(), itremote(), template-interpolated functions and
similar) is left as it was.
spec/package.json declares "type": "module", so the spec runner and every
spec file now load through Node's own ESM loader and built-in TypeScript
type stripping; the require.extensions transpile hook is gone. A new
spec/fixtures/package.json keeps fixtures in a CommonJS scope.

The hand-written changes on top of the mechanical rewrite:

* spec/index.js becomes an ES module and import()s its test dependencies
  after the app is ready, then uses mocha.loadFilesAsync().
* Functions that are stringified and sent to another process now arrive
  as written rather than as CommonJS transpiler output, so the
  remote-control and utility-process fixtures define the plain names
  those bodies use (chai, expect, once, setTimeout, ...) instead of the
  old `electron_1` style shims, and a few bodies require() what they use.
* The two specs that exercise lib/ sources require() them, since lib/ is
  not in a "type": "module" scope.
* spec-helpers resolves BrowserWindow lazily so it stays loadable from a
  utility process.
* tsconfig.spec.json allows .ts extension imports; spec/tsconfig.json
  points editors at it.
@MarshallOfSound
MarshallOfSound requested review from a team as code owners September 21, 2026 20:14
@MarshallOfSound
MarshallOfSound merged commit 9baaed7 into main Sep 21, 2026
71 checks passed
@MarshallOfSound
MarshallOfSound deleted the claude/spec-native-esm branch September 21, 2026 22:21
@release-clerk

release-clerk Bot commented Sep 21, 2026

Copy link
Copy Markdown

No Release Notes

@trop

trop Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

@MarshallOfSound has manually backported this PR to "45-x-y", please check out #54193

@trop

trop Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

@MarshallOfSound has manually backported this PR to "44-x-y", please check out #54194

@trop

trop Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

@MarshallOfSound has manually backported this PR to "43-x-y", please check out #54195

@trop

trop Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

@MarshallOfSound has manually backported this PR to "42-x-y", please check out #54196

MarshallOfSound added a commit that referenced this pull request Sep 22, 2026
…ript 7 (45-x-y) (#54193)

* test: assert that import and require expose the same electron module (#54166)

The `electron` module that ES module code sees is produced by wrapping the
CommonJS one, so both should expose exactly the same names bound to
exactly the same objects. Nothing checked that until now; esm-spec only
verified that the imports do not throw.

A new fixture app loads `electron` (and the process-specific
`electron/main`, `/common`, `/renderer`, `/utility` aliases) through both
`import()` and `require()` in the main process, a utility process and a
non-sandboxed renderer preload, and reports any export that only one
side has, any export that is not the identical value on both sides, and
a default import that is not the `require()` exports object. esm-spec
asserts the report is empty for each process type.

* build: update yarn to 4.18.0 (#54167)

* build: update yarn from 4.12.0 to 4.18.0

Bumps the vendored yarn release (and the DEPS hook, .yarnrc.yml,
script/yarn.js and packageManager references to it) to 4.18.0.
.yarn/releases/yarn-4.18.0.cjs is bin/yarn.js from @yarnpkg/cli-dist@4.18.0
unmodified. The lockfile only changes its metadata version.

Among other fixes, 4.18's built-in TypeScript compatibility patch knows
about the TypeScript 7 package layout; 4.12 fails the fetch step with
ENOENT on lib/_tsc.js when asked to install typescript@7.

* build: bump the nan test lockfile to yarn 4.18's metadata version

The nan spec runner installs nan's test dependencies with the vendored
yarn in immutable mode, and yarn 4.18 refuses the install because it
wants to rewrite the lockfile's metadata version from 8 to 10. Regenerate
the lockfile in the nan patch with 4.18 so the install is a no-op again.

* build: remove release orchestration scripts superseded by Sudowoodo (#52735)

* build: remove release orchestration scripts superseded by Sudowoodo

Follow-up to #52100. Sudowoodo now reads the expected-asset manifest from
script/release/release-assets.json (#52730, electron/sudowoodo#459)
instead of parsing release.ts, which removes the last reason to keep the
dead orchestration code:

- release.ts: no entrypoints since #52100; only remaining consumer was
  Sudowoodo's manifest regex, now retired
- get-url-hash.ts: imported only by release.ts
- upload-node-checksums.py / upload-index-json.py: invoked only by
  release.ts's uploadNodeShasums/uploadIndexJson; Sudowoodo does both
  first-party (checksums-scratchpad merge, direct index.json blob upload)
- get-asset.ts: no importers anywhere

The CI upload subtree stays: upload.py, upload-symbols.py,
upload-node-headers.py, upload-to-github.ts, find-github-release.ts,
github-token.ts, types.ts are invoked by build CI during publish builds
and are unrelated to the Sudowoodo migration.

* build: drop upload.py's unused --publish-release flag

Parsed but never read, and no workflow passes it — a fossil of the
pre-Sudowoodo flow where CI could trigger the publish itself.

* build: run the TypeScript scripts under script/ with plain node (#54168)

ts-node was used as a CLI for a handful of TypeScript scripts under
script/. Those now rely on the type stripping built into Node.js instead:

- script/gen-filenames, check-patch-diff, run-clang-tidy and the release
  helpers (find-github-release, upload-to-github, github-token, types)
  are renamed to .mts and invoked with `node`. They use
  import.meta.dirname and explicit .mts / .js specifiers, and
  tsconfig.script.json moves to nodenext with verbatimModuleSyntax so
  tsc checks them the way Node runs them.
- The documented minimum Node.js for building goes from 22.12 to 22.18,
  the first 22.x release with type stripping on by default.
- script/codesign/gen-trust.ts and its trust.xml template are deleted;
  nothing has invoked them since generate-identity.sh switched to a
  user-scoped keychain in #50058.
- oxfmt, oxlint, lint-staged and .gitattributes learn about .mts.

ts-node itself stays for now because the spec runner still loads specs
through its require hook.

* build: remove the ts-node dependency (#54169)

The last user of ts-node was the spec runner, which registered it as a
require hook so spec/**/*.ts could be loaded inside Electron (the
utility-process net fixture did the same).

Both now register a small local hook, spec/ts-register.js, that
transpiles each .ts file on its own with the TypeScript compiler API
using tsconfig.spec.json and inlines a source map so stack traces still
point at the .ts lines. ts-node also type checked specs as it loaded
them; that check now runs once in the lint job via
`tsc -p tsconfig.spec.json` instead, so type errors in specs fail lint
rather than surfacing part-way through a test shard.

Drops ts-node 6.2.0 and its transitive arrify, buffer-from, diff@3,
make-error, mkdirp@0.5, source-map-support and yn entries from
yarn.lock.

* test: keep spec sources to erasable syntax and explicit type imports (#54170)

Turn on erasableSyntaxOnly and isolatedModules for the spec typecheck so
the specs only use TypeScript syntax that a plain type stripper can
remove (no enums, parameter properties, import = require or angle
bracket casts), and lint spec/ for type-only imports so they are
written as `import type` / inline `type` specifiers.

Also hoist a handful of inline require() calls in spec bodies up to the
existing top-level imports and use named imports from ws.

* test: load specs as native ES modules (#54171)

* test: mechanically rewrite spec imports and __dirname for native ESM

This is the automated half of loading spec/ as native ES modules and is
not expected to pass on its own; the hand-written half follows in the
next commit. A script (included in the pull request description) made
three changes to spec/*.ts and spec/lib/*.ts, then oxfmt was re-run:

* relative import specifiers gain their real file extension
* __dirname / __filename become import.meta.dirname / .filename
* files that still call require() from module code get a
  createRequire(import.meta.url) prelude

Code inside functions that are stringified and evaluated in another
process (remotely(), itremote(), template-interpolated functions and
similar) is left as it was.

* test: load specs as native ES modules with Node's type stripping

spec/package.json declares "type": "module", so the spec runner and every
spec file now load through Node's own ESM loader and built-in TypeScript
type stripping; the require.extensions transpile hook is gone. A new
spec/fixtures/package.json keeps fixtures in a CommonJS scope.

The hand-written changes on top of the mechanical rewrite:

* spec/index.js becomes an ES module and import()s its test dependencies
  after the app is ready, then uses mocha.loadFilesAsync().
* Functions that are stringified and sent to another process now arrive
  as written rather than as CommonJS transpiler output, so the
  remote-control and utility-process fixtures define the plain names
  those bodies use (chai, expect, once, setTimeout, ...) instead of the
  old `electron_1` style shims, and a few bodies require() what they use.
* The two specs that exercise lib/ sources require() them, since lib/ is
  not in a "type": "module" scope.
* spec-helpers resolves BrowserWindow lazily so it stays loadable from a
  utility process.
* tsconfig.spec.json allows .ts extension imports; spec/tsconfig.json
  points editors at it.

* build: upgrade to TypeScript 7 (#54172)

TypeScript 7.0 is the native compiler and ships no JavaScript API. The
earlier changes in this series already moved everything that transpiled
on the fly (script/ and spec/) onto Node's own type stripping, so what
is left here is the compiler swap itself:

* typescript ^5.8.3 -> ^7.0.2.
* build/bundle/typecheck.mjs spawns tsc instead of driving the compiler
  API (the rootDir/private-field diagnostics it used to filter out are
  handled by tsconfig.electron.json setting rootDir to the parent
  checkout instead).
* tsconfig.json drops the removed baseUrl option and anchors the paths
  mapping explicitly; tsconfig.default_app.json moves off the removed
  "node" module resolution; `declare module NodeJS` becomes a namespace.
* A few lib/, spec and docs snippets wrap Buffers for the stricter
  Buffer/ArrayBuffer typings that Blob now expects.
@trop trop Bot removed the in-flight/45-x-y label Sep 22, 2026
MarshallOfSound added a commit that referenced this pull request Sep 22, 2026
* test: assert that import and require expose the same electron module (#54166)

The `electron` module that ES module code sees is produced by wrapping the
CommonJS one, so both should expose exactly the same names bound to
exactly the same objects. Nothing checked that until now; esm-spec only
verified that the imports do not throw.

A new fixture app loads `electron` (and the process-specific
`electron/main`, `/common`, `/renderer`, `/utility` aliases) through both
`import()` and `require()` in the main process, a utility process and a
non-sandboxed renderer preload, and reports any export that only one
side has, any export that is not the identical value on both sides, and
a default import that is not the `require()` exports object. esm-spec
asserts the report is empty for each process type.

* build: update yarn to 4.18.0 (#54167)

* build: update yarn from 4.12.0 to 4.18.0

Bumps the vendored yarn release (and the DEPS hook, .yarnrc.yml,
script/yarn.js and packageManager references to it) to 4.18.0.
.yarn/releases/yarn-4.18.0.cjs is bin/yarn.js from @yarnpkg/cli-dist@4.18.0
unmodified. The lockfile only changes its metadata version.

Among other fixes, 4.18's built-in TypeScript compatibility patch knows
about the TypeScript 7 package layout; 4.12 fails the fetch step with
ENOENT on lib/_tsc.js when asked to install typescript@7.

* build: bump the nan test lockfile to yarn 4.18's metadata version

The nan spec runner installs nan's test dependencies with the vendored
yarn in immutable mode, and yarn 4.18 refuses the install because it
wants to rewrite the lockfile's metadata version from 8 to 10. Regenerate
the lockfile in the nan patch with 4.18 so the install is a no-op again.

* build: remove release orchestration scripts superseded by Sudowoodo (#52735)

* build: remove release orchestration scripts superseded by Sudowoodo

Follow-up to #52100. Sudowoodo now reads the expected-asset manifest from
script/release/release-assets.json (#52730, electron/sudowoodo#459)
instead of parsing release.ts, which removes the last reason to keep the
dead orchestration code:

- release.ts: no entrypoints since #52100; only remaining consumer was
  Sudowoodo's manifest regex, now retired
- get-url-hash.ts: imported only by release.ts
- upload-node-checksums.py / upload-index-json.py: invoked only by
  release.ts's uploadNodeShasums/uploadIndexJson; Sudowoodo does both
  first-party (checksums-scratchpad merge, direct index.json blob upload)
- get-asset.ts: no importers anywhere

The CI upload subtree stays: upload.py, upload-symbols.py,
upload-node-headers.py, upload-to-github.ts, find-github-release.ts,
github-token.ts, types.ts are invoked by build CI during publish builds
and are unrelated to the Sudowoodo migration.

* build: drop upload.py's unused --publish-release flag

Parsed but never read, and no workflow passes it — a fossil of the
pre-Sudowoodo flow where CI could trigger the publish itself.

* build: run the TypeScript scripts under script/ with plain node (#54168)

ts-node was used as a CLI for a handful of TypeScript scripts under
script/. Those now rely on the type stripping built into Node.js instead:

- script/gen-filenames, check-patch-diff, run-clang-tidy and the release
  helpers (find-github-release, upload-to-github, github-token, types)
  are renamed to .mts and invoked with `node`. They use
  import.meta.dirname and explicit .mts / .js specifiers, and
  tsconfig.script.json moves to nodenext with verbatimModuleSyntax so
  tsc checks them the way Node runs them.
- The documented minimum Node.js for building goes from 22.12 to 22.18,
  the first 22.x release with type stripping on by default.
- script/codesign/gen-trust.ts and its trust.xml template are deleted;
  nothing has invoked them since generate-identity.sh switched to a
  user-scoped keychain in #50058.
- oxfmt, oxlint, lint-staged and .gitattributes learn about .mts.

ts-node itself stays for now because the spec runner still loads specs
through its require hook.

* build: remove the ts-node dependency (#54169)

The last user of ts-node was the spec runner, which registered it as a
require hook so spec/**/*.ts could be loaded inside Electron (the
utility-process net fixture did the same).

Both now register a small local hook, spec/ts-register.js, that
transpiles each .ts file on its own with the TypeScript compiler API
using tsconfig.spec.json and inlines a source map so stack traces still
point at the .ts lines. ts-node also type checked specs as it loaded
them; that check now runs once in the lint job via
`tsc -p tsconfig.spec.json` instead, so type errors in specs fail lint
rather than surfacing part-way through a test shard.

Drops ts-node 6.2.0 and its transitive arrify, buffer-from, diff@3,
make-error, mkdirp@0.5, source-map-support and yn entries from
yarn.lock.

* test: keep spec sources to erasable syntax and explicit type imports (#54170)

Turn on erasableSyntaxOnly and isolatedModules for the spec typecheck so
the specs only use TypeScript syntax that a plain type stripper can
remove (no enums, parameter properties, import = require or angle
bracket casts), and lint spec/ for type-only imports so they are
written as `import type` / inline `type` specifiers.

Also hoist a handful of inline require() calls in spec bodies up to the
existing top-level imports and use named imports from ws.

* test: load specs as native ES modules (#54171)

* test: mechanically rewrite spec imports and __dirname for native ESM

This is the automated half of loading spec/ as native ES modules and is
not expected to pass on its own; the hand-written half follows in the
next commit. A script (included in the pull request description) made
three changes to spec/*.ts and spec/lib/*.ts, then oxfmt was re-run:

* relative import specifiers gain their real file extension
* __dirname / __filename become import.meta.dirname / .filename
* files that still call require() from module code get a
  createRequire(import.meta.url) prelude

Code inside functions that are stringified and evaluated in another
process (remotely(), itremote(), template-interpolated functions and
similar) is left as it was.

* test: load specs as native ES modules with Node's type stripping

spec/package.json declares "type": "module", so the spec runner and every
spec file now load through Node's own ESM loader and built-in TypeScript
type stripping; the require.extensions transpile hook is gone. A new
spec/fixtures/package.json keeps fixtures in a CommonJS scope.

The hand-written changes on top of the mechanical rewrite:

* spec/index.js becomes an ES module and import()s its test dependencies
  after the app is ready, then uses mocha.loadFilesAsync().
* Functions that are stringified and sent to another process now arrive
  as written rather than as CommonJS transpiler output, so the
  remote-control and utility-process fixtures define the plain names
  those bodies use (chai, expect, once, setTimeout, ...) instead of the
  old `electron_1` style shims, and a few bodies require() what they use.
* The two specs that exercise lib/ sources require() them, since lib/ is
  not in a "type": "module" scope.
* spec-helpers resolves BrowserWindow lazily so it stays loadable from a
  utility process.
* tsconfig.spec.json allows .ts extension imports; spec/tsconfig.json
  points editors at it.
@trop trop Bot added the merged/45-x-y PR was merged to the "45-x-y" branch. label Sep 22, 2026
MarshallOfSound added a commit that referenced this pull request Sep 22, 2026
* test: assert that import and require expose the same electron module (#54166)

The `electron` module that ES module code sees is produced by wrapping the
CommonJS one, so both should expose exactly the same names bound to
exactly the same objects. Nothing checked that until now; esm-spec only
verified that the imports do not throw.

A new fixture app loads `electron` (and the process-specific
`electron/main`, `/common`, `/renderer`, `/utility` aliases) through both
`import()` and `require()` in the main process, a utility process and a
non-sandboxed renderer preload, and reports any export that only one
side has, any export that is not the identical value on both sides, and
a default import that is not the `require()` exports object. esm-spec
asserts the report is empty for each process type.

* build: update yarn to 4.18.0 (#54167)

* build: update yarn from 4.12.0 to 4.18.0

Bumps the vendored yarn release (and the DEPS hook, .yarnrc.yml,
script/yarn.js and packageManager references to it) to 4.18.0.
.yarn/releases/yarn-4.18.0.cjs is bin/yarn.js from @yarnpkg/cli-dist@4.18.0
unmodified. The lockfile only changes its metadata version.

Among other fixes, 4.18's built-in TypeScript compatibility patch knows
about the TypeScript 7 package layout; 4.12 fails the fetch step with
ENOENT on lib/_tsc.js when asked to install typescript@7.

* build: bump the nan test lockfile to yarn 4.18's metadata version

The nan spec runner installs nan's test dependencies with the vendored
yarn in immutable mode, and yarn 4.18 refuses the install because it
wants to rewrite the lockfile's metadata version from 8 to 10. Regenerate
the lockfile in the nan patch with 4.18 so the install is a no-op again.

* build: remove release entry scripts now owned by Sudowoodo (#52100)

* build: remove release orchestration scripts superseded by Sudowoodo (#52735)

* build: remove release orchestration scripts superseded by Sudowoodo

Follow-up to #52100. Sudowoodo now reads the expected-asset manifest from
script/release/release-assets.json (#52730, electron/sudowoodo#459)
instead of parsing release.ts, which removes the last reason to keep the
dead orchestration code:

- release.ts: no entrypoints since #52100; only remaining consumer was
  Sudowoodo's manifest regex, now retired
- get-url-hash.ts: imported only by release.ts
- upload-node-checksums.py / upload-index-json.py: invoked only by
  release.ts's uploadNodeShasums/uploadIndexJson; Sudowoodo does both
  first-party (checksums-scratchpad merge, direct index.json blob upload)
- get-asset.ts: no importers anywhere

The CI upload subtree stays: upload.py, upload-symbols.py,
upload-node-headers.py, upload-to-github.ts, find-github-release.ts,
github-token.ts, types.ts are invoked by build CI during publish builds
and are unrelated to the Sudowoodo migration.

* build: drop upload.py's unused --publish-release flag

Parsed but never read, and no workflow passes it — a fossil of the
pre-Sudowoodo flow where CI could trigger the publish itself.

* build: run the TypeScript scripts under script/ with plain node (#54168)

ts-node was used as a CLI for a handful of TypeScript scripts under
script/. Those now rely on the type stripping built into Node.js instead:

- script/gen-filenames, check-patch-diff, run-clang-tidy and the release
  helpers (find-github-release, upload-to-github, github-token, types)
  are renamed to .mts and invoked with `node`. They use
  import.meta.dirname and explicit .mts / .js specifiers, and
  tsconfig.script.json moves to nodenext with verbatimModuleSyntax so
  tsc checks them the way Node runs them.
- The documented minimum Node.js for building goes from 22.12 to 22.18,
  the first 22.x release with type stripping on by default.
- script/codesign/gen-trust.ts and its trust.xml template are deleted;
  nothing has invoked them since generate-identity.sh switched to a
  user-scoped keychain in #50058.
- oxfmt, oxlint, lint-staged and .gitattributes learn about .mts.

ts-node itself stays for now because the spec runner still loads specs
through its require hook.

* build: remove the ts-node dependency (#54169)

The last user of ts-node was the spec runner, which registered it as a
require hook so spec/**/*.ts could be loaded inside Electron (the
utility-process net fixture did the same).

Both now register a small local hook, spec/ts-register.js, that
transpiles each .ts file on its own with the TypeScript compiler API
using tsconfig.spec.json and inlines a source map so stack traces still
point at the .ts lines. ts-node also type checked specs as it loaded
them; that check now runs once in the lint job via
`tsc -p tsconfig.spec.json` instead, so type errors in specs fail lint
rather than surfacing part-way through a test shard.

Drops ts-node 6.2.0 and its transitive arrify, buffer-from, diff@3,
make-error, mkdirp@0.5, source-map-support and yn entries from
yarn.lock.

* build(deps-dev): bump ws and @types/ws (#50793)

Updates `ws` from 7.5.10 to 8.21.0 and `@types/ws` from 7.4.7 to 8.18.1
in spec/, and moves the specs to ws 8's API.

* test: keep spec sources to erasable syntax and explicit type imports (#54170)

Turn on erasableSyntaxOnly and isolatedModules for the spec typecheck so
the specs only use TypeScript syntax that a plain type stripper can
remove (no enums, parameter properties, import = require or angle
bracket casts), and lint spec/ for type-only imports so they are
written as `import type` / inline `type` specifiers.

Also hoist a handful of inline require() calls in spec bodies up to the
existing top-level imports and use named imports from ws.

* test: load specs as native ES modules (#54171)

* test: mechanically rewrite spec imports and __dirname for native ESM

This is the automated half of loading spec/ as native ES modules and is
not expected to pass on its own; the hand-written half follows in the
next commit. A script (included in the pull request description) made
three changes to spec/*.ts and spec/lib/*.ts, then oxfmt was re-run:

* relative import specifiers gain their real file extension
* __dirname / __filename become import.meta.dirname / .filename
* files that still call require() from module code get a
  createRequire(import.meta.url) prelude

Code inside functions that are stringified and evaluated in another
process (remotely(), itremote(), template-interpolated functions and
similar) is left as it was.

* test: load specs as native ES modules with Node's type stripping

spec/package.json declares "type": "module", so the spec runner and every
spec file now load through Node's own ESM loader and built-in TypeScript
type stripping; the require.extensions transpile hook is gone. A new
spec/fixtures/package.json keeps fixtures in a CommonJS scope.

The hand-written changes on top of the mechanical rewrite:

* spec/index.js becomes an ES module and import()s its test dependencies
  after the app is ready, then uses mocha.loadFilesAsync().
* Functions that are stringified and sent to another process now arrive
  as written rather than as CommonJS transpiler output, so the
  remote-control and utility-process fixtures define the plain names
  those bodies use (chai, expect, once, setTimeout, ...) instead of the
  old `electron_1` style shims, and a few bodies require() what they use.
* The two specs that exercise lib/ sources require() them, since lib/ is
  not in a "type": "module" scope.
* spec-helpers resolves BrowserWindow lazily so it stays loadable from a
  utility process.
* tsconfig.spec.json allows .ts extension imports; spec/tsconfig.json
  points editors at it.

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
@trop trop Bot added merged/44-x-y PR was merged to the "44-x-y" branch. merged/43-x-y PR was merged to the "43-x-y" branch. merged/42-x-y PR was merged to the "42-x-y" branch. and removed in-flight/44-x-y in-flight/43-x-y in-flight/42-x-y labels Sep 22, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

merged/42-x-y PR was merged to the "42-x-y" branch. merged/43-x-y PR was merged to the "43-x-y" branch. merged/44-x-y PR was merged to the "44-x-y" branch. merged/45-x-y PR was merged to the "45-x-y" branch. no-backport semver/none

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants