From 86110e821c3ee97272c1c1476a1d748a4d48a527 Mon Sep 17 00:00:00 2001 From: ethernet Date: Sun, 6 Sep 2026 21:15:16 -0400 Subject: [PATCH] fix(install-e2e): wait for Electron close before driver exit --- tests-js/install-process-close.test.ts | 33 ++++++++++++++++++++++ tests/install/e2e-assets/drive-update.cjs | 12 ++++++-- tests/install/e2e-assets/process-close.cjs | 25 ++++++++++++++++ tests/install/windows-e2e.ps1 | 1 + 4 files changed, 69 insertions(+), 2 deletions(-) create mode 100644 tests-js/install-process-close.test.ts create mode 100644 tests/install/e2e-assets/process-close.cjs diff --git a/tests-js/install-process-close.test.ts b/tests-js/install-process-close.test.ts new file mode 100644 index 0000000000..2134b810b8 --- /dev/null +++ b/tests-js/install-process-close.test.ts @@ -0,0 +1,33 @@ +import { EventEmitter } from 'node:events' +import { createRequire } from 'node:module' + +import { expect, it, vi } from 'vitest' + +const { observeProcessClose } = createRequire(import.meta.url)('../tests/install/e2e-assets/process-close.cjs') + +it('waits for native close, not exit, and retains a close observed before hand-off', async () => { + const child = new EventEmitter() + const waitForClose = observeProcessClose(child) + let finished = false + const completion = waitForClose().then(() => { finished = true }) + child.emit('exit', 0) + await Promise.resolve() + expect(finished).toBe(false) + child.emit('close', 0) + await completion + expect(finished).toBe(true) + await expect(waitForClose()).resolves.toBeUndefined() +}) + +it('fails if the launched process never closes', async () => { + vi.useFakeTimers() + try { + const waitForClose = observeProcessClose(new EventEmitter()) + const completion = expect(waitForClose(2_000)).rejects.toThrow('Electron process did not close') + await vi.advanceTimersByTimeAsync(2_000) + await completion + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } +}) diff --git a/tests/install/e2e-assets/drive-update.cjs b/tests/install/e2e-assets/drive-update.cjs index 1473d01ae2..86d6969636 100644 --- a/tests/install/e2e-assets/drive-update.cjs +++ b/tests/install/e2e-assets/drive-update.cjs @@ -21,6 +21,7 @@ const fs = require('node:fs') const { _electron } = require('@playwright/test') const { prepareWindowForInput } = require('./window-input.cjs') +const { observeProcessClose } = require('./process-close.cjs') const exePath = process.argv[2] const proofDir = process.argv[3] @@ -93,6 +94,9 @@ async function main() { env: { ...process.env }, timeout: 120_000 }) + const child = app.process() + const waitForProcessClose = observeProcessClose(child) + log(`launched Electron pid=${child.pid}`) // firstWindow() can grab a helper webContents (wake indicator etc.), not // the main app window. Pick the window that actually renders UI (has a @@ -255,7 +259,8 @@ async function main() { // The "Updating Hermes — this window will close" overlay should appear, // then the app quits (hand-off dwell). Screenshot the overlay while the // window is still alive. - await page.waitForTimeout(1200) + // The app can close during the dwell. This wait must outlive its page. + await new Promise(resolve => setTimeout(resolve, 1200)) await shot(page, '05-updating-overlay') // ── Wait for the hand-off to take over ──────────────────────────────── @@ -315,7 +320,10 @@ async function main() { throw new Error('no hand-off within 150s of Update now (no marker, no result, app still alive)') } - log('hand-off confirmed — detached updater owns the rest') + // A marker appears before Electron exits. Exiting this driver at that point + // lets Playwright taskkill the entire tree, including the detached updater. + await waitForProcessClose() + log('Electron process closed — detached updater owns the rest') } main() diff --git a/tests/install/e2e-assets/process-close.cjs b/tests/install/e2e-assets/process-close.cjs new file mode 100644 index 0000000000..6ee30767c7 --- /dev/null +++ b/tests/install/e2e-assets/process-close.cjs @@ -0,0 +1,25 @@ +// Observe at launch: a renderer can close before the native process and its +// stdio pipes. Playwright's driver exit cleanup tree-kills until that close. +function observeProcessClose(child) { + let closed = false + const completion = new Promise(resolve => child.once('close', () => { + closed = true + resolve() + })) + return async function waitForClose(timeoutMs = 120_000) { + if (closed) return + let timer + try { + await Promise.race([ + completion, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('Electron process did not close after update hand-off')), timeoutMs) + }), + ]) + } finally { + clearTimeout(timer) + } + } +} + +module.exports = { observeProcessClose } diff --git a/tests/install/windows-e2e.ps1 b/tests/install/windows-e2e.ps1 index 677554a0ca..fd142d4b1c 100644 --- a/tests/install/windows-e2e.ps1 +++ b/tests/install/windows-e2e.ps1 @@ -763,6 +763,7 @@ function Invoke-GuiUpdateDesktopRoute([string]$TargetSha) { $driver = Join-Path $driverDir "e2e-drive-update.cjs" Copy-Item (Join-Path $AssetsDir "drive-update.cjs") $driver -Force Copy-Item (Join-Path $AssetsDir "window-input.cjs") (Join-Path $driverDir "window-input.cjs") -Force + Copy-Item (Join-Path $AssetsDir "process-close.cjs") (Join-Path $driverDir "process-close.cjs") -Force Push-Location $driverDir $prevEap = $ErrorActionPreference $ErrorActionPreference = "Continue"