Files
electron/script/spec-runner.js
T
Samuel Attard 8287520b9b test: use vitest's API and expect directly, drop the mocha shim and chai (#54439)
* test: assert with vitest's matchers instead of chai

Every assertion moves from chai's chainable language to vitest's own
matchers, so an assertion is always a call and never a bare property read:

- `to.equal` → `toBe`, `to.deep.equal`/`eql` → `toEqual`, `to.be.true` →
  `toBe(true)`, `to.have.property` → `toHaveProperty`, `to.include` →
  `toContain`/`toMatchObject`/`toContainEqual`, `to.be.a('string')` →
  `toBeTypeOf('string')`, `instanceOf` → `toBeInstanceOf`, `lengthOf` →
  `toHaveLength`, `above`/`least`/`below`/`most` → `toBeGreaterThan` and
  friends, `match` → `toMatch`, `throw` → `toThrow`, `oneOf` → `toContain`
  with the operands swapped, `closeTo(e, d)` → `Math.abs(x - e) <= d`,
  `members`/`keys` → `arrayContaining`/`Object.keys`. A chain that kept
  going after `.property()` or `.a()` becomes one matcher where vitest has
  one (`toHaveProperty(key, expect.any(String))`) and separate assertions
  otherwise. chai-as-promised becomes `.resolves`/`.rejects`, a failed
  manual check is `assert.fail()` rather than `new chai.AssertionError`.
- vitest reports promise assertions that are not awaited; the app badge,
  webview zoom and `import('electron')` specs had some that never ran and
  now do (the service worker badge ones waited for a count the worker had
  already cleared, so those just check the worker's answer; the zoom fixture
  issued loadURL() from dom-ready, which aborts, and now waits for the load).
- Code that runs outside the spec module graph (itremote bodies in a
  renderer, the utility-process net harness, two crash-case apps) is
  CommonJS and vitest refuses `require('vitest')`, so it loads the same
  module through spec/lib/vitest-cjs.cjs.
- A lint rule (script/lint-plugins/no-chai-assertions.mjs) rejects chai
  chains on `expect()`, and no-unused-expressions is on for the specs, so
  neither form of property-style assertion can come back.
- chai, chai-as-promised, dirty-chai and their @types leave
  spec/package.json; the refresh-page fixture served chai.js only as "a
  large script" and serves the jquery fixture instead.

* test: return promises instead of taking mocha's `done` callback

vitest has no `done` parameter. The tests and hooks that used one now
return a promise instead; where the callback was only ever called as
`done()` it is the promise's `resolve`, and where it was also handed to an
error-first API or called as `done(error)` it keeps mocha's meaning
(reject on a truthy argument, resolve otherwise) through a local `done`.

* test: move the spec runner's own semantics out of the mocha shim

Three things the shim did are not mocha compatibility but how the Electron
specs need to run, whichever API they are written against: every hook and
test starts from a fresh macrotask so that Chromium's message loop turns
between them, `defer()`-ed cleanup runs before the enclosing suites' own
afterEach hooks, and spec/disabled-tests.json can switch a test off by its
title. Those now live in a small custom vitest runner
(spec/vitest/runner.ts) that applies to every test, and the shim only
translates mocha's API.

* test: write the specs against vitest's API instead of mocha's

Mechanical conversion of every spec file and helper:

- `describe`/`it`/`beforeEach`/... are imported from 'vitest' rather than
  taken from the runner's globals; `before`/`after` become
  `beforeAll`/`afterAll`.
