From d8fe4fd0913e7da1242c228231657c30ff2da99c Mon Sep 17 00:00:00 2001 From: Samuel Attard Date: Sat, 11 Apr 2026 22:56:35 -0700 Subject: [PATCH] chore: prepare spec helpers and vitest setup for migration --- spec/_vitest_runner/setup.ts | 38 +++++++++++++++++++++ spec/_vitest_runner/vitest.config.ts | 9 ++++- spec/_vitest_runner/worker-entry.js | 51 +++++++++++++++++++++++++++- spec/lib/spec-helpers.ts | 28 ++++++++++++--- 4 files changed, 119 insertions(+), 7 deletions(-) create mode 100644 spec/_vitest_runner/setup.ts diff --git a/spec/_vitest_runner/setup.ts b/spec/_vitest_runner/setup.ts new file mode 100644 index 0000000000..20717f2afa --- /dev/null +++ b/spec/_vitest_runner/setup.ts @@ -0,0 +1,38 @@ +import * as chai from 'chai'; +import { afterEach, beforeEach } from 'vitest'; + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { runCleanupFunctions } from '../lib/spec-helpers'; + +import chaiAsPromised = require('chai-as-promised'); +import dirtyChai = require('dirty-chai'); + +chai.use(chaiAsPromised); +chai.use(dirtyChai as any); + +// Show full object diff. +// https://github.com/chaijs/chai/issues/469 +chai.config.truncateThreshold = 0; + +// Skip any tests listed in disabled-tests.json. +const disabledTests = new Set(JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'disabled-tests.json'), 'utf8'))); +beforeEach((ctx) => { + const parts: string[] = [ctx.task.name]; + let suite = ctx.task.suite; + while (suite) { + if (suite.name) parts.unshift(suite.name); + suite = suite.suite; + } + if (disabledTests.has(parts.join(' '))) { + ctx.skip(); + } +}); + +// Run defer()-ed cleanup functions after each test, before other afterEach hooks +// registered by the test file (vitest runs hooks in registration order, and +// setupFiles are loaded first). +afterEach(async () => { + await runCleanupFunctions(); +}); diff --git a/spec/_vitest_runner/vitest.config.ts b/spec/_vitest_runner/vitest.config.ts index 4d50cc1b1a..d7246cb151 100644 --- a/spec/_vitest_runner/vitest.config.ts +++ b/spec/_vitest_runner/vitest.config.ts @@ -4,14 +4,21 @@ import * as path from 'node:path'; import electronPool from './electron-pool'; +const electronShim = path.resolve(__dirname, 'electron-shim.cjs'); + export default defineConfig({ resolve: { alias: { - electron: path.resolve(__dirname, 'electron-shim.cjs') + electron: electronShim, + 'electron/main': electronShim, + 'electron/common': electronShim, + 'electron/renderer': electronShim } }, test: { include: ['spec/**/*.spec.ts'], + exclude: ['spec/fixtures/**', 'spec/node_modules/**'], + setupFiles: ['./spec/_vitest_runner/setup.ts'], // Custom pool: each worker is a real Electron main process. pool: electronPool as any, // Run test *files* in parallel across workers... diff --git a/spec/_vitest_runner/worker-entry.js b/spec/_vitest_runner/worker-entry.js index 4d1e1a7a21..c2c2211a59 100644 --- a/spec/_vitest_runner/worker-entry.js +++ b/spec/_vitest_runner/worker-entry.js @@ -2,15 +2,22 @@ // It is launched via `spawn(electron, [])` with an IPC channel, // so `process.send` is available and vitest's fork-worker protocol works. -const { app } = require('electron'); +const { app, protocol } = require('electron'); const path = require('node:path'); +const v8 = require('node:v8'); process.on('uncaughtException', (err) => { console.error('Unhandled exception in vitest worker:', err); process.exit(1); }); +process.env.ELECTRON_DISABLE_SECURITY_WARNINGS = 'true'; + +if (process.env.ELECTRON_TEST_DISABLE_HARDWARE_ACCELERATION) { + app.disableHardwareAcceleration(); +} + // The pool allocates (mkdtemp) and cleans up this directory; the worker just // points Electron at it before app ready. const userDataDir = process.env.ELECTRON_VITEST_USER_DATA_DIR; @@ -19,8 +26,50 @@ if (!userDataDir) { } app.setPath('userData', userDataDir); +v8.setFlagsFromString('--expose_gc'); +app.commandLine.appendSwitch('js-flags', '--expose_gc'); app.on('window-all-closed', () => null); +// Use fake device for Media Stream to replace actual camera and microphone. +app.commandLine.appendSwitch('use-fake-device-for-media-stream'); +app.commandLine.appendSwitch( + 'host-resolver-rules', + [ + 'MAP localhost2 127.0.0.1', + 'MAP ipv4.localhost2 10.0.0.1', + 'MAP ipv6.localhost2 [::1]', + 'MAP notfound.localhost2 ~NOTFOUND' + ].join(', ') +); + +// Enable features required by tests. +app.commandLine.appendSwitch( + 'enable-features', + [ + // spec/api-web-frame-main-spec.ts + 'DocumentPolicyIncludeJSCallStacksInCrashReports', + // spec/spellchecker-spec.ts + 'UnrestrictSpellingAndGrammarForTesting' + ].join(',') +); + +global.standardScheme = 'app'; +global.zoomScheme = 'zoom'; +global.serviceWorkerScheme = 'sw'; +protocol.registerSchemesAsPrivileged([ + { scheme: global.standardScheme, privileges: { standard: true, secure: true, stream: false } }, + { scheme: global.zoomScheme, privileges: { standard: true, secure: true } }, + { scheme: global.serviceWorkerScheme, privileges: { allowServiceWorkers: true, standard: true, secure: true } }, + { scheme: 'http-like', privileges: { standard: true, secure: true, corsEnabled: true, supportFetchAPI: true } }, + { scheme: 'cors-blob', privileges: { corsEnabled: true, supportFetchAPI: true } }, + { scheme: 'cors', privileges: { corsEnabled: true, supportFetchAPI: true } }, + { scheme: 'no-cors', privileges: { supportFetchAPI: true } }, + { scheme: 'no-fetch', privileges: { corsEnabled: true } }, + { scheme: 'stream', privileges: { standard: true, stream: true } }, + { scheme: 'foo', privileges: { standard: true } }, + { scheme: 'bar', privileges: { standard: true } } +]); + app .whenReady() .then(async () => { diff --git a/spec/lib/spec-helpers.ts b/spec/lib/spec-helpers.ts index 21d98aa940..f662e1ee5f 100644 --- a/spec/lib/spec-helpers.ts +++ b/spec/lib/spec-helpers.ts @@ -1,7 +1,7 @@ import { BrowserWindow } from 'electron/main'; import { AssertionError } from 'chai'; -import { SuiteFunction, TestFunction } from 'mocha'; +import { afterAll, beforeAll, describe, it } from 'vitest'; import * as childProcess from 'node:child_process'; import * as http from 'node:http'; @@ -22,8 +22,26 @@ const addOnly = (fn: Function): T => { return wrapped as any; }; -export const ifit = (condition: boolean) => (condition ? it : addOnly(it.skip)); -export const ifdescribe = (condition: boolean) => (condition ? describe : addOnly(describe.skip)); +export const ifit = (condition: boolean) => (condition ? it : addOnly(it.skip)); +export const ifdescribe = (condition: boolean) => (condition ? describe : addOnly(describe.skip)); + +type DoneCallback = (err?: unknown) => void; + +/** + * Adapts a mocha-style callback test (receiving a `done` function) into a + * vitest-compatible test that returns a Promise. `done()` resolves, + * `done(err)` rejects. + */ +export function withDone(fn: (done: DoneCallback) => void): () => Promise { + return () => + new Promise((resolve, reject) => { + const done: DoneCallback = (err) => { + if (err != null) reject(err instanceof Error ? err : new Error(String(err))); + else resolve(); + }; + fn(done); + }); +} export const isWayland = process.platform === 'linux' && @@ -189,10 +207,10 @@ export async function getRemoteContext() { } export function useRemoteContext(opts?: any) { - before(async () => { + beforeAll(async () => { remoteContext.unshift(await makeRemoteContext(opts)); }); - after(() => { + afterAll(() => { const w = remoteContext.shift(); w!.close(); });