fix(ci): wait for asynchronous NSIS uninstaller surface cleanup
Windows CI / verify (push) Failing after 3m46s

The installer smoke asserted a clean registry and shortcut surface
immediately after the silent uninstall returned and the installation
directory disappeared. NSIS uninstallers run detached through a
temporary copy, so shortcut and registry removal completes after the
invoked process exits, and on current runner images the immediate check
raced that cleanup and failed with leftover HKCU keys and shortcuts.
The smoke now polls the exact same surface assertions until a bounded
deadline, keeping a genuinely dirty uninstall failing, with contract
coverage for the tolerated asynchronous cleanup and the persistent
failure case.
This commit is contained in:
Sucukdeluxe
2026-08-14 15:30:30 +02:00
parent 8a139f851b
commit 5c1ddf26ca
2 changed files with 52 additions and 2 deletions
+16 -2
View File
@@ -223,6 +223,19 @@ function verifyInstalledPhase(phase) {
return uninstallerPath; return uninstallerPath;
} }
async function waitForInstallerSurfaceClean(phases, { keyExists, pathExists = fs.existsSync, timeoutMs = 30000, pollIntervalMs = 250 }) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
assertInstallerSurfaceClean(phases, { keyExists, pathExists });
return;
} catch (error) {
if (Date.now() >= deadline) throw error;
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
}
}
async function waitForPathRemoval(targetPath, timeoutMs = 10000) { async function waitForPathRemoval(targetPath, timeoutMs = 10000) {
const deadline = Date.now() + timeoutMs; const deadline = Date.now() + timeoutMs;
while (fs.existsSync(targetPath)) { while (fs.existsSync(targetPath)) {
@@ -274,7 +287,7 @@ async function main() {
if (!await waitForPathRemoval(phase.installationDirectory, 30000)) { if (!await waitForPathRemoval(phase.installationDirectory, 30000)) {
throw new Error(`Silent uninstall left the installation directory behind: ${phase.installationDirectory}`); throw new Error(`Silent uninstall left the installation directory behind: ${phase.installationDirectory}`);
} }
assertCleanInstallerSmokeSurface(phases); await waitForInstallerSurfaceClean(phases, { keyExists: registryKeyExists });
results.push({ results.push({
flag: phase.flag, flag: phase.flag,
hive: phase.hive, hive: phase.hive,
@@ -314,5 +327,6 @@ module.exports = {
assertShortcutDetails, assertShortcutDetails,
createInstallerPhases, createInstallerPhases,
readShortcutDetails, readShortcutDetails,
registryKeys registryKeys,
waitForInstallerSurfaceClean
}; };
+36
View File
@@ -11,6 +11,7 @@ const {
assertInstallerSurfaceClean, assertInstallerSurfaceClean,
assertPathInside, assertPathInside,
assertShortcutDetails, assertShortcutDetails,
waitForInstallerSurfaceClean,
createInstallerPhases, createInstallerPhases,
readShortcutDetails, readShortcutDetails,
registryKeys registryKeys
@@ -222,6 +223,41 @@ test('clean surface contract includes both registry hives and both shortcut scop
}), /HKLM.*Twitch VOD Manager\.lnk/i); }), /HKLM.*Twitch VOD Manager\.lnk/i);
}); });
test('surface wait tolerates asynchronous uninstaller cleanup before the deadline', async () => {
const phases = createInstallerPhases('C:\\smoke', {
commonDesktop: 'C:\\shared-desktop',
commonPrograms: 'C:\\shared-programs',
currentDesktop: 'C:\\user-desktop',
currentPrograms: 'C:\\user-programs'
});
let sweeps = 0;
await waitForInstallerSurfaceClean(phases, {
keyExists: (key) => {
if (key === registryKeys('HKCU').uninstall) sweeps += 1;
return sweeps < 3;
},
pathExists: () => sweeps < 3,
timeoutMs: 5000,
pollIntervalMs: 5
});
assert.ok(sweeps >= 3);
});
test('surface wait still fails when the uninstaller never cleans up', async () => {
const phases = createInstallerPhases('C:\\smoke', {
commonDesktop: 'C:\\shared-desktop',
commonPrograms: 'C:\\shared-programs',
currentDesktop: 'C:\\user-desktop',
currentPrograms: 'C:\\user-programs'
});
await assert.rejects(() => waitForInstallerSurfaceClean(phases, {
keyExists: () => true,
pathExists: () => false,
timeoutMs: 60,
pollIntervalMs: 5
}), /clean registry and shortcut surface/);
});
test('recursive cleanup is limited to the dedicated runner temp directory', () => { test('recursive cleanup is limited to the dedicated runner temp directory', () => {
assert.doesNotThrow(() => assertPathInside('C:\\runner-temp\\tvm-installer-123', 'C:\\runner-temp')); assert.doesNotThrow(() => assertPathInside('C:\\runner-temp\\tvm-installer-123', 'C:\\runner-temp'));
assert.throws(() => assertPathInside('C:\\runner-temp', 'C:\\runner-temp'), /outside its parent/i); assert.throws(() => assertPathInside('C:\\runner-temp', 'C:\\runner-temp'), /outside its parent/i);