From 5c1ddf26ca2313d327277b5a15ce14ac1c06124a Mon Sep 17 00:00:00 2001 From: Sucukdeluxe <259325684+Sucukdeluxe@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:30:30 +0200 Subject: [PATCH] fix(ci): wait for asynchronous NSIS uninstaller surface cleanup 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. --- scripts/smoke-test-installer.js | 18 ++++++++++++-- scripts/smoke-test-installer.test.js | 36 ++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 2 deletions(-) diff --git a/scripts/smoke-test-installer.js b/scripts/smoke-test-installer.js index 2679682..fdcd337 100644 --- a/scripts/smoke-test-installer.js +++ b/scripts/smoke-test-installer.js @@ -223,6 +223,19 @@ function verifyInstalledPhase(phase) { 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) { const deadline = Date.now() + timeoutMs; while (fs.existsSync(targetPath)) { @@ -274,7 +287,7 @@ async function main() { if (!await waitForPathRemoval(phase.installationDirectory, 30000)) { throw new Error(`Silent uninstall left the installation directory behind: ${phase.installationDirectory}`); } - assertCleanInstallerSmokeSurface(phases); + await waitForInstallerSurfaceClean(phases, { keyExists: registryKeyExists }); results.push({ flag: phase.flag, hive: phase.hive, @@ -314,5 +327,6 @@ module.exports = { assertShortcutDetails, createInstallerPhases, readShortcutDetails, - registryKeys + registryKeys, + waitForInstallerSurfaceClean }; diff --git a/scripts/smoke-test-installer.test.js b/scripts/smoke-test-installer.test.js index d59ad38..278cdb5 100644 --- a/scripts/smoke-test-installer.test.js +++ b/scripts/smoke-test-installer.test.js @@ -11,6 +11,7 @@ const { assertInstallerSurfaceClean, assertPathInside, assertShortcutDetails, + waitForInstallerSurfaceClean, createInstallerPhases, readShortcutDetails, registryKeys @@ -222,6 +223,41 @@ test('clean surface contract includes both registry hives and both shortcut scop }), /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', () => { 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);