- `this.timeout(n)` becomes the `{ timeout: n }` option of the test or
  suite (a hook's second argument; hooks inside a suite that had a timeout
  get it explicitly, since vitest's suite option only reaches the tests), `this.retries(n)` `{ retry: n }`, and
  `this.skip()` the test context's `ctx.skip()`. The two `before` hooks that
  skipped a whole suite now compute the condition up front
  (`describe.runIf`) or per test. `function () {}` callbacks that only
  existed for `this` are arrows again.
- `ifit`/`ifdescribe` map to `it.skip`/`describe.skip`, mocha's chained
  `it(...).timeout(n)` and `this.bail()` are gone, and the two helpers that
  read `this.test` use the context's task state.

* test: remove the mocha compatibility layer

Nothing imports mocha's API any more, so spec/vitest/mocha-compat.ts and the
setup file that installed its globals go, together with @types/mocha and the
Mocha type overloads in spec/ambient.d.ts. Timeouts are vitest's own again
(30 s per test and hook, ELECTRON_SPEC_TIMEOUT raises it for the sanitizer
jobs, replacing MOCHA_TIMEOUT), the junit path variable becomes
ELECTRON_SPEC_JUNIT_FILE, and `closeAllWindows` loses the parameter that a
hook's context argument would otherwise land in.
2026-10-14 10:58:22 -04:00

868 lines
31 KiB
JavaScript
Executable File

#!/usr/bin/env node
const { ElectronVersions, Installer } = require('@electron/fiddle-core');
const { DOMParser } = require('@xmldom/xmldom');
const { hashElement } = require('folder-hash');
const minimist = require('minimist');
const childProcess = require('node:child_process');
const crypto = require('node:crypto');
const fs = require('node:fs');
const net = require('node:net');
const os = require('node:os');
const path = require('node:path');
const { styleText } = require('node:util');
const unknownFlags = [];
const pass = styleText('green', '✓');
const fail = styleText('red', '✗');
const warn = styleText('yellow', '⚠');
const args = minimist(process.argv, {
boolean: ['skipYarnInstall'],
string: ['runners', 'target', 'electronVersion'],
number: ['enableRerun'],
unknown: (arg) => unknownFlags.push(arg)
});
const unknownArgs = [];
for (const flag of unknownFlags) {
unknownArgs.push(flag);
const onlyFlag = flag.replace(/^-+/, '');
if (args[onlyFlag]) {
unknownArgs.push(args[onlyFlag]);
}
}
const utils = require('./lib/utils');
const { YARN_SCRIPT_PATH } = require('./yarn');
const BASE = path.resolve(__dirname, '../..');
const runners = new Map([['main', { description: 'Main process specs', run: runMainProcessElectronTests }]]);
const specHashPath = path.resolve(__dirname, '../spec/.hash');
if (args.electronVersion) {
if (args.runners && args.runners !== 'main') {
console.log(`${fail} only 'main' runner can be used with --electronVersion`);
process.exit(1);
}
args.runners = 'main';
}
let runnersToRun = null;
if (args.runners !== undefined) {
runnersToRun = args.runners.split(',').filter((value) => value);
if (!runnersToRun.every((r) => [...runners.keys()].includes(r))) {
console.log(`${fail} ${runnersToRun} must be a subset of [${[...runners.keys()].join(' | ')}]`);
process.exit(1);
}
console.log('Only running:', runnersToRun);
} else {
console.log(`Triggering runners: ${[...runners.keys()].join(', ')}`);
}
async function main() {
if (args.electronVersion) {
const versions = await ElectronVersions.create();
if (args.electronVersion === 'latest') {
args.electronVersion = versions.latest.version;
} else if (args.electronVersion.startsWith('latest@')) {
const majorVersion = parseInt(args.electronVersion.slice('latest@'.length));
const ver = versions.inMajor(majorVersion).slice(-1)[0];
if (ver) {
args.electronVersion = ver.version;
} else {
console.log(`${fail} '${majorVersion}' is not a recognized Electron major version`);
process.exit(1);
}
} else if (!versions.isVersion(args.electronVersion)) {
console.log(`${fail} '${args.electronVersion}' is not a recognized Electron version`);
process.exit(1);
}
const versionString = `v${args.electronVersion}`;
console.log(`Running against Electron ${styleText('green', versionString)}`);
}
const [lastSpecHash, lastSpecInstallHash] = loadLastSpecHash();
const [currentSpecHash, currentSpecInstallHash] = await getSpecHash();
const somethingChanged = currentSpecHash !== lastSpecHash || lastSpecInstallHash !== currentSpecInstallHash;
if (somethingChanged && !args.skipYarnInstall) {
await installSpecModules(path.resolve(__dirname, '..', 'spec'));
await getSpecHash().then(saveSpecHash);
} else if (somethingChanged) {
// Without the spec install the native addon fixtures stay as the root
// install built them, against the system Node.js headers, so the ones
// that need Electron's headers (see installSpecModules) cannot be loaded.
// CI passes --skipYarnInstall on linux-arm; gate their specs there rather
// than fail with an ABI error.
console.log(
`${warn} --skipYarnInstall leaves the native addon fixtures needing Electron's headers unbuilt; their specs will be skipped`
);
process.env.ELECTRON_SKIP_ELECTRON_HEADER_ADDON_SPECS = '1';
}
if (!fs.existsSync(path.resolve(__dirname, '../electron.d.ts'))) {
console.log('Generating electron.d.ts as it is missing');
generateTypeDefinitions();
}
// Provision a silent virtual printer before launching Electron so the
// webContents.print() specs can run against a real device. Must happen here
// (before the test process starts) because macOS validates deviceName against
// a per-process PrintCore snapshot captured at startup.
const teardownPrinter = await setupVirtualPrinter();
process.on('exit', teardownPrinter);
await runElectronTests();
}
function generateTypeDefinitions() {
const { status } = childProcess.spawnSync('npm', ['run', 'create-typescript-definitions'], {
cwd: path.resolve(__dirname, '..'),
stdio: 'inherit',
shell: true
});
if (status !== 0) {
throw new Error(`Electron typescript definition generation failed with exit code: ${status}.`);
}
}
function loadLastSpecHash() {
return fs.existsSync(specHashPath) ? fs.readFileSync(specHashPath, 'utf8').split('\n') : [null, null];
}
function saveSpecHash([newSpecHash, newSpecInstallHash]) {
fs.writeFileSync(specHashPath, `${newSpecHash}\n${newSpecInstallHash}`);
}
// Runs a command and resolves with its exit code and captured output. Never
// rejects — spawn errors resolve with code -1.
function spawnCapture(cmd, cmdArgs) {
return new Promise((resolve) => {
const child = childProcess.spawn(cmd, cmdArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', (d) => {
stdout += d;
});
child.stderr.on('data', (d) => {
stderr += d;
});
child.on('error', () => resolve({ code: -1, stdout, stderr }));
child.on('close', (code) => resolve({ code, stdout, stderr }));
});
}
function getFreePort() {
return new Promise((resolve, reject) => {
const srv = net.createServer();
srv.on('error', reject);
srv.listen(0, '127.0.0.1', () => {
const { port } = srv.address();
srv.close(() => resolve(port));
});
});
}
async function waitForPort(port, timeoutMs = 10000) {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const listening = await new Promise((resolve) => {
const sock = net.connect({ host: '127.0.0.1', port }, () => {
sock.destroy();
resolve(true);
});
sock.on('error', () => resolve(false));
sock.setTimeout(500, () => {
sock.destroy();
resolve(false);
});
});
if (listening) return true;
await new Promise((resolve) => setTimeout(resolve, 200));
}
return false;
}
function locateBinary(bin) {
const viaPath = (
childProcess.spawnSync('/bin/sh', ['-c', `command -v ${bin} 2>/dev/null`], { encoding: 'utf8' }).stdout || ''
).trim();
if (viaPath) return viaPath;
for (const dir of ['/usr/sbin', '/usr/bin', '/usr/local/sbin', '/usr/local/bin']) {
const candidate = path.join(dir, bin);
if (fs.existsSync(candidate)) return candidate;
}
return '';
}
// Provisions a silent virtual printer used by the webContents.print() specs
// "Microsoft Print To PDF" on a file port on Windows, an ippeveprinter-backed
// driverless CUPS queue on Linux/macOS. Exposes it to the test process via
// ELECTRON_TEST_PRINTER_NAME and returns a synchronous teardown function (safe
// to register on 'exit'). Best-effort: on any failure the spec self-skips, so
// this never blocks the test run.
async function setupVirtualPrinter() {
const noop = () => {};
// Respect a printer provisioned by the environment.
if (process.env.ELECTRON_TEST_PRINTER_NAME) {
return noop;
}
try {
if (process.platform === 'win32') {
const printerName = 'ElectronTestPDF';
const portFile = path.join(os.tmpdir(), `electron-print-${Date.now()}.pdf`);
const ps = (script) => spawnCapture('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
if ((await ps(`Add-PrinterPort -Name '${portFile}' -ErrorAction Stop`)).code !== 0) return noop;
const addPrinter = await ps(
`Add-Printer -Name '${printerName}' -DriverName 'Microsoft Print To PDF' -PortName '${portFile}' -ErrorAction Stop`
);
if (addPrinter.code !== 0) {
await ps(`Remove-PrinterPort -Name '${portFile}' -ErrorAction SilentlyContinue`);
return noop;
}
process.env.ELECTRON_TEST_PRINTER_NAME = printerName;
console.log(`${pass} Provisioned virtual printer: ${printerName}`);
return () => {
childProcess.spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Remove-Printer -Name '${printerName}' -ErrorAction SilentlyContinue`
],
{ stdio: 'ignore' }
);
childProcess.spawnSync(
'powershell.exe',
[
'-NoProfile',
'-NonInteractive',
'-Command',
`Remove-PrinterPort -Name '${portFile}' -ErrorAction SilentlyContinue`
],
{ stdio: 'ignore' }
);
fs.rmSync(portFile, { force: true });
};
}
// Linux / macOS: an IPP Everywhere virtual printer registered with CUPS.
const ippeve = locateBinary('ippeveprinter');
const lpadmin = locateBinary('lpadmin');
if (!ippeve || !lpadmin) {
console.log('Skipping virtual printer setup: ippeveprinter/lpadmin unavailable.');
return noop;
}
const printerName = 'electron-ipp-test';
// Remove any queue left behind by a previous interrupted run.
await spawnCapture(lpadmin, ['-x', printerName]);
const port = await getFreePort();
const spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'electron-ippeve-'));
const ippServer = childProcess.spawn(
ippeve,
['-f', 'application/pdf', '-p', String(port), '-d', spoolDir, '-k', 'Electron Test Printer'],
{ stdio: 'ignore' }
);
let serverExited = false;
ippServer.on('exit', () => {
serverExited = true;
});
// Don't let the long-running ippeveprinter keep the runner's event loop
// alive; otherwise the process hangs on exit instead of running teardown.
ippServer.unref();
const teardown = () => {
childProcess.spawnSync(lpadmin, ['-x', printerName], { stdio: 'ignore' });
if (!serverExited) ippServer.kill();
fs.rmSync(spoolDir, { recursive: true, force: true });
};
if (!(await waitForPort(port))) {
teardown();
console.log('Skipping virtual printer setup: ippeveprinter did not start.');
return noop;
}
const add = await spawnCapture(lpadmin, [
'-p',
printerName,
'-E',
'-v',
`ipp://localhost:${port}/ipp/print`,
'-m',
'everywhere'
]);
if (add.code !== 0) {
teardown();
console.log(`Skipping virtual printer setup: lpadmin failed (${add.stderr.trim()}).`);
return noop;
}
await spawnCapture(lpadmin, ['-d', printerName]);
process.env.ELECTRON_TEST_PRINTER_NAME = printerName;
console.log(`${pass} Provisioned virtual printer: ${printerName}`);
return teardown;
} catch (err) {
console.log(`Skipping virtual printer setup: ${err}`);
return noop;
}
}
async function runElectronTests() {
const errors = [];
const testResultsDir = process.env.ELECTRON_TEST_RESULTS_DIR;
for (const [runnerId, { description, run }] of runners) {
if (runnersToRun && !runnersToRun.includes(runnerId)) {
console.info('\nSkipping:', description);
continue;
}
try {
console.info('\nRunning:', description);
if (testResultsDir) {
process.env.ELECTRON_SPEC_JUNIT_FILE = path.join(testResultsDir, `test-results-${runnerId}.xml`);
}
await run();
} catch (err) {
errors.push([runnerId, err]);
}
}
if (errors.length !== 0) {
for (const err of errors) {
console.error('\n\nRunner Failed:', err[0]);
console.error(err[1]);
}
console.log(`${fail} Electron test runners have failed`);
process.exit(1);
}
}
async function asyncSpawn(exe, runnerArgs, env = process.env) {
return new Promise((resolve, reject) => {
const child = childProcess.spawn(exe, runnerArgs, {
cwd: path.resolve(__dirname, '../..'),
env
});
if (process.env.ELECTRON_TEST_PID_DUMP_PATH && child.pid) {
fs.writeFileSync(process.env.ELECTRON_TEST_PID_DUMP_PATH, child.pid.toString());
}
child.stdout.pipe(process.stdout);
child.stderr.pipe(process.stderr);
child.on('error', (error) => reject(error));
child.on('close', (status, signal) => {
resolve({ status, signal });
});
});
}
function parseJUnitXML(specDir) {
if (!fs.existsSync(process.env.ELECTRON_SPEC_JUNIT_FILE)) {
console.error('JUnit XML file not found:', process.env.ELECTRON_SPEC_JUNIT_FILE);
return [];
}
const xmlContent = fs.readFileSync(process.env.ELECTRON_SPEC_JUNIT_FILE, 'utf8');
const parser = new DOMParser();
const xmlDoc = parser.parseFromString(xmlContent, 'text/xml');
const failedTests = [];
// find failed tests by looking for all testsuite nodes with failure > 0
const testSuites = xmlDoc.getElementsByTagName('testsuite');
for (let i = 0; i < testSuites.length; i++) {
const testSuite = testSuites[i];
const failures = testSuite.getAttribute('failures');
if (failures > 0) {
const testcases = testSuite.getElementsByTagName('testcase');
for (let i = 0; i < testcases.length; i++) {
const testcase = testcases[i];
const failures = testcase.getElementsByTagName('failure');
const errors = testcase.getElementsByTagName('error');
if (failures.length > 0 || errors.length > 0) {
const testName = testcase.getAttribute('name');
// vitest names each <testsuite> after the spec file, relative to spec/.
const suiteName = testSuite.getAttribute('name');
const filePath =
testSuite.getAttribute('file') || (suiteName ? path.resolve(__dirname, '..', specDir, suiteName) : null);
const fileName = filePath ? path.relative(specDir, filePath) : 'unknown file';
const failureInfo = {
name: testName,
file: fileName,
filePath
};
if (failures.length > 0) {
failureInfo.failure = failures[0].textContent || failures[0].nodeValue || 'No failure message';
}
if (errors.length > 0) {
failureInfo.error = errors[0].textContent || errors[0].nodeValue || 'No error message';
}
failedTests.push(failureInfo);
}
}
}
}
return failedTests;
}
async function rerunFailedTest(specDir, testName, testInfo) {
console.log('\n========================================');
console.log(`Rerunning failed test: ${testInfo.name} (${testInfo.file})`);
console.log('========================================');
let grepPattern = testInfo.name;
// Escape special regex characters in test name
grepPattern = grepPattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const args = [];
if (testInfo.filePath) {
args.push('--files', testInfo.filePath);
}
args.push('-g', grepPattern);
const success = await runTestUsingElectron(specDir, testName, false, args);
if (success) {
console.log(`✅ Test passed: ${testInfo.name}`);
return true;
} else {
console.log(`❌ Test failed again: ${testInfo.name}`);
return false;
}
}
async function rerunFailedTests(specDir, testName) {
console.log('\n📋 Parsing JUnit XML for failed tests...');
const failedTests = parseJUnitXML(specDir);
if (failedTests.length === 0) {
console.log('No failed tests could be found.');
process.exit(1);
return;
}
// Save off the original junit xml file
if (fs.existsSync(process.env.ELECTRON_SPEC_JUNIT_FILE)) {
fs.copyFileSync(process.env.ELECTRON_SPEC_JUNIT_FILE, `${process.env.ELECTRON_SPEC_JUNIT_FILE}.save`);
}
console.log(`\n📊 Found ${failedTests.length} failed test(s):`);
failedTests.forEach((test, index) => {
console.log(` ${index + 1}. ${test.name} (${test.file})`);
});
// Step 3: Rerun each failed test individually
console.log('\n🔄 Rerunning failed tests individually...\n');
const results = {
total: failedTests.length,
passed: 0,
failed: 0
};
let index = 0;
for (const testInfo of failedTests) {
let runCount = 0;
let success = false;
let retryTest = false;
while (!success && runCount < args.enableRerun) {
success = await rerunFailedTest(specDir, testName, testInfo);
if (success) {
results.passed++;
} else {
if (runCount === args.enableRerun - 1) {
results.failed++;
} else {
retryTest = true;
console.log(`Retrying test (${runCount + 1}/${args.enableRerun})...`);
}
}
// Add a small delay between tests
if (retryTest || index < failedTests.length - 1) {
console.log('\nWaiting 2 seconds before next test...');
await new Promise((resolve) => setTimeout(resolve, 2000));
}
runCount++;
}
index++;
}
// Step 4: Summary
console.log('\n📈 Summary:');
console.log(`Total failed tests: ${results.total}`);
console.log(`Passed on rerun: ${results.passed}`);
console.log(`Still failing: ${results.failed}`);
// Restore the original junit xml file
if (fs.existsSync(`${process.env.ELECTRON_SPEC_JUNIT_FILE}.save`)) {
fs.renameSync(`${process.env.ELECTRON_SPEC_JUNIT_FILE}.save`, process.env.ELECTRON_SPEC_JUNIT_FILE);
}
if (results.failed === 0) {
console.log('🎉 All previously failed tests now pass!');
} else {
console.log(`⚠️ ${results.failed} test(s) are still failing`);
process.exit(1);
}
}
let electronLaunchCount = 0;
// Launches the vitest CLI, which starts the Electron processes itself
// (spec/vitest/electron-pool.ts). The runner's own arguments are translated:
// `--files` becomes vitest's file filters, `-g`/`--grep` (and `-i`/`--invert`)
// its test name pattern, and anything else is treated as a command line
// switch for the Electron workers.
function toVitestInvocation(exe, specDir, runnerArgs) {
const specRoot = path.resolve(__dirname, '..', specDir);
const files = [];
const electronArgs = [];
let grep = null;
let invert = false;
for (let i = 0; i < runnerArgs.length; i++) {
const arg = String(runnerArgs[i]);
const takeValue = () => (arg.includes('=') ? arg.slice(arg.indexOf('=') + 1) : String(runnerArgs[++i]));
if (arg === '--files') {
while (i + 1 < runnerArgs.length && !String(runnerArgs[i + 1]).startsWith('-')) {
files.push(String(runnerArgs[++i]));
}
} else if (arg === '-g' || arg === '--grep' || arg.startsWith('--grep=') || arg.startsWith('-g=')) {
grep = takeValue();
} else if (arg === '-i' || arg === '--invert') {
invert = true;
} else {
electronArgs.push(arg);
}
}
const vitestArgs = [
path.join(specRoot, 'node_modules', 'vitest', 'vitest.mjs'),
'run',
'--config',
path.join(specRoot, 'vitest.config.ts')
];
if (grep) {
vitestArgs.push('--testNamePattern', invert ? `^(?!.*(?:${grep}))` : grep);
}
const missing = [];
for (const file of files) {
// vitest filters are matched against paths relative to its root (spec/).
const absolute = path.isAbsolute(file) ? file : path.resolve(__dirname, '..', file);
if (!fs.existsSync(absolute)) missing.push(file);
vitestArgs.push(path.relative(specRoot, absolute));
}
if (missing.length > 0) {
console.error(
`${fail} Test files were provided, but these do not exist (relative to electron/): ${missing.join(', ')}`
);
process.exit(1);
}
const env = {
...process.env,
ELECTRON_SPEC_ELECTRON_PATH: exe,
ELECTRON_SPEC_WORKER_ARGS: electronArgs.join(' ')
};
let command = process.execPath;
let commandArgs = vitestArgs;
if (process.platform === 'linux') {
// The mock D-Bus services are started once around the whole run; every
// Electron worker inherits the bus addresses from the CLI's environment.
commandArgs = [path.resolve(__dirname, 'dbus_mock.py'), command, ...commandArgs];
command = 'python3';
}
return { command, commandArgs, env };
}
async function runTestUsingElectron(specDir, testName, shouldRerun, additionalArgs = []) {
let exe;
if (args.electronVersion) {
const installer = new Installer();
exe = await installer.install(args.electronVersion);
} else {
exe = path.resolve(BASE, utils.getElectronExec());
}
let argsToPass = unknownArgs.slice(2);
// Each launch truncates the --log-file it is given, so give reruns their
// own file rather than let them wipe the log of the run that failed.
const launchIndex = electronLaunchCount++;
if (launchIndex > 0) {
argsToPass = argsToPass.map((arg) =>
String(arg).startsWith('--log-file=')
? String(arg).replace(/(\.[^./\\]+)?$/, (ext) => `.rerun-${launchIndex}${ext}`)
: arg
);
}
if (additionalArgs.includes('--files')) {
argsToPass = argsToPass.filter(
(arg) =>
arg.toString().startsWith('--log-file=') ||
(arg.toString().indexOf('--files') === -1 && arg.toString().indexOf('spec/') === -1)
);
}
const { command, commandArgs, env } = toVitestInvocation(exe, specDir, [...argsToPass, ...additionalArgs]);
console.log(`Running: ${command} ${commandArgs.join(' ')}`);
const { status, signal } = await asyncSpawn(command, commandArgs, env);
if (status !== 0) {
if (status) {
const textStatus = process.platform === 'win32' ? `0x${status.toString(16)}` : status.toString();
console.log(`${fail} Electron tests failed with code ${textStatus}.`);
} else {
console.log(`${fail} Electron tests failed with kill signal ${signal}.`);
}
if (shouldRerun) {
await rerunFailedTests(specDir, testName);
} else {
process.exit(1);
}
}
console.log(`${pass} Electron ${testName} process tests passed.`);
return true;
}
async function runMainProcessElectronTests() {
let shouldRerun = false;
if (args.enableRerun && args.enableRerun > 0) {
shouldRerun = true;
}
await runTestUsingElectron('spec', 'main', shouldRerun);
}
async function installSpecModules(dir) {
const env = {
npm_config_msvs_version: '2022',
...process.env,
CXXFLAGS: process.env.CXXFLAGS,
npm_config_yes: 'true'
};
if (args.electronVersion) {
env.npm_config_target = args.electronVersion;
env.npm_config_disturl = 'https://electronjs.org/headers';
env.npm_config_runtime = 'electron';
env.npm_config_devdir = path.join(os.homedir(), '.electron-gyp');
env.npm_config_build_from_source = 'true';
const { status } = childProcess.spawnSync('npm', ['run', 'node-gyp-install', '--ensure'], {
env,
cwd: dir,
stdio: 'inherit',
shell: true
});
if (status !== 0) {
console.log(`${fail} Failed to "npm run node-gyp-install" install in '${dir}'`);
process.exit(1);
}
} else {
env.npm_config_nodedir = path.resolve(BASE, `out/${utils.getOutDir({ shouldLog: true })}/gen/node_headers`);
}
if (fs.existsSync(path.resolve(dir, 'node_modules'))) {
await fs.promises.rm(path.resolve(dir, 'node_modules'), { force: true, recursive: true });
}
const yarnArgs = [YARN_SCRIPT_PATH, 'install', '--immutable'];
const { status } = childProcess.spawnSync(process.execPath, yarnArgs, {
env,
cwd: dir,
stdio: 'inherit'
});
if (status !== 0 && !process.env.IGNORE_YARN_INSTALL_ERROR) {
console.log(`${fail} Failed to yarn install in '${dir}'`);
process.exit(1);
}
// The native addon fixtures are workspaces, so yarn only runs their build
// scripts once per install state and keeps whatever the root `yarn install`
// built them against (the system Node.js headers in CI). N-API addons are
// ABI-stable, so that is fine for them, but addons using Node's C++ module
// API embed NODE_MODULE_VERSION and fail to load in Electron unless they are
// compiled against the headers configured above. Such fixtures opt in with
// "electron:requiresElectronHeaders": true in their package.json and get
// rebuilt here.
const nodeGyp = path.resolve(__dirname, '..', 'node_modules', 'node-gyp', 'bin', 'node-gyp.js');
const nativeAddonsDir = path.resolve(dir, 'fixtures', 'native-addon');
const toolchainEnv = getNativeAddonToolchainEnv();
const rebuildEnv = { ...env, ...toolchainEnv };
// CI pins NPM_CONFIG_MSVS_VERSION=2022 for the spec install, but the Windows
// test runners only ship a newer Visual Studio; let node-gyp detect the
// installed one, as the root install that built the fixtures did.
for (const key of Object.keys(rebuildEnv)) {
if (key.toLowerCase() === 'npm_config_msvs_version') {
delete rebuildEnv[key];
}
}
for (const addon of fs.readdirSync(nativeAddonsDir)) {
const addonDir = path.join(nativeAddonsDir, addon);
const packageJsonPath = path.join(addonDir, 'package.json');
if (!fs.existsSync(packageJsonPath)) {
continue;
}
const addonPackage = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
if (!addonPackage['electron:requiresElectronHeaders']) {
continue;
}
if (toolchainEnv === null) {
// Not silent: the specs covering these fixtures are gated on this
// variable and report as skipped, see spec/node.spec.ts.
console.log(
`${warn} No compiler on this host can build Electron's headers, not rebuilding native addon '${addon}'; its specs will be skipped`
);
process.env.ELECTRON_SKIP_ELECTRON_HEADER_ADDON_SPECS = '1';
continue;
}
console.log(`Rebuilding native addon '${addon}' against Electron's node headers`);
const { status: rebuildStatus } = childProcess.spawnSync(process.execPath, [nodeGyp, 'rebuild'], {
env: rebuildEnv,
cwd: addonDir,
stdio: 'inherit'
});
if (rebuildStatus !== 0) {
console.log(`${fail} Failed to rebuild native addon '${addon}' in '${addonDir}'`);
process.exit(1);
}
}
}
// Electron's V8 headers do not compile with GCC <= 12 (attribute ordering on
// deprecated classes, see https://github.com/electron/electron/issues/53284),
// which is what the Linux CI test image ships. When the Chromium toolchain
// from the source checkout is available (CI restores it into the test job's
// src_artifacts) build the addons with clang and Electron's libc++, mirroring
// script/nan-spec-runner.js. Without it fall back to the system compiler.
//
// Returns null when the Chromium toolchain is present but cannot run on this
// host: the linux-arm64 test job runs on an arm64 host but restores the x64
// clang of the (cross-compiling) build job, and the system compiler there is
// the same GCC that rejects the headers, so nothing on that host can build
// the fixtures.
function getNativeAddonToolchainEnv() {
if (args.electronVersion || process.platform !== 'linux') {
return {};
}
const outDir = utils.getOutDir();
const clangDir = path.resolve(BASE, 'third_party', 'llvm-build', 'Release+Asserts', 'bin');
const libcxxConfigDir = path.resolve(BASE, 'buildtools', 'third_party', 'libc++');
const libcxxIncludeDir = path.resolve(BASE, 'third_party', 'libc++', 'src', 'include');
const libcxxabiIncludeDir = path.resolve(BASE, 'third_party', 'libc++abi', 'src', 'include');
const libcxxLibDir = path.resolve(BASE, 'out', outDir, 'obj', 'buildtools', 'third_party', 'libc++');
const libcxxabiLibDir = path.resolve(BASE, 'out', outDir, 'obj', 'buildtools', 'third_party', 'libc++abi');
const required = [
path.join(clangDir, 'clang++'),
libcxxConfigDir,
libcxxIncludeDir,
libcxxabiIncludeDir,
libcxxLibDir
];
if (!required.every((p) => fs.existsSync(p))) {
console.log('Chromium clang/libc++ not found, building native addons with the system compiler');
return {};
}
const { error: clangError, status: clangStatus } = childProcess.spawnSync(
path.join(clangDir, 'clang++'),
['--version'],
{ stdio: 'ignore' }
);
if (clangError || clangStatus !== 0) {
console.log(
`${warn} Chromium clang cannot run on this ${process.arch} host (${clangError ? clangError.code : `exit code ${clangStatus}`})`
);
return null;
}
const targetArch = process.env.npm_config_arch || process.env.NPM_CONFIG_ARCH || process.arch;
const targetFlags = [];
if (targetArch !== process.arch) {
const target = {
arm: ['arm-linux-gnueabihf', 'bullseye_armhf'],
arm64: ['aarch64-linux-gnu', 'bullseye_arm64'],
ia32: ['i686-linux-gnu', 'bullseye_i386'],
x64: ['x86_64-linux-gnu', 'bullseye_amd64']
}[targetArch];
if (!target) {
throw new Error(`Unsupported native addon target architecture: ${targetArch}`);
}
const [triple, sysrootName] = target;
const sysroot = path.resolve(BASE, 'build', 'linux', require('./sysroots.json')[sysrootName].SysrootDir);
if (!fs.existsSync(sysroot)) {
throw new Error(`Missing sysroot for ${targetArch} native addons: ${sysroot}`);
}
targetFlags.push(`--target=${triple}`, `--sysroot="${sysroot}"`);
}
const ldflags = ['-stdlib=libc++', '-fuse-ld=lld', `-L"${libcxxLibDir}"`];
// Sanitizer builds compile libc++abi into the electron executable and export
// it from there (export_libcxxabi_from_executables in Chromium's
// build/config/c++/c++.gni) instead of producing a static library, so link
// against it only when it exists; otherwise the symbols resolve from the
// executable when the addon is loaded.
if (fs.existsSync(path.join(libcxxabiLibDir, 'libc++abi.a'))) {
ldflags.push(`-L"${libcxxabiLibDir}"`, '-lc++abi');
}
return {
CC: path.join(clangDir, 'clang'),
CXX: path.join(clangDir, 'clang++'),
LD: path.join(clangDir, 'lld'),
npm_config_arch: targetArch,
CFLAGS: [process.env.CFLAGS, '-Wno-trigraphs -fPIC', ...targetFlags].filter(Boolean).join(' '),
CXXFLAGS: [
process.env.CXXFLAGS,
'-Wno-trigraphs',
'-nostdinc++',
`-isystem "${libcxxConfigDir}"`,
`-isystem "${libcxxIncludeDir}"`,
`-isystem "${libcxxabiIncludeDir}"`,
'-fvisibility-inlines-hidden',
'-fPIC',
'-D_LIBCPP_ABI_NAMESPACE=Cr',
'-D_LIBCPP_HARDENING_MODE=_LIBCPP_HARDENING_MODE_EXTENSIVE',
...targetFlags
]
.filter(Boolean)
.join(' '),
LDFLAGS: [process.env.LDFLAGS, ...ldflags, ...targetFlags].filter(Boolean).join(' ')
};
}
function getSpecHash() {
return Promise.all([
(async () => {
const hasher = crypto.createHash('SHA256');
hasher.update(fs.readFileSync(path.resolve(__dirname, '../yarn.lock')));
hasher.update(fs.readFileSync(path.resolve(__dirname, '../spec/package.json')));
hasher.update(fs.readFileSync(path.resolve(__dirname, '../script/spec-runner.js')));
return hasher.digest('hex');
})(),
(async () => {
const specNodeModulesPath = path.resolve(__dirname, '../spec/node_modules');
if (!fs.existsSync(specNodeModulesPath)) {
return null;
}
const { hash } = await hashElement(specNodeModulesPath, {
folders: {
exclude: ['.bin']
}
});
return hash;
})()
]);
}
main().catch((error) => {
console.error('An error occurred inside the spec runner:', error);
process.exit(1);
});