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
823 lines
27 KiB
JavaScript
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/,
|
|
);
|
|
});
|
|
});
|