Files
electron/spec/api-autoupdater-darwin.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

462 lines
21 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import { setupUpdaterHarness, shouldRunUpdaterSpecs } from './lib/autoupdater-darwin-helpers.ts';
import { copyMacOSFixtureApp, unsignApp } from './lib/codesign-helpers.ts';
import { withTempDirectory } from './lib/fs-helpers.ts';
import { ifdescribe, ifit } from './lib/spec-helpers.ts';
import type { RoutedResponse } from './lib/http-server-helpers.ts';
// The update lifecycle: checking, downloading, staging, installing and
// relaunching. The rules for refusing or altering an update are in
// api-autoupdater-darwin-policy.spec.ts; the two files share a harness and
// are split so that they can land on different CI shards.
// The updater specs share ShipIt's per-app cache directory, so no two of them
// can run at the same time.
ifdescribe(shouldRunUpdaterSpecs)('autoUpdater behavior', { tags: ['serial'], timeout: 120000 }, () => {
const harness = setupUpdaterHarness();
const { launchApp, shallowSign, logOnError, updaterIt } = harness;
it('should have a valid code signing identity', () => {
expect(harness.identity()).toBeTypeOf('string');
expect(harness.identity().length).toBeGreaterThanOrEqual(1);
});
// These use the raw build output, not the template. On arm64 builds the
// built app is self-signed by default so the setFeedURL call always works.
ifit(process.arch !== 'arm64')('should fail to set the feed URL when the app is not signed', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyMacOSFixtureApp(dir);
await unsignApp(appPath);
const launchResult = await launchApp(appPath, ['http://myupdate']);
console.log(launchResult);
expect(launchResult.code).toBe(1);
expect(launchResult.out).toContain('Could not get code signature for running application');
});
});
ifit(process.arch !== 'arm64')(
'should fail with code signature error when serverType is default and app is unsigned',
async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyMacOSFixtureApp(dir);
await unsignApp(appPath);
const launchResult = await launchApp(appPath, ['', 'default']);
expect(launchResult.code).toBe(1);
expect(launchResult.out).toContain('Could not get code signature for running application');
});
}
);
ifit(process.arch !== 'arm64')(
'should fail with code signature error when serverType is json and app is unsigned',
async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyMacOSFixtureApp(dir);
await unsignApp(appPath);
const launchResult = await launchApp(appPath, ['', 'json']);
expect(launchResult.code).toBe(1);
expect(launchResult.out).toContain('Could not get code signature for running application');
});
}
);
ifit(process.arch !== 'arm64')(
'should fail with serverType error when an invalid serverType is provided',
async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyMacOSFixtureApp(dir);
const launchResult = await launchApp(appPath, ['', 'weow']);
expect(launchResult.code).toBe(1);
expect(launchResult.out).toContain("Expected serverType to be 'default' or 'json'");
});
}
);
it('should cleanly set the feed URL when the app is signed', async () => {
await withTempDirectory(async (dir) => {
const appPath = await copyMacOSFixtureApp(dir, 'initial', { sourceApp: harness.templateApp() });
await shallowSign(appPath);
const launchResult = await launchApp(appPath, ['http://myupdate']);
expect(launchResult.code).toBe(0);
expect(launchResult.out).toContain('Feed URL Set: http://myupdate');
});
});
describe('with update server', () => {
updaterIt('should hit the update endpoint when checkForUpdates is called', async (ctx) => {
await withTempDirectory(async (dir) => {
const appPath = await ctx.copySignedApp(dir, 'check');
ctx.server.get('/update-check', (req, res) => {
res.status(204).send();
});
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).toBe(0);
expect(ctx.requests).toHaveLength(1);
expect(ctx.requests[0]).toHaveProperty('url', '/update-check');
expect(ctx.requests[0].header('user-agent')).toContain('Electron/');
});
});
});
updaterIt('should hit the update endpoint with customer headers when checkForUpdates is called', async (ctx) => {
await withTempDirectory(async (dir) => {
const appPath = await ctx.copySignedApp(dir, 'check-with-headers');
ctx.server.get('/update-check', (req, res) => {
res.status(204).send();
});
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult.code).toBe(0);
expect(ctx.requests).toHaveLength(1);
expect(ctx.requests[0]).toHaveProperty('url', '/update-check');
expect(ctx.requests[0].header('x-test')).toBe('this-is-a-test');
});
});
});
updaterIt(
'should hit the download endpoint when an update is available and error if the file is bad',
async (ctx) => {
await withTempDirectory(async (dir) => {
const appPath = await ctx.copySignedApp(dir, 'update');
ctx.server.get('/update-file', (req, res) => {
res.status(500).send('This is not a file');
});
ctx.server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${ctx.port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: new Date().toString()
});
});
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).toHaveProperty('code', 1);
expect(launchResult.out).toContain('Update download failed. The server sent an invalid response.');
expect(ctx.requests).toHaveLength(2);
expect(ctx.requests[0]).toHaveProperty('url', '/update-check');
expect(ctx.requests[1]).toHaveProperty('url', '/update-file');
expect(ctx.requests[0].header('user-agent')).toContain('Electron/');
expect(ctx.requests[1].header('user-agent')).toContain('Electron/');
});
});
}
);
updaterIt(
'should hit the download endpoint when an update is available and update successfully when the zip is provided',
async (ctx) => {
await ctx.withUpdatableApp(
{
nextVersion: '2.0.0',
startFixture: 'update',
endFixture: 'update'
},
async (appPath, updateZipPath) => {
ctx.serveUpdate(updateZipPath);
const relaunchPromise = ctx.relaunched();
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).toHaveProperty('code', 0);
expect(launchResult.out).toContain('Update Downloaded');
expect(ctx.requests).toHaveLength(2);
expect(ctx.requests[0]).toHaveProperty('url', '/update-check');
expect(ctx.requests[1]).toHaveProperty('url', '/update-file');
expect(ctx.requests[0].header('user-agent')).toContain('Electron/');
expect(ctx.requests[1].header('user-agent')).toContain('Electron/');
});
await relaunchPromise;
expect(ctx.requests).toHaveLength(3);
expect(ctx.requests[2].url).toBe('/update-check/updated/2.0.0');
expect(ctx.requests[2].header('user-agent')).toContain('Electron/');
}
);
}
);
updaterIt(
'should hit the download endpoint when an update is available and update successfully when the zip is provided even after a different update was staged',
async (ctx) => {
await ctx.withUpdatableApp(
{
nextVersion: '2.0.0',
startFixture: 'update-stack',
endFixture: 'update-stack'
},
async (appPath, updateZipPath2) => {
const updateZipPath3 = await ctx.getUpdateZip('3.0.0', 'update-stack');
let updateCount = 0;
ctx.server.get('/update-file', (req, res) => {
res.download(updateCount > 1 ? updateZipPath3 : updateZipPath2);
});
ctx.server.get('/update-check', (req, res) => {
updateCount++;
res.json({
url: `http://localhost:${ctx.port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: new Date().toString()
});
});
const relaunchPromise = ctx.relaunched();
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).toHaveProperty('code', 0);
expect(launchResult.out).toContain('Update Downloaded');
expect(ctx.requests).toHaveLength(4);
expect(ctx.requests[0]).toHaveProperty('url', '/update-check');
expect(ctx.requests[1]).toHaveProperty('url', '/update-file');
expect(ctx.requests[0].header('user-agent')).toContain('Electron/');
expect(ctx.requests[1].header('user-agent')).toContain('Electron/');
expect(ctx.requests[2]).toHaveProperty('url', '/update-check');
expect(ctx.requests[3]).toHaveProperty('url', '/update-file');
expect(ctx.requests[2].header('user-agent')).toContain('Electron/');
expect(ctx.requests[3].header('user-agent')).toContain('Electron/');
});
await relaunchPromise;
expect(ctx.requests).toHaveLength(5);
expect(ctx.requests[4].url).toBe('/update-check/updated/3.0.0');
expect(ctx.requests[4].header('user-agent')).toContain('Electron/');
}
);
},
{ timeout: 180000 }
);
updaterIt(
'should preserve the staged update directory and prune orphaned ones when a new update is downloaded',
async (ctx) => {
// Clean up any existing update directories before the test
await ctx.cleanSquirrelCache();
await ctx.withUpdatableApp(
{
nextVersion: '2.0.0',
startFixture: 'update-stack',
endFixture: 'update-stack'
},
async (appPath, updateZipPath2) => {
const updateZipPath3 = await ctx.getUpdateZip('3.0.0', 'update-stack');
let updateCount = 0;
let downloadCount = 0;
let dirsDuringFirstDownload: string[] = [];
let dirsDuringSecondDownload: string[] = [];
ctx.server.get('/update-file', async (req, res) => {
downloadCount++;
// Snapshot update directories at the moment each download begins.
// By this point uniqueTemporaryDirectoryForUpdate has already run
// (prune + mkdtemp). We want to verify:
// 1st download: 1 dir (nothing to preserve, nothing to prune)
// 2nd download: 2 dirs (staged dir from 1st check is preserved
// so quitAndInstall stays safe, + new temp dir)
// The count never exceeds 2 across repeated checks — orphaned dirs
// (no longer referenced by ShipItState.plist) get pruned.
if (downloadCount === 1) {
dirsDuringFirstDownload = await ctx.getUpdateDirectoriesInCache();
} else if (downloadCount === 2) {
dirsDuringSecondDownload = await ctx.getUpdateDirectoriesInCache();
}
res.download(updateCount > 1 ? updateZipPath3 : updateZipPath2);
});
ctx.server.get('/update-check', (req, res) => {
updateCount++;
res.json({
url: `http://localhost:${ctx.port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: new Date().toString()
});
});
const relaunchPromise = ctx.relaunched();
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).toHaveProperty('code', 0);
expect(launchResult.out).toContain('Update Downloaded');
});
await relaunchPromise;
// First download: exactly one temp dir (the first update).
expect(
dirsDuringFirstDownload,
`Expected 1 update directory during first download but found ${dirsDuringFirstDownload.length}: ${dirsDuringFirstDownload.join(', ')}`
).toHaveLength(1);
// Second download: exactly two — the staged one preserved + the new
// one. Crucially the first download's directory must still be present,
// otherwise a mid-download quitAndInstall would find a dangling
// ShipItState.plist.
expect(
dirsDuringSecondDownload,
`Expected 2 update directories during second download (staged + new) but found ${dirsDuringSecondDownload.length}: ${dirsDuringSecondDownload.join(', ')}`
).toHaveLength(2);
expect(
dirsDuringSecondDownload,
'The staged update directory from the first download must be preserved during the second download'
).toContain(dirsDuringFirstDownload[0]);
}
);
}
);
updaterIt(
'should keep the update directory count bounded across repeated checks',
async (ctx) => {
// Verifies the orphan prune actually fires: after a second download
// completes and rewrites ShipItState.plist, the first directory is no
// longer referenced and must be removed when a third check begins.
// Without this, directories would accumulate forever.
await ctx.cleanSquirrelCache();
await ctx.withUpdatableApp(
{
nextVersion: '2.0.0',
startFixture: 'update-triple-stack',
endFixture: 'update-triple-stack'
},
async (appPath, updateZipPath2) => {
const updateZipPath3 = await ctx.getUpdateZip('3.0.0', 'update-triple-stack');
const updateZipPath4 = await ctx.getUpdateZip('4.0.0', 'update-triple-stack');
let downloadCount = 0;
const dirsPerDownload: string[][] = [];
ctx.server.get('/update-file', async (req, res) => {
downloadCount++;
// Snapshot after prune+mkdtemp but before the payload transfers.
dirsPerDownload.push(await ctx.getUpdateDirectoriesInCache());
const zips = [updateZipPath2, updateZipPath3, updateZipPath4];
res.download(zips[Math.min(downloadCount, zips.length) - 1]);
});
ctx.server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${ctx.port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: new Date().toString()
});
});
const relaunchPromise = ctx.relaunched();
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).toHaveProperty('code', 0);
expect(launchResult.out).toContain('Update Downloaded');
});
await relaunchPromise;
expect(ctx.requests[ctx.requests.length - 1].url).toBe('/update-check/updated/4.0.0');
expect(dirsPerDownload).toHaveLength(3);
// 1st: fresh cache, 1 dir.
expect(dirsPerDownload[0], `1st download: ${dirsPerDownload[0].join(', ')}`).toHaveLength(1);
// 2nd: staged (1st) preserved + new = 2 dirs.
expect(dirsPerDownload[1], `2nd download: ${dirsPerDownload[1].join(', ')}`).toHaveLength(2);
expect(dirsPerDownload[1]).toContain(dirsPerDownload[0][0]);
// 3rd: 1st is now orphaned (plist points to 2nd) — must be pruned.
// Staged (2nd) preserved + new = still 2 dirs. Bounded.
expect(dirsPerDownload[2], `3rd download: ${dirsPerDownload[2].join(', ')}`).toHaveLength(2);
expect(
dirsPerDownload[2],
'The first (now orphaned) update directory must be pruned on the third check'
).not.toContain(dirsPerDownload[0][0]);
const secondDir = dirsPerDownload[1].find((d) => d !== dirsPerDownload[0][0]);
expect(
dirsPerDownload[2],
'The second (currently staged) update directory must be preserved on the third check'
).toContain(secondDir);
}
);
},
{ timeout: 240000 }
);
// Regression test for https://github.com/electron/electron/issues/50200
//
// When checkForUpdates() is called again after an update has been staged,
// Squirrel creates a new temporary directory and prunes old ones. If the
// prune removes the directory that ShipItState.plist references while the
// second download is still in flight, a subsequent quitAndInstall() will
// fail with ENOENT and the app will never relaunch.
updaterIt(
'should install the staged update when quitAndInstall is called while a second check is in flight',
async (ctx) => {
await ctx.cleanSquirrelCache();
await ctx.withUpdatableApp(
{
nextVersion: '2.0.0',
startFixture: 'update-race',
endFixture: 'update-race'
},
async (appPath, updateZipPath) => {
let downloadCount = 0;
let stalledResponse: RoutedResponse | null = null;
ctx.server.get('/update-file', (req, res) => {
downloadCount++;
if (downloadCount === 1) {
// First download completes normally and stages the update.
res.download(updateZipPath);
} else {
// Second download: stall indefinitely to simulate a slow
// network. This keeps the second check "in progress" when
// quitAndInstall() fires. Hold onto the response so we can
// clean it up later.
stalledResponse = res;
}
});
ctx.server.get('/update-check', (req, res) => {
res.json({
url: `http://localhost:${ctx.port}/update-file`,
name: 'My Release Name',
notes: 'Theses are some release notes innit',
pub_date: new Date().toString()
});
});
const relaunchPromise = ctx.relaunched();
const launchResult = await ctx.launchApp(appPath, [`http://localhost:${ctx.port}/update-check`]);
logOnError(launchResult, () => {
expect(launchResult).toHaveProperty('code', 0);
expect(launchResult.out).toContain('Update Downloaded');
expect(launchResult.out).toContain('Calling quitAndInstall mid-download');
// First check + first download + second check + stalled second download.
expect(ctx.requests).toHaveLength(4);
expect(ctx.requests[0]).toHaveProperty('url', '/update-check');
expect(ctx.requests[1]).toHaveProperty('url', '/update-file');
expect(ctx.requests[2]).toHaveProperty('url', '/update-check');
expect(ctx.requests[3]).toHaveProperty('url', '/update-file');
// The second download must have been in flight (never completed)
// when quitAndInstall was called.
expect(launchResult.out).not.toContain('Unexpected second download completion');
});
// Unblock the stalled response now that the initial app has exited
// so the server can shut down cleanly.
if (stalledResponse) {
(stalledResponse as RoutedResponse).status(500).end();
}
// The originally staged update (2.0.0) must have been applied and
// the app must relaunch, proving the staged update directory was
// not pruned out from under ShipItState.plist.
await relaunchPromise;
expect(ctx.requests).toHaveLength(5);
expect(ctx.requests[4].url).toBe('/update-check/updated/2.0.0');
expect(ctx.requests[4].header('user-agent')).toContain('Electron/');
}
);
}
);
});
});