Files
electron/spec/api-crash-reporter.spec.ts
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

813 lines
32 KiB
TypeScript

import { app } from 'electron/main';
import Busboy from 'busboy';
import { describe, expect, it } from 'vitest';
import * as childProcess from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as http from 'node:http';
import * as os from 'node:os';
import * as path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { ifdescribe, ifit, defer, startRemoteControlApp, repeatedly, listen } from './lib/spec-helpers.ts';
const isWindowsOnArm = process.platform === 'win32' && process.arch === 'arm64';
const isWindows20H1OrLater = () => {
const [major, , build] = os.release().split('.').map(Number);
return major > 10 || (major === 10 && build >= 19041);
};
type CrashInfo = {
prod: string;
ver: string;
process_type: string;
ptype: string;
platform: string;
_productName: string;
_version: string;
upload_file_minidump: Buffer;
guid: string;
mainProcessSpecific: 'mps' | undefined;
rendererSpecific: 'rs' | undefined;
globalParam: 'globalValue' | undefined;
addedThenRemoved: 'to-be-removed' | undefined;
longParam: string | undefined;
'electron.v8-fatal.location': string | undefined;
'electron.v8-fatal.message': string | undefined;
'electron.v8-oom.stack': string | undefined;
'electron.v8-oom.heap.used': string | undefined;
'electron.v8-oom.heap.total': string | undefined;
'electron.v8-oom.heap.limit': string | undefined;
'electron.v8-oom.heap.utilization_pct': string | undefined;
'electron.v8-oom.heap.native_contexts': string | undefined;
'electron.v8-oom.heap.detached_contexts': string | undefined;
};
function checkCrash(expectedProcessType: string, fields: CrashInfo) {
expect(String(fields.prod), 'prod').toBe('Electron');
expect(String(fields.ver), 'ver').toBe(process.versions.electron);
expect(String(fields.ptype), 'ptype').toBe(expectedProcessType);
expect(String(fields.process_type), 'process_type').toBe(expectedProcessType);
expect(String(fields.platform), 'platform').toBe(process.platform);
expect(String(fields._productName), '_productName').toBe('Zombies');
expect(String(fields._version), '_version').toBe(app.getVersion());
expect(fields.upload_file_minidump).toBeInstanceOf(Buffer);
}
const startServer = async () => {
const crashes: CrashInfo[] = [];
function getCrashes() {
return crashes;
}
const emitter = new EventEmitter();
function waitForCrash(): Promise<CrashInfo> {
return new Promise((resolve) => {
emitter.once('crash', (crash) => {
resolve(crash);
});
});
}
const server = http.createServer((req, res) => {
const busboy = Busboy({ headers: req.headers });
const fields = {} as Record<string, any>;
const files = {} as Record<string, Buffer>;
busboy.on('file', (fieldname, file) => {
const chunks = [] as Array<Buffer>;
file.on('data', (chunk) => {
chunks.push(chunk);
});
file.on('end', () => {
files[fieldname] = Buffer.concat(chunks);
});
});
busboy.on('field', (fieldname, val) => {
fields[fieldname] = val;
});
busboy.on('finish', () => {
// breakpad id must be 16 hex digits.
const reportId = Math.random().toString(16).split('.')[1].padStart(16, '0');
res.end(reportId, async () => {
req.socket.destroy();
const crash = { ...fields, ...files } as CrashInfo;
crashes.push(crash);
emitter.emit('crash', crash);
});
});
req.pipe(busboy);
});
const { port } = await listen(server);
defer(() => {
server.close();
});
return { getCrashes, port, waitForCrash };
};
function runApp(appPath: string, args: Array<string> = []) {
const appProcess = childProcess.spawn(process.execPath, [appPath, ...args]);
return new Promise((resolve) => {
appProcess.once('exit', resolve);
});
}
function runCrashApp(crashType: string, port: number, extraArgs: Array<string> = []) {
const appPath = path.join(import.meta.dirname, 'fixtures', 'apps', 'crash');
return runApp(appPath, [`--crash-type=${crashType}`, `--crash-reporter-url=http://127.0.0.1:${port}`, ...extraArgs]);
}
function waitForNewFileInDir(dir: string): Promise<string[]> {
function readdirIfPresent(dir: string): string[] {
try {
return fs.readdirSync(dir);
} catch {
return [];
}
}
const initialFiles = readdirIfPresent(dir);
return new Promise((resolve) => {
const ivl = setInterval(() => {
const newCrashFiles = readdirIfPresent(dir).filter((f) => !initialFiles.includes(f));
if (newCrashFiles.length) {
clearInterval(ivl);
resolve(newCrashFiles);
}
}, 1000);
});
}
ifdescribe(!process.mas && !process.env.DISABLE_CRASH_REPORTER_TESTS)('crashReporter module', () => {
describe('should send minidump', () => {
it('when renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer', port);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).toBeUndefined();
});
it('when sandboxed renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('sandboxed-renderer', port);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).toBeUndefined();
});
// __fastfail crashes never reach crashpad's in-process handler; they are
// captured out-of-process by the electron_wer.dll WER runtime exception
// helper, which Windows only invokes on 10.0.19041 (20H1) and later.
for (const crashType of ['renderer-fastfail', 'node-renderer-fastfail']) {
ifit(process.platform === 'win32' && isWindows20H1OrLater())(
`when ${crashType === 'renderer-fastfail' ? 'sandboxed' : 'node-integrated'} renderer terminates via __fastfail`,
async () => {
const { port, waitForCrash } = await startServer();
runCrashApp(crashType, port);
const crash = await waitForCrash();
checkCrash('renderer', crash);
}
);
}
it('when main process crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port);
const crash = await waitForCrash();
checkCrash('browser', crash);
expect(crash.mainProcessSpecific).toBe('mps');
});
it('when a node process crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('node', port);
const crash = await waitForCrash();
checkCrash('node', crash);
expect(crash.mainProcessSpecific).toBeUndefined();
expect(crash.rendererSpecific).toBeUndefined();
});
it('when a node process inside a node process crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('node-fork', port);
const crash = await waitForCrash();
checkCrash('node', crash);
expect(crash.mainProcessSpecific).toBeUndefined();
expect(crash.rendererSpecific).toBeUndefined();
});
// Ensures that passing in crashpadHandlerPID flag for Linx child processes
// does not affect child process args.
ifit(process.platform === 'linux')('ensure linux child process args are not modified', async () => {
const { port, waitForCrash } = await startServer();
let exitCode: number | null = null;
const appPath = path.join(import.meta.dirname, 'fixtures', 'apps', 'crash');
const crashType = 'node-extra-args';
const crashProcess = childProcess.spawn(
process.execPath,
[appPath, `--crash-type=${crashType}`, `--crash-reporter-url=http://127.0.0.1:${port}`],
{ stdio: 'inherit' }
);
crashProcess.once('close', (code) => {
exitCode = code;
});
await waitForCrash();
expect(exitCode).toBe(0);
});
describe('with guid', () => {
for (const processType of ['main', 'renderer', 'sandboxed-renderer']) {
it(`when ${processType} crashes`, async () => {
const { port, waitForCrash } = await startServer();
runCrashApp(processType, port);
const crash = await waitForCrash();
expect(crash.guid).toBeTypeOf('string');
});
}
it('is a consistent id', async () => {
let crash1Guid;
let crash2Guid;
{
const { port, waitForCrash } = await startServer();
runCrashApp('main', port);
const crash = await waitForCrash();
crash1Guid = crash.guid;
}
{
const { port, waitForCrash } = await startServer();
runCrashApp('main', port);
const crash = await waitForCrash();
crash2Guid = crash.guid;
}
expect(crash2Guid).toBe(crash1Guid);
});
});
describe('with extra parameters', () => {
it('when renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer', port, ['--set-extra-parameters-in-renderer']);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).toBeUndefined();
expect(crash.rendererSpecific).toBe('rs');
expect(crash.addedThenRemoved).toBeUndefined();
});
it('when sandboxed renderer crashes', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('sandboxed-renderer', port, ['--set-extra-parameters-in-renderer']);
const crash = await waitForCrash();
checkCrash('renderer', crash);
expect(crash.mainProcessSpecific).toBeUndefined();
expect(crash.rendererSpecific).toBe('rs');
expect(crash.addedThenRemoved).toBeUndefined();
});
// Regression: base::circular_deque relocates elements on growth,
// corrupting crashpad::Annotation's self-referential pointers and
// causing missing crash keys or a hung handler. See crash_keys.cc.
it('does not corrupt the crashpad annotation list after deque reallocation', { timeout: 45000 }, async () => {
// Tight timeout so a hanging handler fails fast instead of waiting
// for the mocha default of 120s.
const { port, waitForCrash } = await startServer();
runCrashApp('renderer-dynamic-keys', port);
const crash = await Promise.race([
waitForCrash(),
new Promise<never>((_resolve, reject) => {
global.setTimeout(
() =>
reject(
new Error(
'crashpad handler hung walking corrupted annotation list; crash upload did not arrive within 30s'
)
),
30000
);
})
]);
expect(crash.process_type).toBe('renderer');
const missing: string[] = [];
for (let i = 0; i < 50; i++) {
if ((crash as any)[`dyn-key-${i}`] !== `val-${i}`) {
missing.push(`dyn-key-${i}`);
}
}
expect(missing, `missing dynamic crash keys: ${missing.join(', ')}`).toHaveLength(0);
});
it('contains v8 crash keys when a v8 crash occurs', async () => {
const { remotely } = await startRemoteControlApp();
const { port, waitForCrash } = await startServer();
await remotely(
(port: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true
});
},
[port]
);
remotely(() => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
bw.loadURL('about:blank');
bw.webContents.executeJavaScript(
"process._linkedBinding('electron_common_v8_util').triggerFatalErrorForTesting()"
);
});
const crash = await waitForCrash();
expect(crash.prod).toBe('Electron');
expect(crash._productName).toBe('electron-test-remote-control');
expect(crash.process_type).toBe('renderer');
expect(crash['electron.v8-fatal.location']).toBe('v8::Context::New()');
expect(crash['electron.v8-fatal.message']).toBe('Circular extension dependency');
});
});
describe('OOM crash keys', () => {
it(
'reports OOM stack trace and heap statistics when renderer runs out of memory',
{ timeout: 120000 },
async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer-oom', port, ['--js-flags=--max-old-space-size=128']);
const crash = await waitForCrash();
expect(crash.process_type).toBe('renderer');
expect(crash['electron.v8-oom.stack']).toBeTypeOf('string');
expect(crash['electron.v8-oom.stack']).toContain('oomTrigger');
expect(crash['electron.v8-oom.heap.used']).toBeTypeOf('string');
expect(crash['electron.v8-oom.heap.limit']).toBeTypeOf('string');
}
);
it('captures the calling function on JSON.stringify OOM', { timeout: 120000 }, async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer-oom-json', port, ['--js-flags=--max-old-space-size=128']);
const crash = await waitForCrash();
expect(crash.process_type).toBe('renderer');
expect(crash['electron.v8-oom.stack']).toBeTypeOf('string');
expect(crash['electron.v8-oom.stack']).toContain('serializeData');
});
it('captures OOM crash keys inside a web worker', { timeout: 120000 }, async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer-oom-worker', port, ['--js-flags=--max-old-space-size=128']);
const crash = await waitForCrash();
expect(crash.process_type).toBe('renderer');
const oomStack = crash['electron.v8-oom.stack'];
expect(oomStack).toBeTypeOf('string');
expect(oomStack!.length).toBeGreaterThan(0);
expect(crash['electron.v8-oom.heap.used']).toBeTypeOf('string');
expect(crash['electron.v8-oom.heap.limit']).toBeTypeOf('string');
});
});
});
describe('extra parameter limits', () => {
function stitchLongCrashParam(crash: any, paramKey: string) {
if (crash[paramKey]) return crash[paramKey];
let chunk = 1;
let stitched = '';
while (crash[`${paramKey}__${chunk}`]) {
stitched += crash[`${paramKey}__${chunk}`];
chunk++;
}
return stitched;
}
it('should truncate extra values longer than 5 * 4096 characters', async () => {
const { port, waitForCrash } = await startServer();
const { remotely } = await startRemoteControlApp();
remotely((port: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true,
extra: { longParam: 'a'.repeat(100000) }
});
global.setTimeout(() => process.crash());
}, port);
const crash = await waitForCrash();
expect(stitchLongCrashParam(crash, 'longParam'), 'crash should have truncated longParam').toHaveLength(160 * 127);
});
it('should omit extra keys with names longer than the maximum', async () => {
const kKeyLengthMax = 39;
const { port, waitForCrash } = await startServer();
const { remotely } = await startRemoteControlApp();
remotely(
(port: number, kKeyLengthMax: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true,
extra: {
['a'.repeat(kKeyLengthMax + 10)]: 'value',
['b'.repeat(kKeyLengthMax)]: 'value',
'not-long': 'not-long-value'
}
});
require('electron').crashReporter.addExtraParameter('c'.repeat(kKeyLengthMax + 10), 'value');
global.setTimeout(() => process.crash());
},
port,
kKeyLengthMax
);
const crash = await waitForCrash();
expect(crash).not.toHaveProperty(['a'.repeat(kKeyLengthMax + 10)]);
expect(crash).not.toHaveProperty(['a'.repeat(kKeyLengthMax)]);
expect(crash).toHaveProperty(['b'.repeat(kKeyLengthMax)], 'value');
expect(crash).toHaveProperty('not-long', 'not-long-value');
expect(crash).not.toHaveProperty(['c'.repeat(kKeyLengthMax + 10)]);
expect(crash).not.toHaveProperty(['c'.repeat(kKeyLengthMax)]);
});
});
describe('globalExtra', () => {
it('should be sent with main process dumps', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port, ['--add-global-param=globalParam:globalValue']);
const crash = await waitForCrash();
expect(crash.globalParam).toBe('globalValue');
});
it('should be sent with renderer process dumps', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('renderer', port, ['--add-global-param=globalParam:globalValue']);
const crash = await waitForCrash();
expect(crash.globalParam).toBe('globalValue');
});
it('should be sent with sandboxed renderer process dumps', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('sandboxed-renderer', port, ['--add-global-param=globalParam:globalValue']);
const crash = await waitForCrash();
expect(crash.globalParam).toBe('globalValue');
});
it('should not be overridden by extra in main process', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port, ['--add-global-param=mainProcessSpecific:global']);
const crash = await waitForCrash();
expect(crash.mainProcessSpecific).toBe('global');
});
it('should not be overridden by extra in renderer process', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port, ['--add-global-param=rendererSpecific:global']);
const crash = await waitForCrash();
expect(crash.rendererSpecific).toBe('global');
});
});
// TODO(nornagon): also test crashing main / sandboxed renderers.
ifit(!isWindowsOnArm)('should not send a minidump when uploadToServer is false', async () => {
const { port, waitForCrash, getCrashes } = await startServer();
waitForCrash().then(() => expect.fail('expected not to receive a dump'));
await runCrashApp('renderer', port, ['--no-upload']);
// wait a sec in case the crash reporter is about to upload a crash
await setTimeout(1000);
expect(getCrashes()).toHaveLength(0);
});
ifit(!isWindowsOnArm)('should not send a minidump when setUploadToServer(false) is called after start', async () => {
const { port, getCrashes } = await startServer();
await runCrashApp('main', port, ['--set-upload-to-server=false']);
await setTimeout(2000);
expect(getCrashes()).toHaveLength(0);
});
ifit(!isWindowsOnArm)('should send a minidump when setUploadToServer(true) is called after start', async () => {
const { port, waitForCrash } = await startServer();
runCrashApp('main', port, ['--no-upload', '--set-upload-to-server=true']);
const crash = await waitForCrash();
checkCrash('browser', crash);
});
describe('getUploadedReports', () => {
it('returns an array of reports', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' });
});
const reports = await remotely(() => require('electron').crashReporter.getUploadedReports());
expect(reports).toBeInstanceOf(Array);
});
});
// TODO(nornagon): re-enable on woa
ifdescribe(!isWindowsOnArm)('getLastCrashReport', () => {
it('returns the last uploaded report', async () => {
const { remotely } = await startRemoteControlApp();
const { port, waitForCrash } = await startServer();
// 0. clear the crash reports directory.
const dir = await remotely(() => require('electron').app.getPath('crashDumps'));
try {
fs.rmdirSync(dir, { recursive: true });
fs.mkdirSync(dir);
} catch {
/* ignore */
}
// 1. start the crash reporter.
await remotely(
(port: number) => {
require('electron').crashReporter.start({
submitURL: `http://127.0.0.1:${port}`,
compress: false,
ignoreSystemCrashHandler: true
});
},
[port]
);
// 2. generate a crash in the renderer.
remotely(() => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
bw.loadURL('about:blank');
bw.webContents.executeJavaScript('process.crash()');
});
await waitForCrash();
// 3. get the crash from getLastCrashReport.
const firstReport = await repeatedly(() =>
remotely(() => require('electron').crashReporter.getLastCrashReport())
);
expect(firstReport).not.toBeNull();
expect(firstReport.date).toBeInstanceOf(Date);
expect(Date.now() - +firstReport.date).toBeLessThan(30000);
});
});
describe('getParameters', () => {
it('returns all of the current parameters', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({
submitURL: 'http://127.0.0.1',
extra: { extra1: 'hi' }
});
});
const parameters = await remotely(() => require('electron').crashReporter.getParameters());
expect(parameters).toHaveProperty('extra1', 'hi');
});
it('reflects added and removed parameters', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' });
require('electron').crashReporter.addExtraParameter('hello', 'world');
});
{
const parameters = await remotely(() => require('electron').crashReporter.getParameters());
expect(parameters).toHaveProperty('hello', 'world');
}
await remotely(() => {
require('electron').crashReporter.removeExtraParameter('hello');
});
{
const parameters = await remotely(() => require('electron').crashReporter.getParameters());
expect(parameters).not.toHaveProperty('hello');
}
});
it('can be called in the renderer', async () => {
const { remotely } = await startRemoteControlApp();
const rendererParameters = await remotely(async () => {
const { crashReporter, BrowserWindow } = require('electron');
crashReporter.start({ submitURL: 'http://' });
const bw = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
bw.loadURL('about:blank');
await bw.webContents.executeJavaScript("require('electron').crashReporter.addExtraParameter('hello', 'world')");
return bw.webContents.executeJavaScript("require('electron').crashReporter.getParameters()");
});
expect(rendererParameters).toEqual({ hello: 'world' });
});
it('can be called in a node child process', async () => {
function slurp(stream: NodeJS.ReadableStream): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
stream.on('data', (chunk) => {
chunks.push(chunk);
});
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', (e) => reject(e));
});
}
// TODO(nornagon): how to enable crashpad in a node child process...?
const child = childProcess.fork(
path.join(import.meta.dirname, 'fixtures', 'module', 'print-crash-parameters.js'),
[],
{
silent: true
}
);
const output = await slurp(child.stdout!);
expect(JSON.parse(output)).toEqual({ hello: 'world' });
});
});
describe('crash dumps directory', () => {
it('is set by default', () => {
expect(app.getPath('crashDumps')).toBeTypeOf('string');
});
it('is inside the user data dir', () => {
expect(app.getPath('crashDumps')).toContain(app.getPath('userData'));
});
function crash(processType: string, remotely: Function) {
if (processType === 'main') {
return remotely(() => {
global.setTimeout(() => {
process.crash();
});
});
} else if (processType === 'renderer') {
return remotely(() => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({
show: false,
webPreferences: { nodeIntegration: true, contextIsolation: false }
});
bw.loadURL('about:blank');
bw.webContents.executeJavaScript('process.crash()');
});
} else if (processType === 'sandboxed-renderer') {
const preloadPath = path.join(import.meta.dirname, 'fixtures', 'apps', 'crash', 'sandbox-preload.js');
return remotely((preload: string) => {
const { BrowserWindow } = require('electron');
const bw = new BrowserWindow({
show: false,
webPreferences: { sandbox: true, preload, contextIsolation: false }
});
bw.loadURL('about:blank');
}, preloadPath);
} else if (processType === 'node') {
const crashScriptPath = path.join(import.meta.dirname, 'fixtures', 'apps', 'crash', 'node-crash.js');
return remotely((crashScriptPath: string) => {
const { app } = require('electron');
const childProcess = require('node:child_process');
const version = app.getVersion();
const url = 'http://127.0.0.1';
childProcess.fork(crashScriptPath, [url, version], { silent: true });
}, crashScriptPath);
}
}
const processList =
process.platform === 'linux'
? ['main', 'renderer', 'sandboxed-renderer']
: ['main', 'renderer', 'sandboxed-renderer', 'node'];
for (const crashingProcess of processList) {
describe(`when ${crashingProcess} crashes`, () => {
it('stores crashes in the crash dump directory when uploadToServer: false', async () => {
const { remotely } = await startRemoteControlApp();
const crashesDir = await remotely(() => {
const { crashReporter, app } = require('electron');
crashReporter.start({
submitURL: 'http://127.0.0.1',
uploadToServer: false,
ignoreSystemCrashHandler: true
});
return app.getPath('crashDumps');
});
let reportsDir = crashesDir;
if (process.platform === 'darwin' || process.platform === 'linux') {
reportsDir = path.join(crashesDir, 'completed');
} else if (process.platform === 'win32') {
reportsDir = path.join(crashesDir, 'reports');
}
const newFileAppeared = waitForNewFileInDir(reportsDir);
crash(crashingProcess, remotely);
const newFiles = await newFileAppeared;
expect(newFiles.length).toBeGreaterThan(0);
expect(newFiles[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.dmp$/);
});
it('respects an overridden crash dump directory', async () => {
const { remotely } = await startRemoteControlApp();
const crashesDir = path.join(app.getPath('temp'), randomUUID());
const remoteCrashesDir = await remotely((crashesDir: string) => {
const { crashReporter, app } = require('electron');
app.setPath('crashDumps', crashesDir);
crashReporter.start({
submitURL: 'http://127.0.0.1',
uploadToServer: false,
ignoreSystemCrashHandler: true
});
return app.getPath('crashDumps');
}, crashesDir);
expect(remoteCrashesDir).toBe(crashesDir);
let reportsDir = crashesDir;
if (process.platform === 'darwin' || process.platform === 'linux') {
reportsDir = path.join(crashesDir, 'completed');
} else if (process.platform === 'win32') {
reportsDir = path.join(crashesDir, 'reports');
}
const newFileAppeared = waitForNewFileInDir(reportsDir);
crash(crashingProcess, remotely);
const newFiles = await newFileAppeared;
expect(newFiles.length).toBeGreaterThan(0);
expect(newFiles[0]).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.dmp$/);
});
});
}
});
describe('start() option validation', () => {
it('requires that the submitURL option be specified', async () => {
const { remotely } = await startRemoteControlApp();
await expect(
remotely(() => {
const { crashReporter } = require('electron');
crashReporter.start({} as any);
})
).rejects.toThrow('submitURL must be specified when uploadToServer is true');
});
it('allows the submitURL option to be omitted when uploadToServer is false', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
const { crashReporter } = require('electron');
crashReporter.start({ uploadToServer: false } as any);
});
});
it('can be called twice', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
const { crashReporter } = require('electron');
crashReporter.start({ submitURL: 'http://127.0.0.1' });
crashReporter.start({ submitURL: 'http://127.0.0.1' });
});
});
});
describe('getUploadToServer()', () => {
it('returns true when uploadToServer is set to true (by default)', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' });
});
const uploadToServer = await remotely(() => require('electron').crashReporter.getUploadToServer());
expect(uploadToServer).toBe(true);
});
it('returns false when uploadToServer is set to false in init', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1', uploadToServer: false });
});
const uploadToServer = await remotely(() => require('electron').crashReporter.getUploadToServer());
expect(uploadToServer).toBe(false);
});
it('is updated by setUploadToServer', async () => {
const { remotely } = await startRemoteControlApp();
await remotely(() => {
require('electron').crashReporter.start({ submitURL: 'http://127.0.0.1' });
});
await remotely(() => {
require('electron').crashReporter.setUploadToServer(false);
});
expect(await remotely(() => require('electron').crashReporter.getUploadToServer())).toBe(false);
await remotely(() => {
require('electron').crashReporter.setUploadToServer(true);
});
expect(await remotely(() => require('electron').crashReporter.getUploadToServer())).toBe(true);
});
});
describe('when not started', () => {
it('does not prevent process from crashing', async () => {
const appPath = path.join(import.meta.dirname, 'fixtures', 'api', 'cookie-app');
await runApp(appPath);
});
});
});