Files
maka/scripts/verify-windows-harness.test.mjs
agent-radar 7be535059e
Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 19:05:14 +08:00

823 lines
27 KiB
JavaScript

/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { PassThrough } from 'node:stream';
import { after, describe, it } from 'node:test';
import { bumpedAutoupdateVersion } from './desktop-update-contract.mjs';
import { validateWindowsUpgradeBaseline } from './prepare-windows-upgrade-baseline.mjs';
import {
diffTreeManifests,
directoryTreeManifest,
rendererLayoutMatchesViewport,
rendererViewportMatchesNativeClient,
runCommand,
waitForDevToolsPort,
waitForUsableRenderer,
} from './verify-packaged-app.mjs';
import { waitForInstalledProductVersion } from './verify-windows-autoupdate.mjs';
import { verifyPackagedWindowsSandboxLifecycle } from './verify-windows-x64.mjs';
import {
WINDOWS_SANDBOX_DEFERRED_HARDENING,
WINDOWS_SANDBOX_PHASE4_MATRIX,
} from './verify-windows-sandbox-e2e.mjs';
import {
deleteUninstallRegistrationForInstall,
readUninstallDisplayVersionsForInstall,
verifyRestoredWindowsInstallation,
} from './verify-windows-installer-rollback.mjs';
import {
completeInstalledApplicationUninstall,
installerVersion,
listInstalledProcesses,
terminateInstalledProcesses,
waitForInstalledProcessAppearance,
waitForInstalledProcessesToExit,
waitForUninstallRegistrationToClear,
} from './verify-windows-installer-lifecycle.mjs';
// Tests for the shared release-verification helpers. Everything here is
// platform-neutral on purpose: the Windows lanes execute these helpers for
// real, and this file proves their contracts on every PR that touches them.
const temporaryRoots = [];
const delay = (milliseconds) =>
new Promise((resolvePromise) => setTimeout(resolvePromise, milliseconds));
it('pins every Phase 4 adversarial category to executable evidence', async () => {
assert.deepEqual(WINDOWS_SANDBOX_PHASE4_MATRIX.map(({ category }) => category).sort(), [
'credentials',
'descendants',
'environment',
'filesystem_aliases',
'ipc',
'lifecycle_failures',
'network_channels',
'registry',
]);
for (const row of WINDOWS_SANDBOX_PHASE4_MATRIX) {
assert.ok(row.evidence.length > 0, `${row.category} has no executable evidence`);
assert.equal(new Set(row.evidence).size, row.evidence.length, `${row.category} is duplicated`);
}
assert.deepEqual(WINDOWS_SANDBOX_DEFERRED_HARDENING, [
'Authenticode identity verification',
'direct Credential Manager and DPAPI probes',
'inbound listener enforcement',
'UDP channel enforcement',
'no-Win32k mitigation',
'dedicated window-station and clipboard isolation',
'power-loss automatic recovery',
]);
const verifier = await readFile(
new URL('./verify-windows-sandbox-e2e.mjs', import.meta.url),
'utf8',
);
for (const stage of [
'verifyPackagedClientCancellation',
'verifyPackagedRuntimeHostParentDeath',
'verifyPackagedConcurrencySoak',
'verifyPackagedAdversarialMatrix',
]) {
assert.match(verifier, new RegExp(`await ${stage}\\(`));
}
const adversarialProbe = await readFile(
new URL('../experiments/windows-sandbox/adversarial-matrix-smoke.ps1', import.meta.url),
'utf8',
);
for (const field of [
'fileDenied',
'tcpDenied',
'namedPipeDenied',
'environmentDenied',
'registryDenied',
'parentTokenDenied',
'descendantAppContainer',
'descendantInJob',
'descendantSpawnDenied',
]) {
assert.match(adversarialProbe, new RegExp(`"${field}":true`));
}
assert.match(adversarialProbe, /phase4-junction/u);
assert.match(adversarialProbe, /phase4-hardlink/u);
assert.match(adversarialProbe, /\.json\.quarantined/u);
assert.match(adversarialProbe, /cleanupErrors\.Count -gt 0/u);
assert.doesNotMatch(adversarialProbe, /SilentlyContinue/u);
assert.match(adversarialProbe, /work root still exists/u);
const localBroker = await readFile(
new URL('../experiments/windows-sandbox/launcher/src/broker_client.rs', import.meta.url),
'utf8',
);
assert.match(localBroker, /let owner_pid = parent_process_id\(\)\?/u);
assert.match(localBroker, /validate_owner_process\(owner, owner_pid\)/u);
assert.match(localBroker, /GetProcessTimes/u);
assert.match(localBroker, /launch_appcontainer_owned\(&request\.launch, owner\)/u);
const launcher = await readFile(
new URL('../experiments/windows-sandbox/launcher/src/windows_launcher.rs', import.meta.url),
'utf8',
);
assert.match(launcher, /WaitForMultipleObjects/u);
assert.match(launcher, /Runtime Host owner exited during sandbox launch/u);
});
it('scopes rollback registration reads and deletion to the fixture uninstaller', async () => {
const calls = [];
const uninstaller = 'C:\\fixture\\installed\\Uninstall Maka.exe';
const run = async (command, args, options) => {
calls.push({ command, args, options });
return { stdout: '0.1.11', stderr: '' };
};
assert.equal(await readUninstallDisplayVersionsForInstall(uninstaller, { run }), '0.1.11');
await deleteUninstallRegistrationForInstall(uninstaller, { run });
assert.equal(calls.length, 2);
for (const { command, args, options } of calls) {
assert.equal(command, 'powershell');
assert.equal(options.timeoutMs, 30_000);
const script = args.at(-1);
assert.match(script, /UninstallString/u);
assert.ok(script.includes('[StringComparison]::OrdinalIgnoreCase'));
assert.match(script, /C:\\fixture\\installed\\Uninstall Maka\.exe/u);
assert.doesNotMatch(script, /DisplayName/u);
}
assert.match(calls[0].args.at(-1), /\.DisplayVersion/u);
assert.match(calls[1].args.at(-1), /Remove-Item -LiteralPath \$_\.Path/u);
});
describe('rendererLayoutMatchesViewport', () => {
const viewportLayout = () => ({
devicePixelRatio: 1,
innerWidth: 1920,
innerHeight: 1040,
outerWidth: 1920,
outerHeight: 1040,
documentWidth: 1920,
documentHeight: 1040,
visualViewportWidth: 1920,
visualViewportHeight: 1040,
screenAvailWidth: 1920,
screenAvailHeight: 1040,
html: { x: 0, y: 0, width: 1920, height: 1040 },
body: { x: 0, y: 0, width: 1920, height: 1040 },
root: { x: 0, y: 0, width: 1920, height: 1040 },
appFrame: { x: 0, y: 0, width: 1920, height: 1040 },
});
it('accepts a renderer tree that covers the maximized viewport', () => {
assert.equal(rendererLayoutMatchesViewport(viewportLayout()), true);
});
it('rejects the stale-height band from the maximize regression', () => {
const layout = viewportLayout();
layout.root.height = 820;
layout.appFrame.height = 820;
assert.equal(rendererLayoutMatchesViewport(layout), false);
});
it('matches the renderer viewport to native client pixels at the reported scale', () => {
const layout = viewportLayout();
layout.devicePixelRatio = 1.25;
assert.equal(
rendererViewportMatchesNativeClient(layout, {
clientWidth: 2400,
clientHeight: 1300,
}),
true,
);
});
it('accepts proportional native dimensions independently of reported DPR', () => {
const layout = viewportLayout();
layout.devicePixelRatio = 1.5;
assert.equal(
rendererViewportMatchesNativeClient(layout, {
clientWidth: 1920,
clientHeight: 1040,
}),
true,
);
});
it('rejects a renderer viewport that is stale against the native client', () => {
assert.equal(
rendererViewportMatchesNativeClient(viewportLayout(), {
clientWidth: 1920,
clientHeight: 900,
}),
false,
);
});
});
it('runs packaged sandbox lifecycle evidence against the exact shipped broker', async () => {
const calls = [];
const sandboxExecutable = 'C:\\release\\resources\\windows-sandbox\\maka-windows-sandbox.exe';
await verifyPackagedWindowsSandboxLifecycle(sandboxExecutable, {
run: async (command, args) => {
calls.push({ command, args });
return { stdout: '', stderr: '' };
},
});
assert.deepEqual(
calls.map(({ command, args }) => ({
command,
script: args[2].replaceAll('\\', '/').split('/').at(-1),
launcherFlag: args[3],
launcher: args[4],
})),
[
{
command: 'pwsh',
script: 'appcontainer-smoke.ps1',
launcherFlag: '-LauncherPath',
launcher: sandboxExecutable,
},
{
command: 'pwsh',
script: 'acl-recovery-smoke.ps1',
launcherFlag: '-LauncherPath',
launcher: sandboxExecutable,
},
],
);
});
it('uses the product SemVer contract throughout Windows release verification', () => {
assert.equal(installerVersion('Maka-1.2.3-beta.2-win-x64.exe'), '1.2.3-beta.2');
assert.equal(bumpedAutoupdateVersion('1.2.3-beta.2'), '1.2.3');
assert.equal(bumpedAutoupdateVersion('1.2.3'), '1.2.4');
const baseline = {
version: '1.2.3-beta.1',
tag: 'v1.2.3-beta.1',
assetName: 'Maka-1.2.3-beta.1-win-x64.exe',
sha256: 'a'.repeat(64),
};
assert.equal(validateWindowsUpgradeBaseline(baseline, '1.2.3-beta.2'), baseline);
assert.throws(
() => validateWindowsUpgradeBaseline(baseline, '1.2.3-alpha.1'),
/must be older than the candidate/u,
);
});
it('reuses the packaged renderer smoke without widening rollback verification', async () => {
const calls = [];
const installDirectory = 'C:\\fixture\\installed';
const workingDirectory = 'C:\\fixture\\smoke';
const executable = join(installDirectory, 'Maka.exe');
const run = async (command, args, options) => {
calls.push({ command, args, options });
return { stdout: '1.2.3.0', stderr: '' };
};
const smokeRenderer = async (executable, options) => {
calls.push({ executable, options });
};
await verifyRestoredWindowsInstallation(installDirectory, workingDirectory, '1.2.3', {
run,
smokeRenderer,
});
assert.deepEqual(calls, [
{
executable,
options: {
workingDirectory,
},
},
{
command: 'powershell',
args: [
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-Item '${executable}').VersionInfo.ProductVersion`,
],
options: { timeoutMs: 30_000 },
},
]);
});
async function makeTree(shape) {
const root = await mkdtemp(join(tmpdir(), 'maka-harness-test-'));
temporaryRoots.push(root);
for (const [relative, content] of Object.entries(shape)) {
const absolute = join(root, relative);
if (content === null) {
await mkdir(absolute, { recursive: true });
} else {
await mkdir(join(absolute, '..'), { recursive: true });
await writeFile(absolute, content);
}
}
return root;
}
after(async () => {
for (const root of temporaryRoots) {
await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
describe('directoryTreeManifest', () => {
it('returns sorted POSIX-relative paths with sha256 content hashes', async () => {
const root = await makeTree({
'b.txt': 'bee',
'a/nested.txt': 'nested',
'a/z.txt': 'zed',
});
const manifest = await directoryTreeManifest(root);
assert.deepEqual(
manifest.map((entry) => entry.path),
['a/nested.txt', 'a/z.txt', 'b.txt'],
);
for (const entry of manifest) {
assert.match(entry.sha256, /^[0-9a-f]{64}$/);
}
});
it('records empty directories so their loss is visible', async () => {
const root = await makeTree({ 'a/file.txt': 'x', 'a/empty': null });
const manifest = await directoryTreeManifest(root);
assert.deepEqual(
manifest.map((entry) => entry.path),
['a/empty/', 'a/file.txt'],
);
assert.equal(manifest.find((entry) => entry.path === 'a/empty/').sha256, null);
});
it('hashes content, not names: same names different bytes differ', async () => {
const left = await directoryTreeManifest(await makeTree({ 'f.txt': 'one' }));
const right = await directoryTreeManifest(await makeTree({ 'f.txt': 'two' }));
assert.notEqual(left[0].sha256, right[0].sha256);
});
it('throws on non-regular entries instead of skipping them', async (t) => {
const root = await makeTree({ 'real.txt': 'x' });
try {
await symlink(join(root, 'real.txt'), join(root, 'link.txt'));
} catch (error) {
// Windows without Developer Mode cannot create symlinks; the guard
// itself is platform-neutral readdir dirent logic.
if (error.code === 'EPERM') return t.skip('symlink creation requires privilege here');
throw error;
}
await assert.rejects(
() => directoryTreeManifest(root),
/Unsupported directory entry .*link\.txt/,
);
});
});
describe('diffTreeManifests', () => {
const manifest = (entries) => entries.map(([path, sha256]) => ({ path, sha256 }));
const cases = [
{
name: 'identical manifests diff empty',
before: manifest([
['a.txt', '1'.repeat(64)],
['b/c.txt', '2'.repeat(64)],
]),
after: manifest([
['a.txt', '1'.repeat(64)],
['b/c.txt', '2'.repeat(64)],
]),
expected: { missing: [], extra: [], changed: [] },
},
{
name: 'a lost file is missing',
before: manifest([['a.txt', '1'.repeat(64)]]),
after: manifest([]),
expected: { missing: ['a.txt'], extra: [], changed: [] },
},
{
name: 'a new file is extra',
before: manifest([]),
after: manifest([['n.txt', '3'.repeat(64)]]),
expected: { missing: [], extra: ['n.txt'], changed: [] },
},
{
name: 'different bytes at the same path are changed',
before: manifest([['a.txt', '1'.repeat(64)]]),
after: manifest([['a.txt', '4'.repeat(64)]]),
expected: { missing: [], extra: [], changed: ['a.txt'] },
},
{
name: 'a lost empty directory is missing',
before: manifest([['a/empty/', null]]),
after: manifest([]),
expected: { missing: ['a/empty/'], extra: [], changed: [] },
},
];
for (const { name, before, after: afterManifest, expected } of cases) {
it(name, () => {
assert.deepEqual(diffTreeManifests(before, afterManifest), expected);
});
}
});
describe('runCommand', () => {
it('kills the child and rejects when timeoutMs elapses', async () => {
const root = await makeTree({});
const sentinel = join(root, 'child-survived.txt');
await assert.rejects(
() =>
runCommand(
process.execPath,
[
'-e',
"setTimeout(() => require('node:fs').writeFileSync(process.argv[1], 'leaked'), 600)",
sentinel,
],
{ timeoutMs: 100 },
),
/did not finish within 100ms/,
);
await delay(800);
await assert.rejects(() => access(sentinel), { code: 'ENOENT' });
});
it('resolves stdout for a completing command without a timeout', async () => {
const { stdout } = await runCommand(process.execPath, ['-e', "process.stdout.write('ok')"]);
assert.equal(stdout, 'ok');
});
});
describe('waitForDevToolsPort', () => {
const makeChild = () => {
const child = new EventEmitter();
child.stderr = new PassThrough();
child.exitCode = null;
return child;
};
it('resolves the announced port', async () => {
const child = makeChild();
const wait = waitForDevToolsPort(child, { timeoutMs: 2_000 });
child.stderr.write('DevTools listening on ws://127.0.0.1:54321/devtools/browser/abc\n');
assert.equal(await wait, 54321);
});
it('rejects when the child exits before announcing', async () => {
const child = makeChild();
const wait = waitForDevToolsPort(child, { timeoutMs: 2_000 });
child.exitCode = 1;
child.emit('exit');
await assert.rejects(() => wait, /exited before announcing/);
});
});
describe('waitForUsableRenderer', () => {
it('fails fast when the child is already dead', async () => {
await assert.rejects(
() =>
waitForUsableRenderer(
'ws://127.0.0.1:1/devtools/page/x',
{ exitCode: 1 },
{
deadlineMs: 5_000,
description: 'T',
},
),
/T exited before it became usable/,
);
});
it('retries failed probes and fails only at the deadline', async () => {
const startedAt = Date.now();
await assert.rejects(
() =>
waitForUsableRenderer(
'ws://127.0.0.1:1/devtools/page/x',
{ exitCode: null },
{
deadlineMs: 700,
description: 'T',
},
),
/T did not become usable within 700ms/,
);
const elapsed = Date.now() - startedAt;
assert.ok(elapsed >= 700, `deadline respected: ${elapsed}`);
assert.ok(elapsed < 30_000, `no runaway: ${elapsed}`);
});
it('caps a half-open WebSocket probe to the remaining deadline budget', async () => {
const sockets = new Set();
const server = createServer((socket) => {
sockets.add(socket);
socket.on('close', () => sockets.delete(socket));
});
await new Promise((resolvePromise) => server.listen(0, '127.0.0.1', resolvePromise));
const address = server.address();
assert.ok(address && typeof address !== 'string');
const startedAt = Date.now();
try {
await assert.rejects(
() =>
waitForUsableRenderer(
`ws://127.0.0.1:${address.port}/devtools/page/hung`,
{ exitCode: null },
{ deadlineMs: 200, description: 'T' },
),
/T did not become usable within 200ms/,
);
const elapsed = Date.now() - startedAt;
assert.ok(elapsed < 2_000, `inner probe exceeded the outer deadline budget: ${elapsed}`);
} finally {
for (const socket of sockets) socket.destroy();
await new Promise((resolvePromise) => server.close(resolvePromise));
}
});
});
describe('waitForInstalledProcessesToExit', () => {
it('returns once an enumeration reports no processes', async () => {
const snapshots = [[{ processId: 1, name: 'Maka.exe' }], []];
await waitForInstalledProcessesToExit('C:/nowhere', {
listProcesses: async () => snapshots.shift(),
timeoutMs: 2_000,
pollIntervalMs: 10,
});
});
it('never treats a failed enumeration as empty: rejects at the deadline', async () => {
await assert.rejects(
() =>
waitForInstalledProcessesToExit('C:/nowhere', {
listProcesses: async () => {
throw new Error('wmi stalled');
},
timeoutMs: 200,
pollIntervalMs: 10,
}),
/Could not enumerate installed Maka processes within 200ms: wmi stalled/,
);
});
});
describe('listInstalledProcesses', () => {
it('bounds a WMI round to ten seconds so polling can retry', async () => {
let observedTimeout;
const processes = await listInstalledProcesses('C:/nowhere', {
run: async (_command, _args, options) => {
observedTimeout = options.timeoutMs;
return { stdout: '', stderr: '' };
},
});
assert.deepEqual(processes, []);
assert.equal(observedTimeout, 10_000);
});
});
describe('waitForInstalledProcessAppearance', () => {
it('survives a failed probe and returns the later successful match', async () => {
// The regression run 32340493254 rejected on the first wedged
// enumeration; the contract is failure-then-success within the deadline.
const probes = [
() => Promise.reject(new Error('wmi stalled')),
() =>
Promise.resolve([
{ processId: 7, name: 'Maka.exe', path: 'C:/nowhere/installed/Maka.exe' },
]),
];
const matched = await waitForInstalledProcessAppearance('C:/nowhere/installed', 'Maka.exe', {
listProcesses: () => probes.shift()(),
timeoutMs: 2_000,
pollIntervalMs: 10,
});
assert.deepEqual(
matched.map((processInfo) => processInfo.processId),
[7],
);
});
it('matches by executable basename, case-insensitively', async () => {
const matched = await waitForInstalledProcessAppearance('C:/nowhere/installed', 'Maka.exe', {
listProcesses: async () => [
{ processId: 1, name: 'other.exe', path: 'C:/nowhere/installed/other.exe' },
{ processId: 2, name: 'MAKA.EXE', path: 'C:/nowhere/installed/MAKA.EXE' },
],
timeoutMs: 2_000,
pollIntervalMs: 10,
});
assert.deepEqual(
matched.map((processInfo) => processInfo.processId),
[2],
);
});
it('reports the last probe error only when no later enumeration succeeds', async () => {
await assert.rejects(
() =>
waitForInstalledProcessAppearance('C:/nowhere/installed', 'Maka.exe', {
listProcesses: async () => {
throw new Error('wmi stalled');
},
timeoutMs: 150,
pollIntervalMs: 10,
}),
/did not appear among installed processes within 150ms\.\nlast probe error: wmi stalled/,
);
});
});
describe('waitForInstalledProductVersion', () => {
it('retries a stalled PowerShell read within the outer deadline', async () => {
const observedTimeouts = [];
const results = [new Error('powershell stalled'), { stdout: '0.1.12', stderr: '' }];
const version = await waitForInstalledProductVersion('C:/installed/Maka.exe', {
run: async (_command, _args, options) => {
observedTimeouts.push(options.timeoutMs);
const result = results.shift();
if (result instanceof Error) throw result;
return result;
},
timeoutMs: 60_000,
pollIntervalMs: 1,
});
assert.equal(version, '0.1.12');
assert.equal(observedTimeouts.length, 2);
assert.ok(observedTimeouts.every((timeout) => timeout > 0 && timeout <= 10_000));
});
});
describe('terminateInstalledProcesses', () => {
const process7 = { processId: 7, name: 'Maka.exe', path: 'C:/nowhere/installed/Maka.exe' };
it('tolerates exit 128 and an overrun kill, then proves exit', async () => {
const events = [];
const failures = [
new Error('taskkill /PID 7 /T /F failed with exit code 128'),
new Error('taskkill /PID 8 /T /F did not finish within 30000ms'),
];
await terminateInstalledProcesses('C:/nowhere/installed', {
listProcesses: async () => [process7, { ...process7, processId: 8 }],
run: async (command, args) => {
events.push(`${command}:${args[1]}`);
throw failures.shift();
},
waitForExit: async () => {
events.push('wait');
},
});
// Both mechanism failures were tolerated and the authoritative exit
// wait still ran.
assert.deepEqual(events, ['taskkill:7', 'taskkill:8', 'wait']);
});
it('lets the exit proof decide after any taskkill mechanism failure', async () => {
let exitProofRan = false;
await terminateInstalledProcesses('C:/nowhere/installed', {
listProcesses: async () => [process7],
run: async () => {
throw new Error('taskkill /PID 7 /T /F failed with exit code 255');
},
waitForExit: async () => {
exitProofRan = true;
},
});
assert.equal(exitProofRan, true);
});
it('reports the exit proof and taskkill failures together when residue remains', async () => {
await assert.rejects(
() =>
terminateInstalledProcesses('C:/nowhere/installed', {
listProcesses: async () => [process7],
run: async () => {
throw new Error('taskkill failed with exit code 255');
},
waitForExit: async () => {
throw new Error('Maka.exe (7) is still running');
},
}),
(error) => {
assert.ok(error instanceof AggregateError);
assert.match(error.message, /did not exit after taskkill failures/);
assert.deepEqual(
error.errors.map((failure) => failure.message),
['Maka.exe (7) is still running', 'taskkill failed with exit code 255'],
);
return true;
},
);
});
it('retries a transient enumeration failure before killing and proving exit', async () => {
const events = [];
let attempts = 0;
await terminateInstalledProcesses('C:/nowhere/installed', {
listProcesses: async () => {
attempts += 1;
if (attempts === 1) throw new Error('wmi stalled');
return [process7];
},
run: async () => {
events.push('kill');
return { stdout: '', stderr: '' };
},
waitForExit: async () => events.push('wait'),
pollIntervalMs: 1,
});
assert.equal(attempts, 2);
assert.deepEqual(events, ['kill', 'wait']);
});
});
describe('completeInstalledApplicationUninstall', () => {
it('runs the uninstaller and waits for both cleanup barriers in order', async () => {
const events = [];
await completeInstalledApplicationUninstall('C:/installed', 'C:/installed/uninstall.exe', {
requirePath: async () => events.push('access'),
run: async () => {
events.push('uninstall');
return { stdout: '', stderr: '' };
},
waitForMissing: async () => events.push('files-missing'),
waitForRegistration: async () => events.push('registration-clear'),
});
assert.deepEqual(events, ['access', 'uninstall', 'files-missing', 'registration-clear']);
});
it('still proves both barriers when a detached uninstaller is already gone', async () => {
const events = [];
await completeInstalledApplicationUninstall('C:/installed', 'C:/installed/uninstall.exe', {
requirePath: async () => {
const error = new Error('missing');
error.code = 'ENOENT';
throw error;
},
run: async () => assert.fail('a missing uninstaller must not be launched'),
waitForMissing: async () => events.push('files-missing'),
waitForRegistration: async () => events.push('registration-clear'),
});
assert.deepEqual(events, ['files-missing', 'registration-clear']);
});
});
describe('waitForUninstallRegistrationToClear', () => {
const runReturning = (outputs) => async () => ({ stdout: outputs.shift(), stderr: '' });
it('returns once the registration reads empty', async () => {
await waitForUninstallRegistrationToClear({
run: runReturning(['0.1.11\n', '\n']),
timeoutMs: 2_000,
pollIntervalMs: 10,
});
});
it('reports the lingering registration at the deadline', async () => {
await assert.rejects(
() =>
waitForUninstallRegistrationToClear({
run: async () => ({ stdout: '0.1.11\n', stderr: '' }),
timeoutMs: 150,
pollIntervalMs: 10,
}),
/uninstall registration \(DisplayVersion "0\.1\.11"\) is still present after 150ms/,
);
});
it('tolerates probe failures but rejects with them at the deadline', async () => {
await assert.rejects(
() =>
waitForUninstallRegistrationToClear({
run: async () => {
throw new Error('registry stalled');
},
timeoutMs: 150,
pollIntervalMs: 10,
}),
/Could not read the Maka uninstall registration within 150ms: registry stalled/,
);
});
});