diff --git a/.github/workflows/ci-real.yml b/.github/workflows/ci-real.yml new file mode 100644 index 00000000..df168295 --- /dev/null +++ b/.github/workflows/ci-real.yml @@ -0,0 +1,61 @@ +name: CI (real lifecycle) + +# Real-user lifecycle suite. Runs on a single OS (windows) because each +# test downloads ~500 MB of standalone payload and the wall-clock for +# the full chain is ~45-60 min. Scheduled nightly + manual dispatch. +# +# See e2e/AGENTS.md for the @real contract. +on: + workflow_dispatch: + schedule: + # 09:00 UTC daily (02:00 PT) — after the typical US workday wraps, + # so a failure surfaces by morning standup. + - cron: '0 9 * * *' + +permissions: + contents: read + +concurrency: + group: ci-real-${{ github.ref }} + cancel-in-progress: true + +jobs: + real: + name: Real lifecycle (windows) + runs-on: windows-latest + timeout-minutes: 90 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build app + run: pnpm run build + + - name: Run real lifecycle suite + run: pnpm run test:e2e:real + + - name: Upload Playwright failure artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-real-failure + path: | + test-results/ + playwright-report/ + retention-days: 14 + if-no-files-found: ignore diff --git a/e2e/AGENTS.md b/e2e/AGENTS.md new file mode 100644 index 00000000..98a263e3 --- /dev/null +++ b/e2e/AGENTS.md @@ -0,0 +1,106 @@ +# e2e — test tier contract + +Every Playwright test in this directory MUST carry exactly one tier tag. +No exceptions, no untagged tests. + +## Tier tags + +### `@ci` — every-PR matrix + +Runs on Windows + macOS + Linux on every PR. Allowed to: + +- Seed installations via `launchApp({ installations, settings, ... })`. +- Stage fixtures on disk (`writeFileSync`, `mkdirSync`, `execFileSync git`, etc.). +- Use the `__e2e` backdoor (`seedDownloads`, `setInstallUpdate`, `setAppUpdateState`, + `seedRunningSession`, `ageReleaseCache`, IPC invocation spies, …). +- Invoke `window.api.*` directly via the eval bridge. +- Send synthetic IPC into the renderer (`wc.send(...)`). + +In short: `@ci` is the seeded-harness tier. Fast, loud, deterministic, +covers renderer + IPC contracts. + +### `@real` — real lifecycle + +Run only via `pnpm run test:e2e:real` (single project, single OS, nightly / +manual). The contract: + +> A `@real` test must behave as if a human had booted the app and clicked +> through the UI. Nothing outside the start or end of the test may change +> state except via DOM clicks and keyboard input dispatched through the +> visible webContents. + +Concretely, **forbidden** in `@real`: + +- Passing `installations`, `settings`, or any other seed to `launchApp(...)`. +- Calls into `__e2e.*` that **mutate** state (`seedDownloads`, + `setInstallUpdate`, `setAppUpdateState`, `seedRunningSession`, + `ageReleaseCache`, …). +- Calls into `window.api.*` from test code (use button clicks / typing). +- Sending synthetic IPC via `webContents.send(...)`. +- Pre-staging fake install directories with `writeFile` / `mkdir` to skip + a real install. + +**Allowed** in `@real` (read-only observation only): + +- `__e2e.getIpcInvocations`, `__e2e.getRunningSessionSnapshot`, + `__e2e.getReleaseCacheCheckedAt` — observing live state never affects it. +- `app.evaluate(({ BrowserWindow }) => ...)` for inspecting window / + webContents state. +- `execFileSync('git', ['rev-parse', 'HEAD'], …)` for asserting against + on-disk state the app actually produced. +- DOM-driven flows through `panel.click(...)`, `panel.fill(...)`, + `panel.pressKey(...)`. + +Prerequisite paths (consent → pick local → install → launch, etc.) that a +test isn't itself asserting belong in `e2e/support/realPrereqs.ts`. Drive +them with real clicks; never short-circuit with `window.api`. + +## OS axis (independent of tier) + +`@ci` tests default to running on **all three** OSes. Genuinely +platform-specific behavior gets an opt-out tag: + +| Tag | Effect | +|---|---| +| `@windows-only` | excluded from `macos` + `linux` projects | +| `@macos-only` | excluded from `windows` + `linux` projects | +| `@linux-only` | excluded from `windows` + `macos` projects | + +Example: `quit-flow.spec.ts` is `@ci @macos-only` because tray-close mode +only exists on macOS. + +`@real` runs on a single OS by configuration; OS opt-out tags don't apply. + +## Playwright projects + +```ts +{ name: 'macos', grep: /@ci/, grepInvert: /@(windows|linux)-only/ } +{ name: 'windows', grep: /@ci/, grepInvert: /@(macos|linux)-only/ } +{ name: 'linux', grep: /@ci/, grepInvert: /@(macos|windows)-only/ } +{ name: 'real', grep: /@real/, timeout: 600_000 } +``` + +## Commands + +| Command | Runs | +|---|---| +| `pnpm run test:e2e:windows` | `@ci` minus `@macos-only` / `@linux-only` | +| `pnpm run test:e2e:macos` | `@ci` minus `@windows-only` / `@linux-only` | +| `pnpm run test:e2e:linux` | `@ci` minus `@macos-only` / `@windows-only` | +| `pnpm run test:e2e:real` | `@real` only (real lifecycle suite) | + +When the user says "run the lifecycle tests" or "validate the lifecycle +tests", they mean `pnpm run test:e2e:real`. That is the only suite that +proves the app still works for a real user. + +## Skipping a broken test + +Don't silently delete a failing test. Choose one: + +- `test.skip(...)` with a `// TODO(#NNN): …` comment naming the issue. +- `test.fail(...)` when the test correctly asserts a contract the product + is currently violating (known product bug). `test.fail` flips green + when the product is fixed, alerting the team to remove the marker. + +Every skip / fail must link to an issue. Stale skips rot — they're +not "passing" tests. diff --git a/e2e/chooser.test.ts b/e2e/chooser.test.ts index 5139c4a8..386c0829 100644 --- a/e2e/chooser.test.ts +++ b/e2e/chooser.test.ts @@ -39,17 +39,17 @@ test.afterAll(async () => { await ctx.cleanup() }) -test('chooser body renders on cold start @windows @macos @linux', async () => { +test('chooser body renders on cold start @ci', async () => { await expectChooserVisible(ctx.panel) expect(await ctx.panel.exists('.chooser-tile-new')).toBe(true) }) -test('title bar shows install-less pill on chooser host @windows @macos @linux', async () => { +test('title bar shows install-less pill on chooser host @ci', async () => { expect(await ctx.titleBar.exists('.title-install-pill.is-install-less')).toBe(true) expect(await ctx.titleBar.textOf('.title-install-name')).toMatch(/Desktop 2\.0/i) }) -test('clicking New Install tile opens the new-install takeover @windows @macos @linux', async () => { +test('clicking New Install tile opens the new-install takeover @ci', async () => { await clickNewInstallTile(ctx.panel) await expectTakeoverOpen(ctx.panel) await dismissOverlay(ctx.panel) @@ -64,7 +64,7 @@ test('clicking New Install tile opens the new-install takeover @windows @macos @ // after the dedup path runs. // --------------------------------------------------------------------------- -test('activate hook focuses the existing chooser host instead of spawning a duplicate @windows @macos @linux', async () => { +test('activate hook focuses the existing chooser host instead of spawning a duplicate @ci', async () => { // Baseline: exactly one host BrowserWindow is open from `beforeAll`. const before = await ctx.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()).length, @@ -92,14 +92,14 @@ test('activate hook focuses the existing chooser host instead of spawning a dupl // preserve. // --------------------------------------------------------------------------- -test('title popup + system modal webContents are pre-warmed on the chooser host @windows @macos @linux', async () => { +test('title popup + system modal webContents are pre-warmed on the chooser host @ci', async () => { // Both popups are pre-warmed in `comfy-window:title-bar-ready` so the // first user trigger doesn't pay the load cost. await waitForWebContents(ctx.app, 'comfyTitlePopup.html', 10_000) await waitForWebContents(ctx.app, 'comfySystemModal.html', 10_000) }) -test('title popup opens, renders menu items, and closes via bridge @windows @macos @linux', async () => { +test('title popup opens, renders menu items, and closes via bridge @ci', async () => { // Click the waffle menu — main pushes a config to the cached title popup // and flips it visible. We assert the popup is no longer marked hidden // by the EmbeddedPopupView contract (the WebContentsView's bounds become @@ -128,7 +128,7 @@ test('title popup opens, renders menu items, and closes via bridge @windows @mac ).toBe(false) }) -test('title popup reopens after a blur dismiss (menu-closed IPC clears the reopen guard) @windows @macos @linux', async () => { +test('title popup reopens after a blur dismiss (menu-closed IPC clears the reopen guard) @ci', async () => { // The previous test dismissed the popup via the close bridge — that // stamps the title-bar's `menuClosedAt.menu` so a click within 100ms // is suppressed by the time-based reopen guard. Wait past that @@ -173,7 +173,7 @@ test('title popup reopens after a blur dismiss (menu-closed IPC clears the reope ).toBe(true) }) -test('title-bar tooltip popup is created on demand and hides cleanly @windows @macos @linux', async () => { +test('title-bar tooltip popup is created on demand and hides cleanly @ci', async () => { // No webContents for the tooltip popup exists before the first show — // unlike titlePopup / systemModal, the tooltip is NOT pre-warmed. expect(await findWebContentsId(ctx.app, 'comfyTitleTooltip.html')).toBeNull() diff --git a/e2e/copy-update-destination.test.ts b/e2e/copy-update-destination.test.ts index 8c2a411f..c11348a3 100644 --- a/e2e/copy-update-destination.test.ts +++ b/e2e/copy-update-destination.test.ts @@ -68,7 +68,7 @@ test.beforeEach(async () => { await resetIpcInvocations(ctx.app, 'open-install-window') }) -test('Copy success opens the destination install in a new window @lifecycle', async () => { +test('Copy success opens the destination install in a new window @ci', async () => { // Drive ProgressModal with a synthetic copy result carrying the // destination install id. The renderer's `handleDone` consumes // `op.result.newInstallationId` and calls `openInstallWindow`. @@ -95,7 +95,7 @@ test('Copy success opens the destination install in a new window @lifecycle', as expect(calls[0]?.installationId).toBe(DEST_ID) }) -test('No newInstallationId → no open-install-window call @lifecycle', async () => { +test('No newInstallationId → no open-install-window call @ci', async () => { // Same shape but no newInstallationId — should NOT trigger // openInstallWindow. Guards the conditional branch in handleDone // against accidentally firing for non-copy success paths. diff --git a/e2e/dashboard-delete-flow.test.ts b/e2e/dashboard-delete-flow.test.ts index 65a86ff1..7cf04c82 100644 --- a/e2e/dashboard-delete-flow.test.ts +++ b/e2e/dashboard-delete-flow.test.ts @@ -73,12 +73,12 @@ test.afterAll(async () => { if (installPath) await rm(installPath, { recursive: true, force: true }) }) -test('chooser shows the seeded tile @lifecycle', async () => { +test('chooser shows the seeded tile @ci', async () => { await ctx.panel.waitForSelector(byTestId(TID.dashboardTile(INSTALL_ID)), { timeout: 10_000 }) expect(await ctx.panel.textOf(`${byTestId(TID.dashboardTile(INSTALL_ID))} .chooser-tile-name`)).toBe(INSTALL_NAME) }) -test('Delete from kebab opens confirm without invoking get-detail-sections @lifecycle', async () => { +test('Delete from kebab opens confirm without invoking get-detail-sections @ci', async () => { // Clear any cumulative invocation history so the assertion measures // only what fires during the Delete dispatch itself. await resetIpcInvocations(ctx.app, 'get-detail-sections') @@ -104,7 +104,7 @@ test('Delete from kebab opens confirm without invoking get-detail-sections @life expect(invocations, 'Delete dispatch must not call get-detail-sections').toEqual([]) }) -test('Confirm removes the install directory and tile @lifecycle', async () => { +test('Confirm removes the install directory and tile @ci', async () => { // Confirm the delete via the BaseAlert primary button. const confirmClicked = await ctx.panel.click(byTestId(TID.baseAlertAction)) expect(confirmClicked, 'confirm button click dispatched').toBe(true) diff --git a/e2e/devhooks-smoke.test.ts b/e2e/devhooks-smoke.test.ts index 058e1864..9715ba65 100644 --- a/e2e/devhooks-smoke.test.ts +++ b/e2e/devhooks-smoke.test.ts @@ -30,7 +30,7 @@ test.afterAll(async () => { await ctx.cleanup() }) -test('dev hooks bridge: seedDownloads, setInstallUpdate, setAppUpdateState all run @windows @macos @linux', async () => { +test('dev hooks bridge: seedDownloads, setInstallUpdate, setAppUpdateState all run @ci', async () => { await seedDownloads(ctx.app, { active: [ { @@ -57,7 +57,7 @@ test('dev hooks bridge: seedDownloads, setInstallUpdate, setAppUpdateState all r await setAppUpdateState(ctx.app, { kind: null, version: null, autoUpdate: true }) }) -test('dev hooks bridge: getTitlePopupBounds returns null when no popup is open @windows @macos @linux', async () => { +test('dev hooks bridge: getTitlePopupBounds returns null when no popup is open @ci', async () => { const bounds = await getTitlePopupBounds(ctx.app) expect(bounds).toBeNull() }) diff --git a/e2e/downloads-shelf.test.ts b/e2e/downloads-shelf.test.ts index 381a2817..e5522efa 100644 --- a/e2e/downloads-shelf.test.ts +++ b/e2e/downloads-shelf.test.ts @@ -59,7 +59,7 @@ test.beforeEach(async () => { // flex-allocated-height regression without manual smoke testing. // --------------------------------------------------------------------------- -test('empty drawer fits content (popup height < 260px) @windows @macos @linux', async () => { +test('empty drawer fits content (popup height < 260px) @ci', async () => { await openDownloadsTray(ctx.titleBar) await waitForPopupVisible(ctx.app) await waitForStableBounds(ctx.app) @@ -74,7 +74,7 @@ test('empty drawer fits content (popup height < 260px) @windows @macos @linux', expect(bounds!.bounds.height).toBeGreaterThan(40) }) -test('one downloading entry fits content (NOT clipped at 396 ceiling) @windows @macos @linux', async () => { +test('one downloading entry fits content (NOT clipped at 396 ceiling) @ci', async () => { await seedDownloads(ctx.app, { active: [makeEntry({ url: 'https://example.test/m1.safetensors', filename: 'm1.safetensors', status: 'downloading', progress: 0.5 })], recent: [], @@ -91,7 +91,7 @@ test('one downloading entry fits content (NOT clipped at 396 ceiling) @windows @ expect(bounds!.bounds.height).toBeLessThan(260) }) -test('many entries cap at the ceiling and the list scrolls @windows @macos @linux', async () => { +test('many entries cap at the ceiling and the list scrolls @ci', async () => { const active: DownloadProgressLike[] = [] for (let i = 0; i < 12; i++) { active.push( @@ -130,7 +130,7 @@ test('many entries cap at the ceiling and the list scrolls @windows @macos @linu // active rows, remove for terminal rows). // --------------------------------------------------------------------------- -test('per-status row close button maps to the right action @windows @macos @linux', async () => { +test('per-status row close button maps to the right action @ci', async () => { await seedDownloads(ctx.app, { active: [ makeEntry({ url: 'u-dl', filename: 'dl.safetensors', status: 'downloading', progress: 0.5 }), @@ -180,7 +180,7 @@ test('per-status row close button maps to the right action @windows @macos @linu // owes the user when a download starts mid-shelf-open). // --------------------------------------------------------------------------- -test('the open popup repaints live when tray state changes @windows @macos @linux', async () => { +test('the open popup repaints live when tray state changes @ci', async () => { await openDownloadsTray(ctx.titleBar) await waitForPopupVisible(ctx.app) await waitForStableBounds(ctx.app) diff --git a/e2e/dropdowns.test.ts b/e2e/dropdowns.test.ts index 3d586b6c..c25d8d37 100644 --- a/e2e/dropdowns.test.ts +++ b/e2e/dropdowns.test.ts @@ -47,7 +47,7 @@ test.beforeEach(async () => { // `Reset Zoom` menu-item gating. // --------------------------------------------------------------------------- -test('Reset Zoom menu item is absent at zoom level 0 @windows @macos @linux', async () => { +test('Reset Zoom menu item is absent at zoom level 0 @ci', async () => { await setComfyViewZoomLevel(ctx.app, 0) await openTitleMenu(ctx.titleBar) await popup.waitForSelector('[role="menuitem"]', { timeout: 5_000 }) @@ -56,7 +56,7 @@ test('Reset Zoom menu item is absent at zoom level 0 @windows @macos @linux', as expect(labels.some((l) => /reset zoom/i.test(l))).toBe(false) }) -test('Reset Zoom menu item appears with the current percent label when zoom is non-zero @windows @macos @linux', async () => { +test('Reset Zoom menu item appears with the current percent label when zoom is non-zero @ci', async () => { await setComfyViewZoomLevel(ctx.app, 1) await openTitleMenu(ctx.titleBar) await popup.waitForSelector('[role="menuitem"]', { timeout: 5_000 }) @@ -77,7 +77,7 @@ test('Reset Zoom menu item appears with the current percent label when zoom is n // would leak event handlers (and `tray-state-changed` callbacks etc). // --------------------------------------------------------------------------- -test('title-popup webContents listener counts are stable across repeated opens @windows @macos @linux', async () => { +test('title-popup webContents listener counts are stable across repeated opens @ci', async () => { // Prime the popup once so the renderer has loaded and any first-run // wiring is in place. await openTitleMenu(ctx.titleBar) @@ -107,7 +107,7 @@ test('title-popup webContents listener counts are stable across repeated opens @ // tooltip; otherwise both popups overlap and the user reads garbage. // --------------------------------------------------------------------------- -test('opening the title menu hides the title-bar tooltip @windows @macos @linux', async () => { +test('opening the title menu hides the title-bar tooltip @ci', async () => { // Drive the tooltip directly via the title-bar bridge, mirroring the // existing tooltip-on-demand test in chooser.test.ts. await ctx.app.evaluate(({ webContents }) => { diff --git a/e2e/lifecycle-add-existing.test.ts b/e2e/lifecycle-add-existing.test.ts index e05df26a..9e01439c 100644 --- a/e2e/lifecycle-add-existing.test.ts +++ b/e2e/lifecycle-add-existing.test.ts @@ -103,7 +103,7 @@ test.afterAll(async () => { if (stagedPath) await rm(stagedPath, { recursive: true, force: true }) }) -test('probe detects the staged standalone-shaped directory @lifecycle', async () => { +test('probe detects the staged standalone-shaped directory @ci', async () => { const results = await ctx.panel.evaluate( `window.api.probeInstallation(${JSON.stringify(stagedPath)})`, ) @@ -115,7 +115,7 @@ test('probe detects the staged standalone-shaped directory @lifecycle', async () expect(standalone!.comfyVersion?.commit).toMatch(/^[0-9a-f]{40}$/) }) -test('track-installation registers the directory and chooser shows the tile @lifecycle', async () => { +test('track-installation registers the directory and chooser shows the tile @ci', async () => { const probeResults = await ctx.panel.evaluate( `window.api.probeInstallation(${JSON.stringify(stagedPath)})`, ) diff --git a/e2e/lifecycle-cancel-flow.test.ts b/e2e/lifecycle-cancel-flow.test.ts index 4996d91b..c709cf93 100644 --- a/e2e/lifecycle-cancel-flow.test.ts +++ b/e2e/lifecycle-cancel-flow.test.ts @@ -111,7 +111,7 @@ function readGuardVerdict(): Promise { return ctx.panel.evaluate('window.__guardPromise') } -test('useActionGuard fires cancel-operation when the user confirms cancelling the busy op @lifecycle', async () => { +test('useActionGuard fires cancel-operation when the user confirms cancelling the busy op @ci', async () => { // Active session + in-flight op makes the guard's busy check fire. await startInFlightOp() await runGuardInBackground('Restart ComfyUI') @@ -140,7 +140,7 @@ test('useActionGuard fires cancel-operation when the user confirms cancelling th expect(await readGuardVerdict()).toBe(true) }) -test('useActionGuard returns false without cancelling when the user dismisses the confirm @lifecycle', async () => { +test('useActionGuard returns false without cancelling when the user dismisses the confirm @ci', async () => { await startInFlightOp() await runGuardInBackground('Restart ComfyUI') @@ -158,7 +158,7 @@ test('useActionGuard returns false without cancelling when the user dismisses th await settleInFlightOp({ ok: false, cancelled: true }) }) -test('Return-to-Dashboard from in-flight op cancels and closes the takeover @lifecycle', async () => { +test('Return-to-Dashboard from in-flight op cancels and closes the takeover @ci', async () => { // Seed a real running session so the local-install confirm prompt // appears (cloud / remote skip the prompt). await seedRunningSession(ctx.app, { diff --git a/e2e/lifecycle-cloud.test.ts b/e2e/lifecycle-cloud.test.ts index 664a69b1..39046236 100644 --- a/e2e/lifecycle-cloud.test.ts +++ b/e2e/lifecycle-cloud.test.ts @@ -13,7 +13,7 @@ * response (status code agnostic), so a reachable endpoint is * sufficient — no auth, no specific status required. * - * Tagged @lifecycle to share the dedicated Playwright project's + * Tagged @real to share the dedicated Playwright project's * 180-second per-test timeout. No 500 MB download (cloud install is * pure remote URL routing), so the suite runs in well under a minute. */ @@ -36,7 +36,7 @@ test.afterAll(async () => { await ctx.cleanup() }) -test('cold start lands on first-use start screen @lifecycle', async () => { +test('cold start lands on first-use start screen @real', async () => { // Consent + cloud/local pick + ToS now share a single merged start // screen (commit 5619823). Continue stays disabled until ToS is // ticked, so seeing both the hero and the disabled CTA is the @@ -46,7 +46,7 @@ test('cold start lands on first-use start screen @lifecycle', async () => { await ctx.panel.waitForVisible('[data-testid="first-use-continue"]') }) -test('accept ToS + pick cloud auto-launches the seeded Cloud install @lifecycle', async () => { +test('accept ToS + pick cloud auto-launches the seeded Cloud install @real', async () => { // Pick Cloud first — the merged start screen lets the user choose // their path before accepting terms; the order doesn't matter for // the underlying `pickedChoice` reactive. diff --git a/e2e/lifecycle-copy-update-fail.test.ts b/e2e/lifecycle-copy-update-fail.test.ts index c0eb7b8b..185e2a6c 100644 --- a/e2e/lifecycle-copy-update-fail.test.ts +++ b/e2e/lifecycle-copy-update-fail.test.ts @@ -84,7 +84,7 @@ test.afterAll(async () => { if (sourcePath) await rm(sourcePath, { recursive: true, force: true }) }) -test('copy-update keeps the new install when the chained update fails @lifecycle', async () => { +test('copy-update keeps the new install when the chained update fails @ci', async () => { // Start capturing comfy-output BEFORE firing the action — the handler // emits the failure + retry hint via sendOutput during the update // step, and we need to assert both lines reached the renderer. diff --git a/e2e/lifecycle-copy.test.ts b/e2e/lifecycle-copy.test.ts index 6b8a99ca..8a61f02b 100644 --- a/e2e/lifecycle-copy.test.ts +++ b/e2e/lifecycle-copy.test.ts @@ -92,7 +92,7 @@ test.afterAll(async () => { if (sourcePath) await rm(sourcePath, { recursive: true, force: true }) }) -test('Copy creates a new install on disk + in the registry, source untouched @lifecycle', async () => { +test('Copy creates a new install on disk + in the registry, source untouched @ci', async () => { const result = await ctx.panel.evaluate( `window.api.runAction(${JSON.stringify(SOURCE_ID)}, 'copy', { name: ${JSON.stringify(COPY_NAME)} })`, ) diff --git a/e2e/lifecycle-deep-links.test.ts b/e2e/lifecycle-deep-links.test.ts index d028bc89..0ef725c6 100644 --- a/e2e/lifecycle-deep-links.test.ts +++ b/e2e/lifecycle-deep-links.test.ts @@ -81,7 +81,7 @@ async function fireDeepLink(payload: Record): Promise { }, payload) } -test('comfy://open-settings?tab=global opens Global Settings @lifecycle', async () => { +test('comfy://open-settings?tab=global opens Global Settings @ci', async () => { await fireDeepLink({ kind: 'open-settings', settingsTab: 'global' }) await expect @@ -98,7 +98,7 @@ test('comfy://open-settings?tab=global opens Global Settings @lifecycle', async expect(pickerCalls.length).toBe(0) }) -test('comfy://open-settings?tab=comfy on chooser host opens the picker (compact fallback) @lifecycle', async () => { +test('comfy://open-settings?tab=comfy on chooser host opens the picker (compact fallback) @ci', async () => { // Chooser host has no install backing it, so `useDeepLinkRouter` // falls through to the compact picker so the user can pick an // install before landing on its Config tab. Install-backed hosts @@ -127,7 +127,7 @@ test('comfy://open-settings?tab=comfy on chooser host opens the picker (compact expect(globalCalls.length).toBe(0) }) -test('comfy://install-update with a non-matching installationId is ignored on chooser host @lifecycle', async () => { +test('comfy://install-update with a non-matching installationId is ignored on chooser host @ci', async () => { // The chooser host's `opts.installationId` is the empty string. The // `install-update` branch guards on `!id || id !== opts.installationId` // so a payload for an unrelated install must NOT open any popup diff --git a/e2e/lifecycle-delete-untrack.test.ts b/e2e/lifecycle-delete-untrack.test.ts index 2978d50b..7557bb61 100644 --- a/e2e/lifecycle-delete-untrack.test.ts +++ b/e2e/lifecycle-delete-untrack.test.ts @@ -4,13 +4,11 @@ * Drives the same kebab → menu-item → confirm flow a user performs * manually: * - * - **Untrack** routes through `onManage({ autoAction: 'remove' })`, - * which opens the instance-picker popup in expanded mode with the - * autoAction seed. The popup's `ComfyUISettingsContent` then loads - * section data, locates the `'remove'` source-action def, and fires - * its `confirm` payload as a BaseAlert inside the popup webContents. - * Confirming drops the installation from the registry but leaves the - * install directory on disk. + * - **Untrack** is a renderer-built confirm + instant `remove` IPC. + * The BaseAlert mounts in the panel webContents (no picker popup + * hop, no `get-detail-sections` round-trip). Confirming drops the + * installation from the registry but leaves the install directory + * on disk. * - **Delete** routes through the kebab fast-path (`onShowProgress` * builds the confirm renderer-side, no popup mount). The BaseAlert * appears in the panel webContents. Confirming drops both the @@ -29,7 +27,6 @@ import { test, expect } from '@playwright/test' import { launchApp, type AppContext } from './launchApp' import { expectChooserVisible } from './support/chooserHelpers' import { byTestId, TID } from './support/testIds' -import { titlePopupPage, waitForWebContents } from './support/cdpPages' let ctx: AppContext let untrackPath: string @@ -105,24 +102,21 @@ test.afterAll(async () => { if (deletePath) await rm(deletePath, { recursive: true, force: true }) }) -test('chooser lists both seeded installs @lifecycle', async () => { +test('chooser lists both seeded installs @ci', async () => { await ctx.panel.waitForSelector(byTestId(TID.dashboardTile(UNTRACK_ID)), { timeout: 10_000 }) await ctx.panel.waitForSelector(byTestId(TID.dashboardTile(DELETE_ID)), { timeout: 10_000 }) }) -test('kebab Untrack drops the record but preserves the install directory @lifecycle', async () => { +test('kebab Untrack drops the record but preserves the install directory @ci', async () => { await openKebabAndClick(UNTRACK_ID, 'untrack') - // useInstallContextMenu's 'untrack' branch calls - // `onManage({ autoAction: 'remove' })`, which in ChooserView opens - // the picker popup in expanded mode with autoAction seeded. The - // popup's ComfyUISettingsContent loads sections, finds the 'remove' - // source-action def, and fires its `confirm` payload as a BaseAlert - // in the popup webContents. - await waitForWebContents(ctx.app, 'comfyTitlePopup.html') - const popup = titlePopupPage(ctx.app) - await popup.waitForVisible(byTestId(TID.baseAlertAction), { timeout: 15_000 }) - const confirmed = await popup.click(byTestId(TID.baseAlertAction)) + // useInstallContextMenu's 'untrack' branch builds the confirm modal + // renderer-side via `modal.confirm({ title: 'Forget Install', … })` + // and only then fires `runAction(id, 'remove')` — no picker popup + // hop, the BaseAlert mounts in the same panel webContents as the + // dashboard chooser (parity with the Delete fast-path below). + await ctx.panel.waitForVisible(byTestId(TID.baseAlertAction), { timeout: 15_000 }) + const confirmed = await ctx.panel.click(byTestId(TID.baseAlertAction)) expect(confirmed, 'untrack confirm click dispatched').toBe(true) await ctx.panel.waitFor( @@ -132,7 +126,7 @@ test('kebab Untrack drops the record but preserves the install directory @lifecy expect(await pathExists(untrackPath), 'untrack must leave the install directory on disk').toBe(true) }) -test('kebab Delete drops the record AND removes the install directory @lifecycle', async () => { +test('kebab Delete drops the record AND removes the install directory @ci', async () => { await openKebabAndClick(DELETE_ID, 'delete') // Delete uses the kebab fast-path BaseAlert that useInstallContextMenu diff --git a/e2e/lifecycle-dismiss-error.test.ts b/e2e/lifecycle-dismiss-error.test.ts index 89d5fd79..3e861a74 100644 --- a/e2e/lifecycle-dismiss-error.test.ts +++ b/e2e/lifecycle-dismiss-error.test.ts @@ -70,7 +70,7 @@ test.afterAll(async () => { if (installPath) await rm(installPath, { recursive: true, force: true }) }) -test('Dismiss error from the kebab clears the error instance @lifecycle', async () => { +test('Dismiss error from the kebab clears the error instance @ci', async () => { // Seed an error directly into the renderer-side sessionStore so the // kebab grows its Dismiss-error item without needing to drive a real // failing op first. The kebab item visibility tracks @@ -96,7 +96,7 @@ test('Dismiss error from the kebab clears the error instance @lifecycle', async .toBe(false) }) -test('Dismiss-error item is gone from the kebab after clearing @lifecycle', async () => { +test('Dismiss-error item is gone from the kebab after clearing @ci', async () => { // The menu items are rebuilt on every open via `getMenuItems(inst)`; // re-opening proves the item really disappears (not just hidden in // a stale prior menu instance) once the store no longer carries an diff --git a/e2e/lifecycle-first-use-migrate.test.ts b/e2e/lifecycle-first-use-migrate.test.ts index a2f4e975..da89ea17 100644 --- a/e2e/lifecycle-first-use-migrate.test.ts +++ b/e2e/lifecycle-first-use-migrate.test.ts @@ -61,7 +61,7 @@ test.afterAll(async () => { if (legacyBasePath) await rm(legacyBasePath, { recursive: true, force: true }) }) -test('cold start with legacy desktop lands on start screen and surfaces migrate sub-step @lifecycle', async () => { +test('cold start with legacy desktop lands on start screen and surfaces migrate sub-step @ci', async () => { // Merged start screen — consent + cloud/local + ToS all share one // page (commit 5619823). The hasLegacyDesktop branch fires after the // user picks Local and clicks Continue. @@ -97,7 +97,7 @@ test('cold start with legacy desktop lands on start screen and surfaces migrate await ctx.panel.waitForVisible('[data-testid="first-use-local-migrate"]', { timeout: 10_000 }) }) -test('migrate sub-step opens MigrateConfirmTakeover (takeover surface) @lifecycle', async () => { +test('migrate sub-step opens MigrateConfirmTakeover (takeover surface) @ci', async () => { // Reset run-action invocations so the confirm assertion below counts // only the migrate-to-standalone dispatch this test produces. await resetIpcInvocations(ctx.app, 'run-action') diff --git a/e2e/lifecycle-first-use-skip.test.ts b/e2e/lifecycle-first-use-skip.test.ts index fc9838bc..0ab4d9dc 100644 --- a/e2e/lifecycle-first-use-skip.test.ts +++ b/e2e/lifecycle-first-use-skip.test.ts @@ -32,7 +32,7 @@ test.afterAll(async () => { await ctx?.cleanup() }) -test('cold start lands on first-use start screen @lifecycle', async () => { +test('cold start lands on first-use start screen @ci', async () => { // Consent + cloud/local pick share a single merged start screen // (commit 5619823). The hero + Continue CTA prove we've reached the // takeover; the Continue button is still disabled because ToS isn't @@ -41,7 +41,7 @@ test('cold start lands on first-use start screen @lifecycle', async () => { await ctx.panel.waitForVisible('[data-testid="first-use-continue"]') }) -test('Skip Onboarding IPC clears bookkeeping and reveals the chooser @lifecycle', async () => { +test('Skip Onboarding IPC clears bookkeeping and reveals the chooser @ci', async () => { // Reset so the assertions below count only the calls produced by // the skip-onboarding IPC (boot already exercised consent-step // mounting which pushed `'consent-lockdown'`). diff --git a/e2e/lifecycle-migrate.test.ts b/e2e/lifecycle-migrate.test.ts index e3e1582e..18dbdd1f 100644 --- a/e2e/lifecycle-migrate.test.ts +++ b/e2e/lifecycle-migrate.test.ts @@ -100,7 +100,7 @@ test.afterAll(async () => { if (stagedSnapshotPath) await rm(stagedSnapshotPath, { force: true }) }) -test('auto-tracker registers Legacy Desktop install on boot @lifecycle', async () => { +test('auto-tracker registers Legacy Desktop install on boot @ci', async () => { await ctx.panel.waitFor( async () => { const names = await ctx.panel.allText( @@ -120,7 +120,7 @@ test('auto-tracker registers Legacy Desktop install on boot @lifecycle', async ( legacyInstallId = desktop!.id }) -test('preview-desktop-migration stages a snapshot envelope from the legacy install @lifecycle', async () => { +test('preview-desktop-migration stages a snapshot envelope from the legacy install @ci', async () => { expect(legacyInstallId, 'legacyInstallId not captured by the prior test').toBeTruthy() const result = await ctx.panel.evaluate<{ ok: boolean @@ -139,7 +139,7 @@ test('preview-desktop-migration stages a snapshot envelope from the legacy insta stagedSnapshotPath = result.snapshotPath! }) -test('standalone source exposes a CPU variant the migration target picker can pin @lifecycle', async () => { +test('standalone source exposes a CPU variant the migration target picker can pin @ci', async () => { // The renderer-side migration target picker calls these same field-option // IPCs to build its release / variant rows. The CI lifecycle suite pins // CPU on Windows; without that pick we'd download a GPU payload that diff --git a/e2e/lifecycle-periodic-update-check.test.ts b/e2e/lifecycle-periodic-update-check.test.ts index 74a5904f..ce173d3f 100644 --- a/e2e/lifecycle-periodic-update-check.test.ts +++ b/e2e/lifecycle-periodic-update-check.test.ts @@ -100,7 +100,7 @@ test.afterAll(async () => { if (stagedInstallPath) await rm(stagedInstallPath, { recursive: true, force: true }) }) -test('background timer re-fetches the release cache on its interval @lifecycle', async () => { +test('background timer re-fetches the release cache on its interval @ci', async () => { test.setTimeout(60_000) // Wait for the initial IPC-hook pre-warm to populate the cache. diff --git a/e2e/lifecycle-picker-cluster.test.ts b/e2e/lifecycle-picker-cluster.test.ts index 2f8959df..c51b1ad4 100644 --- a/e2e/lifecycle-picker-cluster.test.ts +++ b/e2e/lifecycle-picker-cluster.test.ts @@ -111,7 +111,7 @@ async function openExpandedPicker(): Promise { ) } -test('pick-install for a running install dismisses the popup and focuses the existing host @lifecycle', async () => { +test('pick-install for a running install dismisses the popup and focuses the existing host @ci', async () => { // Seed a running session so the chain hits the // `performPickerLaunch.focused-running` branch. Without this the // panel would attempt a real `runAction('launch')` against a fake @@ -155,7 +155,7 @@ test('pick-install for a running install dismisses the popup and focuses the exi expect(focusCalls[0]?.installationId).toBe(INSTALL_ID) }) -test('pick-install for a stopped install dismisses the popup and fires runAction(launch) @lifecycle', async () => { +test('pick-install for a stopped install dismisses the popup and fires runAction(launch) @ci', async () => { // No seedRunningSession — the chain hits // `performChooserLaunch.launched`: looks up the launch action, // claims the chooser host, and dispatches `runAction('launch')` @@ -200,7 +200,7 @@ test('pick-install for a stopped install dismisses the popup and fires runAction expect(focusCalls.length).toBe(0) }) -test('open-new-install dismisses the popup and mounts the new-install takeover @lifecycle', async () => { +test('open-new-install dismisses the popup and mounts the new-install takeover @ci', async () => { await openExpandedPicker() const popup = titlePopupPage(ctx.app) @@ -223,7 +223,7 @@ test('open-new-install dismisses the popup and mounts the new-install takeover @ await ctx.panel.waitForVisible('.config-shell', { timeout: 10_000 }) }) -test('open-install-action with reveal-in-folder keeps the popup open and fires the action @lifecycle', async () => { +test('open-install-action with reveal-in-folder keeps the popup open and fires the action @ci', async () => { await openExpandedPicker() const popup = titlePopupPage(ctx.app) diff --git a/e2e/lifecycle-port-conflict.test.ts b/e2e/lifecycle-port-conflict.test.ts index edd205e4..41cb95c9 100644 --- a/e2e/lifecycle-port-conflict.test.ts +++ b/e2e/lifecycle-port-conflict.test.ts @@ -115,7 +115,7 @@ async function injectConflict(): Promise { })()`) } -test('port-conflict banner + dual-action footer render when result.portConflict is set @lifecycle', async () => { +test('port-conflict banner + dual-action footer render when result.portConflict is set @ci', async () => { await injectConflict() await ctx.panel.waitForVisible(byTestId(TID.progressPortConflictBanner), { timeout: 10_000 }) @@ -128,7 +128,7 @@ test('port-conflict banner + dual-action footer render when result.portConflict expect(await ctx.panel.exists(byTestId(TID.progressReboot))).toBe(false) }) -test('Use Next Port fires runAction(launch) with portOverride @lifecycle', async () => { +test('Use Next Port fires runAction(launch) with portOverride @ci', async () => { await injectConflict() await ctx.panel.waitForVisible(byTestId(TID.progressPortConflictUsePort), { timeout: 10_000 }) @@ -149,7 +149,7 @@ test('Use Next Port fires runAction(launch) with portOverride @lifecycle', async expect(launchCall!.actionData).toEqual({ portOverride: NEXT_PORT }) }) -test('Kill Process confirms, hits killPortProcess, then re-invokes op.apiCall @lifecycle', async () => { +test('Kill Process confirms, hits killPortProcess, then re-invokes op.apiCall @ci', async () => { await injectConflict() await ctx.panel.waitForVisible(byTestId(TID.progressPortConflictKill), { timeout: 10_000 }) diff --git a/e2e/lifecycle-progress-reboot.test.ts b/e2e/lifecycle-progress-reboot.test.ts index fc3f8278..b9e3b914 100644 --- a/e2e/lifecycle-progress-reboot.test.ts +++ b/e2e/lifecycle-progress-reboot.test.ts @@ -55,7 +55,7 @@ test.afterAll(async () => { if (installPath) await rm(installPath, { recursive: true, force: true }) }) -test('ProgressModal Reboot re-runs the same apiCall to recover an errored install @lifecycle', async () => { +test('ProgressModal Reboot re-runs the same apiCall to recover an errored install @ci', async () => { // Seed a pre-existing errorInstance to mimic the audit's #24 setup — // the install was already errored before the user took any action // against it (distinct from #7, where the error originates inside the diff --git a/e2e/lifecycle-snapshot-export.test.ts b/e2e/lifecycle-snapshot-export.test.ts index 54490514..a2b0c532 100644 --- a/e2e/lifecycle-snapshot-export.test.ts +++ b/e2e/lifecycle-snapshot-export.test.ts @@ -134,7 +134,7 @@ async function findExportedFile(prefix: string): Promise { return match ? path.join(exportDir, match) : null } -test('per-row Export writes a valid envelope JSON to disk @lifecycle', async () => { +test('per-row Export writes a valid envelope JSON to disk @ci', async () => { const popup = await openSnapshotsTab() // Read the seeded snapshot filenames off the registry so the test @@ -183,7 +183,7 @@ test('per-row Export writes a valid envelope JSON to disk @lifecycle', async () expect(envelope.snapshots?.[0]?.comfyui?.commit).toBe(COMMIT_B) }) -test('Export All writes an envelope containing every seeded snapshot @lifecycle', async () => { +test('Export All writes an envelope containing every seeded snapshot @ci', async () => { const popup = await openSnapshotsTab() await popup.waitForVisible(byTestId(TID.snapshotsExportAll), { timeout: 5_000 }) diff --git a/e2e/lifecycle-snapshot-import.test.ts b/e2e/lifecycle-snapshot-import.test.ts index 0d4e2e0c..645b41f3 100644 --- a/e2e/lifecycle-snapshot-import.test.ts +++ b/e2e/lifecycle-snapshot-import.test.ts @@ -112,7 +112,7 @@ test.afterAll(async () => { if (envelopeDir) await rm(envelopeDir, { recursive: true, force: true }) }) -test('Import preview → Continue writes the envelope snapshot into the install @lifecycle', async () => { +test('Import preview → Continue writes the envelope snapshot into the install @ci', async () => { // Sanity: empty install starts with zero snapshots. const initialCount = await ctx.panel.evaluate( `window.api.getSnapshots(${JSON.stringify(INSTALL_ID)}).then(d => d.snapshots.length)`, diff --git a/e2e/lifecycle-snapshot-restore.test.ts b/e2e/lifecycle-snapshot-restore.test.ts index 2503f519..167d1450 100644 --- a/e2e/lifecycle-snapshot-restore.test.ts +++ b/e2e/lifecycle-snapshot-restore.test.ts @@ -198,7 +198,7 @@ test.afterAll(async () => { if (workTmpPath) await rm(workTmpPath, { recursive: true, force: true }) }) -test('seeded HEADs start at commit B (sanity) @lifecycle', async () => { +test('seeded HEADs start at commit B (sanity) @ci', async () => { const comfyHead = gitIn(path.join(stagedInstallPath, 'ComfyUI'), ['rev-parse', 'HEAD']) const nodeHead = gitIn( path.join(stagedInstallPath, 'ComfyUI', 'custom_nodes', NODE_DIRNAME), @@ -208,7 +208,7 @@ test('seeded HEADs start at commit B (sanity) @lifecycle', async () => { expect(nodeHead).toBe(nodeCommitB) }) -test('snapshot-restore moves ComfyUI + node HEAD back to commit A @lifecycle', async () => { +test('snapshot-restore moves ComfyUI + node HEAD back to commit A @ci', async () => { const list = await ctx.panel.evaluate( `window.api.getSnapshots(${JSON.stringify(INSTALL_ID)})`, ) @@ -236,7 +236,7 @@ test('snapshot-restore moves ComfyUI + node HEAD back to commit A @lifecycle', a expect(nodeHead, 'custom node HEAD did not move to commit A after restore').toBe(nodeCommitA) }) -test('restore captures a post-restore snapshot @lifecycle', async () => { +test('restore captures a post-restore snapshot @ci', async () => { // After a successful restore, `actions.ts` calls `saveSnapshot(... 'post-restore')`. // Poll because the post-restore save runs after `update(restoreState)` returns // — the runAction call already awaited the action result, but the snapshot @@ -271,18 +271,20 @@ test('restore captures a post-restore snapshot @lifecycle', async () => { * the op-card terminal states. The actual git-checkout work is identical * to the earlier test; we don't re-assert HEAD movement (that's covered). */ -test('picker-driven restore surfaces inline op-card + auto-dismisses on success @lifecycle', async () => { +test('picker-driven restore surfaces inline op-card + auto-dismisses on success @ci', async () => { test.setTimeout(120_000) // The earlier `snapshot-restore moves … back to commit A` test already - // performed a restore via runAction. A `post-restore` snapshot is on - // disk now; pick that as the target so we have a row to expand. + // performed a restore via runAction. The list now reads (newest first): + // `post-restore` then the seeded `manual` row. The newest snapshot is + // the install's current state — SnapshotsView intentionally suppresses + // its Restore button (`v-if="i !== 0"`) since there's nothing to roll + // back to. Pick the older `manual` row so a real Restore CTA renders. const list = await ctx.panel.evaluate( `window.api.getSnapshots(${JSON.stringify(INSTALL_ID)})`, ) - const target = list.snapshots.find((s) => s.trigger === 'post-restore') - ?? list.snapshots[0] - expect(target, 'no snapshot to restore in picker test').toBeDefined() + const target = list.snapshots.find((s) => s.trigger === 'manual') + expect(target, 'seeded manual snapshot missing from list').toBeDefined() await ctx.panel.evaluate( `(() => { diff --git a/e2e/lifecycle-snapshot-roundtrip.test.ts b/e2e/lifecycle-snapshot-roundtrip.test.ts index bc1ed24a..7edfa024 100644 --- a/e2e/lifecycle-snapshot-roundtrip.test.ts +++ b/e2e/lifecycle-snapshot-roundtrip.test.ts @@ -163,7 +163,7 @@ async function findExportedFile(prefix: string): Promise { return match ? path.join(exportDir, match) : null } -test('Export All from A writes an envelope containing both seeded snapshots @lifecycle', async () => { +test('Export All from A writes an envelope containing both seeded snapshots @ci', async () => { const popup = await openSnapshotsTab(INSTALL_ID_A) await popup.waitForVisible(byTestId(TID.snapshotsExportAll), { timeout: 5_000 }) @@ -195,7 +195,7 @@ test('Export All from A writes an envelope containing both seeded snapshots @lif expect(labels).toContain(LABEL_SECOND) }) -test('Import into B consumes the envelope and writes both snapshots @lifecycle', async () => { +test('Import into B consumes the envelope and writes both snapshots @ci', async () => { const initialCount = await ctx.panel.evaluate( `window.api.getSnapshots(${JSON.stringify(INSTALL_ID_B)}).then(d => d.snapshots.length)`, ) diff --git a/e2e/lifecycle-snapshot.test.ts b/e2e/lifecycle-snapshot.test.ts index 84c7881e..ac4e93cb 100644 --- a/e2e/lifecycle-snapshot.test.ts +++ b/e2e/lifecycle-snapshot.test.ts @@ -76,7 +76,7 @@ test.afterAll(async () => { if (stagedInstallPath) await rm(stagedInstallPath, { recursive: true, force: true }) }) -test('seeded snapshot row renders the backend-formatted version @lifecycle', async () => { +test('seeded snapshot row renders the backend-formatted version @ci', async () => { const opened = await ctx.panel.evaluate( `(() => { window.api.openInstancePicker({ @@ -96,7 +96,7 @@ test('seeded snapshot row renders the backend-formatted version @lifecycle', asy expect(metaText!).toContain(EXPECTED_VERSION) }) -test('captures a new snapshot via runAction and shows it at the top @lifecycle', async () => { +test('captures a new snapshot via runAction and shows it at the top @ci', async () => { const before = await ctx.panel.evaluate( `window.api.getSnapshots(${JSON.stringify(INSTALL_ID)}).then(d => d.snapshots.length)`, ) diff --git a/e2e/lifecycle-startup-update-check.test.ts b/e2e/lifecycle-startup-update-check.test.ts index 5ef8d916..1915af5e 100644 --- a/e2e/lifecycle-startup-update-check.test.ts +++ b/e2e/lifecycle-startup-update-check.test.ts @@ -82,7 +82,7 @@ test.afterAll(async () => { if (stagedInstallPath) await rm(stagedInstallPath, { recursive: true, force: true }) }) -test('startup pre-warm fills the release cache without any UI gesture @lifecycle', async () => { +test('startup pre-warm fills the release cache without any UI gesture @ci', async () => { test.setTimeout(60_000) // Poll the detail-sections payload — same pipeline the dashboard diff --git a/e2e/lifecycle-update-check.test.ts b/e2e/lifecycle-update-check.test.ts index 75cdc4a7..e7e567f3 100644 --- a/e2e/lifecycle-update-check.test.ts +++ b/e2e/lifecycle-update-check.test.ts @@ -127,7 +127,7 @@ test.afterAll(async () => { if (stagedInstallPathB) await rm(stagedInstallPathB, { recursive: true, force: true }) }) -test('check-update hits the real Comfy-Org/ComfyUI remote and finds a newer release @lifecycle', async () => { +test('check-update hits the real Comfy-Org/ComfyUI remote and finds a newer release @ci', async () => { const result = await ctx.panel.evaluate( `window.api.runAction(${JSON.stringify(INSTALL_ID)}, 'check-update')`, ) @@ -159,7 +159,7 @@ test('check-update hits the real Comfy-Org/ComfyUI remote and finds a newer rele ).toBe(true) }) -test('cross-channel fetch populates the latest channel card too @lifecycle', async () => { +test('cross-channel fetch populates the latest channel card too @ci', async () => { // The check-update action prefetches the "other" channel(s) in parallel // (Promise.allSettled over `['stable', 'latest']`). The previous test // already ran check-update — now verify both cards have data, not just @@ -210,7 +210,7 @@ function countAutoCheckUpdateCalls(calls: unknown[], installationId: string): nu .length } -test('Update tab does NOT auto-refresh when the channel data is fresh @lifecycle', async () => { +test('Update tab does NOT auto-refresh when the channel data is fresh @ci', async () => { // The previous tests just ran check-update — both cache entries are // seconds old, well inside the 15min freshness window. Opening the // picker on the Update tab must NOT fire an extra check-update IPC. @@ -235,7 +235,7 @@ test('Update tab does NOT auto-refresh when the channel data is fresh @lifecycle await closeTitlePopupIfOpen(ctx.app) }) -test('Update tab auto-refreshes when channel data is stale @lifecycle', async () => { +test('Update tab auto-refreshes when channel data is stale @ci', async () => { test.setTimeout(120_000) // Age every in-memory release-cache entry past the 15min staleness @@ -250,7 +250,8 @@ test('Update tab auto-refreshes when channel data is stale @lifecycle', async () // picker for): the sections pipeline now reports the stale timestamp, // proving the hook reached the same map `getEffectiveInfo` reads. // The release cache is shared per-repo, so install A and B see the - // same staled entries. + // same staled entries. The channel-card surface uses `lastCheckedAt` + // (number) — `lastChecked` is the localized string sibling. const staleSections = await ctx.panel.evaluate( `window.api.getDetailSections(${JSON.stringify(INSTALL_ID_B)})`, ) @@ -258,7 +259,7 @@ test('Update tab auto-refreshes when channel data is stale @lifecycle', async () const staleChannelField = staleUpdateSection.fields!.find((f) => f.id === 'updateChannel')! const staleCard = staleChannelField.options!.find((o) => o.value === staleChannelField.value) expect( - (staleCard?.data as { checkedAt?: number } | undefined)?.checkedAt, + (staleCard?.data as { lastCheckedAt?: number } | undefined)?.lastCheckedAt, 'ageReleaseCache hook did not mutate the in-memory map main reads from', ).toBe(stalenessTs) @@ -285,7 +286,7 @@ test('Update tab auto-refreshes when channel data is stale @lifecycle', async () .toBeGreaterThanOrEqual(1) // After the auto-refresh completes, the selected channel card's - // `checkedAt` should advance past `stalenessTs` — proves the + // `lastCheckedAt` should advance past `stalenessTs` — proves the // staleness was actually cleared, not just an IPC fire. await expect .poll(async () => { @@ -295,8 +296,8 @@ test('Update tab auto-refreshes when channel data is stale @lifecycle', async () const updateSection = sections.find((s) => s.tab === 'update')! const channelField = updateSection.fields!.find((f) => f.id === 'updateChannel')! const card = channelField.options!.find((o) => o.value === channelField.value) - const checkedAt = (card?.data as { checkedAt?: number } | undefined)?.checkedAt - return typeof checkedAt === 'number' && checkedAt > stalenessTs + const lastCheckedAt = (card?.data as { lastCheckedAt?: number } | undefined)?.lastCheckedAt + return typeof lastCheckedAt === 'number' && lastCheckedAt > stalenessTs }, { timeout: 15_000, intervals: [250, 500, 1_000] }) .toBe(true) diff --git a/e2e/lifecycle.test.ts b/e2e/lifecycle.test.ts index 0b28b1aa..624bccfa 100644 --- a/e2e/lifecycle.test.ts +++ b/e2e/lifecycle.test.ts @@ -3,7 +3,7 @@ * GPU, latest stable release) → ComfyUI auto-launches via brand chrome → * dashboard return → relaunch → stop. * - * Downloads ~500 MB of standalone payload. Tagged @lifecycle and runs under + * Downloads ~500 MB of standalone payload. Tagged @real and runs under * the dedicated Playwright project (10-minute per-test timeout). * * Run: @@ -37,17 +37,23 @@ import { resolve } from 'node:path' import { test, expect } from '@playwright/test' import { launchApp, type AppContext } from './launchApp' import { - clickInstallTile, expectChooserVisible, expectTakeoverOpen, } from './support/chooserHelpers' import { - ensureInstallPanelView, getIpcInvocations, getRunningSessionSnapshot, resetIpcInvocations, - returnFirstInstallHostToDashboard, } from './support/devHooks' +import { + deleteInstallViaDashboardKebab, + ensureInstalledAndLaunched, + ensureInstallPanelMounted, + launchComfyByClickingTile, + openPickerByClickingTitlePill, + returnToDashboardViaPickerHome, + saveSnapshotViaPicker, +} from './support/realPrereqs' import { isPopupVisible, systemModalPage, @@ -105,66 +111,59 @@ test.beforeAll(async () => { // the on-disk state the next greped run consumes. ctx = await launchApp() - if (process.env['LIFECYCLE_REUSE_DIR']) { - try { - await ctx.panel.waitForVisible('.chooser-view', { timeout: 10_000 }) - } catch { /* fresh boot may still be on first-use takeover */ } - const installs = await ctx.panel.evaluate(`window.api.getInstallations()`) - .catch(() => [] as InstallationLite[]) - // Filter out the Cloud install record (no `installPath`) that's - // seeded on first chooser mount — only a local standalone is a - // valid hydration target. - const localInstall = installs.find((i) => typeof i.installPath === 'string' && i.installPath.length > 0) - if (localInstall) { - _updateInstallId = localInstall.id - _updateInstallPath = localInstall.installPath - _comfyUIDir = path.join(_updateInstallPath, 'ComfyUI') - try { - _installedCommit = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: _comfyUIDir, encoding: 'utf-8', windowsHide: true, - }).trim() - } catch { /* partial hydration — git dir may not exist on a half-built profile */ } - try { - const list = await ctx.panel.evaluate( - `window.api.getSnapshots(${JSON.stringify(_updateInstallId)})`, - ) - const target = list.snapshots.find((s) => s.label === 'lifecycle-restore-target') - if (target) { - _restoreSnapshotFilename = target.filename - const snapPath = path.join(_updateInstallPath, '.launcher', 'snapshots', target.filename) - const snap = JSON.parse(readFileSync(snapPath, 'utf-8')) as { - comfyui?: { commit?: string | null } - } - if (snap.comfyui?.commit) _snapshotHeadAtCapture = snap.comfyui.commit - } - } catch { /* snapshot not yet captured on this profile */ } - HYDRATED = true - console.log(`[lifecycle] hydrated from reused profile: installId=${_updateInstallId} commit=${_installedCommit || '(none)'} restoreSnapshot=${_restoreSnapshotFilename || '(none)'}`) - - // The picker-driven IN_PLACE_RELAUNCH tests (update / restore / - // restart) and the pin-bottom Restart / Copy tests all assume - // comfy is running before they fire — that's the state the full - // chain reaches via test 11 ("re-launch ComfyUI after update"). - // Launch the install here so a greped re-run lands in the same - // running-comfy state instead of skipping the relaunch leg. - try { - await clickInstallTile(ctx.panel, 'ComfyUI') - await expect.poll(comfyFrontendIsLoaded, { timeout: 180_000, intervals: [1_000, 2_000] }).toBe(true) - // chooser-pick attach destroys the panel webContents without - // remounting (production lazily mounts on the next Settings - // click / comfy-lifecycle body) — picker-driven tests need - // `ctx.panel.evaluate` reachable, so do the lazy mount once - // here. Mirrors the same dance test 12 does after `clickInstallTile`. - await ensureInstallPanelView(ctx.app, _updateInstallId) - await waitForWebContents(ctx.app, 'panel.html') - console.log('[lifecycle] auto-launched reused install + remounted install-backed panel view') - } catch (err) { - console.log(`[lifecycle] auto-launch failed (tests that require running comfy will fail): ${(err as Error).message}`) + if (!process.env['LIFECYCLE_REUSE_DIR']) { + // Fresh mode: leave the cold-start setup tests below to drive the + // first-use takeover + install end-to-end so each step is asserted. + return + } + + // Reuse mode: probe disk via the panel to decide whether an install + // already exists. If it does, drive hydration + launch through + // `ensureInstalledAndLaunched` (real DOM clicks, no `__e2e.*` + // mutations) and flip HYDRATED so the setup tests below skip + // themselves. If the reuse dir is empty, fall through and let the + // setup tests run normally — they'll populate the profile for the + // next greped re-run. + try { + await ctx.panel.waitForVisible('.chooser-view', { timeout: 10_000 }) + } catch { /* fresh boot may still be on first-use takeover */ } + + const installs = await ctx.panel.evaluate(`window.api.getInstallations()`) + .catch(() => [] as InstallationLite[]) + // Filter out the Cloud install record (no `installPath`) that's + // seeded on first chooser mount — only a local standalone is a + // valid hydration target. + const localInstall = installs.find((i) => typeof i.installPath === 'string' && i.installPath.length > 0) + if (!localInstall) { + console.log('[lifecycle] LIFECYCLE_REUSE_DIR set but no install found — running fresh setup tests to populate the profile') + return + } + + const hydrated = await ensureInstalledAndLaunched(ctx) + _updateInstallId = hydrated.id + _updateInstallPath = hydrated.installPath + _comfyUIDir = hydrated.comfyUIDir + _installedCommit = hydrated.installedCommit + + // Read-only snapshot rehydration — `getSnapshots` is observation + // only, and the snapshot file on disk was written by a prior run. + try { + const list = await ctx.panel.evaluate( + `window.api.getSnapshots(${JSON.stringify(_updateInstallId)})`, + ) + const target = list.snapshots.find((s) => s.label === 'lifecycle-restore-target') + if (target) { + _restoreSnapshotFilename = target.filename + const snapPath = path.join(_updateInstallPath, '.launcher', 'snapshots', target.filename) + const snap = JSON.parse(readFileSync(snapPath, 'utf-8')) as { + comfyui?: { commit?: string | null } } - } else { - console.log('[lifecycle] LIFECYCLE_REUSE_DIR set but no install found — running fresh setup tests to populate the profile') + if (snap.comfyui?.commit) _snapshotHeadAtCapture = snap.comfyui.commit } - } + } catch { /* snapshot not yet captured on this profile */ } + + HYDRATED = true + console.log(`[lifecycle] hydrated from reused profile: installId=${_updateInstallId} commit=${_installedCommit || '(none)'} restoreSnapshot=${_restoreSnapshotFilename || '(none)'}`) }) test.afterAll(async () => { @@ -184,7 +183,7 @@ async function comfyFrontendIsLoaded(): Promise { // First-use takeover → New Install takeover // --------------------------------------------------------------------------- -test('cold start lands on first-use start screen @lifecycle', async () => { +test('cold start lands on first-use start screen @real', async () => { test.skip(HYDRATED, 'reuse mode: first-use already completed on the persisted profile') // The first-use takeover gates the chooser body until consent + // cloud/local pick + Continue are completed on the merged start @@ -195,7 +194,7 @@ test('cold start lands on first-use start screen @lifecycle', async () => { await ctx.panel.waitForVisible('[data-testid="first-use-continue"]') }) -test('accept ToS + pick local (non-express) opens New Install takeover with form pre-filled @lifecycle', async () => { +test('accept ToS + pick local (non-express) opens New Install takeover with form pre-filled @real', async () => { test.skip(HYDRATED, 'reuse mode: first-use already completed on the persisted profile') // Pick Local — reveals the Express-Install modifier. We want the @@ -320,7 +319,7 @@ test('accept ToS + pick local (non-express) opens New Install takeover with form } }) -test('completes install (auto-launches via brand chrome) @lifecycle', async () => { +test('completes install (auto-launches via brand chrome) @real', async () => { test.skip(HYDRATED, 'reuse mode: install already on disk on the persisted profile') // No explicit variant / release / name picking — trust the // recommended defaults the modal has already filled in. On a no-GPU @@ -336,7 +335,7 @@ test('completes install (auto-launches via brand chrome) @lifecycle', async () = await expect.poll(comfyFrontendIsLoaded, { timeout: 480_000, intervals: [1_000, 2_000] }).toBe(true) }) -test('first-use Local chain marks firstUseCompleted once and cycles firstUseMode @lifecycle', async () => { +test('first-use Local chain marks firstUseCompleted once and cycles firstUseMode @real', async () => { test.skip(HYDRATED, 'reuse mode: first-use IPC log only exists on the boot that drove the chain') // Asserts the chain bookkeeping the auto-launch above relied on: // - `markFirstUseCompleted` (set-setting firstUseCompleted=true) @@ -360,7 +359,7 @@ test('first-use Local chain marks firstUseCompleted once and cycles firstUseMode // Launch & verify split-view + dark background // --------------------------------------------------------------------------- -test('auto-launch landed on a single host window (in-place attach) @lifecycle', async () => { +test('auto-launch landed on a single host window (in-place attach) @real', async () => { test.skip(HYDRATED, 'reuse mode: install was not auto-launched on this boot') // In-place attach guard: the redesigned install flow has // `autoLaunchOnFinish: true`, so the chooser host transforms into @@ -389,7 +388,7 @@ test('auto-launch landed on a single host window (in-place attach) @lifecycle', * BrowserWindow background is dark (#171717) so no white frame flashes * pre-load. */ -test('ComfyUI window has dark background and split-view architecture @lifecycle', async () => { +test('ComfyUI window has dark background and split-view architecture @real', async () => { test.skip(HYDRATED, 'reuse mode: comfy is not auto-running on this boot') const arch = await ctx.app.evaluate(({ BrowserWindow, WebContentsView }) => { for (const win of BrowserWindow.getAllWindows()) { @@ -428,49 +427,44 @@ test('ComfyUI window has dark background and split-view architecture @lifecycle' // Return to Dashboard — symmetric undo of in-place attach // --------------------------------------------------------------------------- -test('return-to-dashboard flips install host in place (same window id) @lifecycle', async () => { +test('return-to-dashboard flips install host in place (same window id) @real', async () => { test.skip(HYDRATED, 'reuse mode: no install-backed host exists to flip (comfy not auto-running)') - // Snapshot the live BrowserWindow ids BEFORE the flip so the - // post-flip assertion can prove the install-backed host was reused - // as the chooser host instead of being closed and replaced. - const before = await ctx.app.evaluate(({ BrowserWindow }) => { + // Snapshot the live BrowserWindow ids + the install-backed host id + // BEFORE the flip so the post-flip assertion can prove the install + // host was reused as the chooser host instead of being closed and + // replaced. + const before = await ctx.app.evaluate(({ BrowserWindow, WebContentsView }) => { const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()) - return { count: wins.length, ids: wins.map((w) => w.id) } + const comfyHost = wins.find((w) => + w.contentView.children.some((v) => + v instanceof WebContentsView && + /^http:\/\/(127\.0\.0\.1|localhost):/.test(v.webContents.getURL()), + ), + ) + return { count: wins.length, ids: wins.map((w) => w.id), comfyHostId: comfyHost?.id ?? null } }) + expect(before.comfyHostId, 'no install-backed host window found to flip').not.toBeNull() - // Trigger the same code path the File menu's "Return to Dashboard" - // entry runs (popup item handler calls `returnToDashboard(parentEntryId)`). - const flippedId = await returnFirstInstallHostToDashboard(ctx.app) - expect(flippedId, 'no install-backed host window found to flip').not.toBeNull() - expect(before.ids).toContain(flippedId) - - // After the flip the comfyView should no longer be loading a localhost URL - // (the install was detached and the comfyView navigated to about:blank). - await expect.poll(comfyFrontendIsLoaded, { timeout: 30_000, intervals: [500] }).toBe(false) + // Drive the File menu's "Return to Dashboard" item via real popup + // clicks. The helper polls `comfyFrontendIsLoaded`→false, waits for + // `panel.html` to reappear, and asserts the chooser body is visible. + await returnToDashboardViaPickerHome(ctx) const after = await ctx.app.evaluate(({ BrowserWindow }) => { const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()) return { count: wins.length, ids: wins.map((w) => w.id) } }) - // Same window count (no fresh window) and the flipped id is still alive — - // proving the install-backed host stayed the same BrowserWindow when it - // returned to chooser mode. + // Same window count (no fresh window) and the install-backed host id + // is still alive — proving the host stayed the same BrowserWindow + // when it returned to chooser mode. expect(after.count).toBe(before.count) - expect(after.ids).toContain(flippedId) - - // The chooser body should be visible again on the same window. The - // install-backed PanelApp was destroyed at attach time, so wait for - // the chooser PanelApp's webContents to be (re-)created by the in-place - // detach before driving DOM assertions through it. - await waitForWebContents(ctx.app, 'panel.html') - await expectChooserVisible(ctx.panel) + expect(after.ids).toContain(before.comfyHostId) // Re-launch ComfyUI from the same chooser host so the subsequent stop // test can find a running comfy webContents to close. The host id must // STILL be the same one we just flipped (chooser → install in place). - await clickInstallTile(ctx.panel, 'ComfyUI') - await expect.poll(comfyFrontendIsLoaded, { timeout: 180_000, intervals: [1_000] }).toBe(true) + await launchComfyByClickingTile(ctx, 'ComfyUI') const reattached = await ctx.app.evaluate(({ BrowserWindow, WebContentsView }) => { const wins = BrowserWindow.getAllWindows().filter((w) => !w.isDestroyed()) @@ -483,7 +477,7 @@ test('return-to-dashboard flips install host in place (same window id) @lifecycl return { count: wins.length, comfyHostId: comfyHost?.id ?? null } }) expect(reattached.count).toBe(before.count) - expect(reattached.comfyHostId).toBe(flippedId) + expect(reattached.comfyHostId).toBe(before.comfyHostId) }) // --------------------------------------------------------------------------- @@ -506,28 +500,24 @@ interface InstallationLite { installPath: string } -interface UpdateActionResult { - ok: boolean - message?: string - navigate?: string -} - let _updateInstallId = '' let _updateInstallPath = '' let _comfyUIDir = '' let _installedCommit = '' -test('stop ComfyUI again so update-comfyui (requires stopped) can run @lifecycle', async () => { +test('stop ComfyUI again so update-comfyui (requires stopped) can run @real', async () => { // `update-comfyui` is in REQUIRES_STOPPED; the prior test re-launched. // Detach in place rather than closing the window so the chooser host // stays alive for the subsequent re-launch. - await returnFirstInstallHostToDashboard(ctx.app) - await expect.poll(comfyFrontendIsLoaded, { timeout: 30_000, intervals: [500] }).toBe(false) - await waitForWebContents(ctx.app, 'panel.html') - await expectChooserVisible(ctx.panel) + // Prereq for individual --grep: ensure comfy is running so the + // file-menu Return to Dashboard has something to flip. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } + await returnToDashboardViaPickerHome(ctx) }) -test('captures install metadata for the update tests @lifecycle', async () => { +test('captures install metadata for the update tests @real', async () => { const installs = await ctx.panel.evaluate( `window.api.getInstallations()`, ) @@ -547,21 +537,47 @@ test('captures install metadata for the update tests @lifecycle', async () => { expect(_installedCommit).toMatch(/^[a-f0-9]{40}$/) }) -test('update-comfyui drives the real updater and moves HEAD forward @lifecycle', async () => { +test('update-comfyui drives the real updater and moves HEAD forward @real', async () => { // Real update can run pip-install if requirements.txt changed // between the oldest standalone release we installed on and the // latest stable tag. Stretch the per-test timeout to cover that. test.setTimeout(600_000) expect(_installedCommit, 'installed commit not captured').toBeTruthy() - const result = await ctx.panel.evaluate( - `window.api.runAction(${JSON.stringify(_updateInstallId)}, 'update-comfyui', { channel: 'stable' })`, - ) - expect(result.ok, `update-comfyui failed: ${result.message ?? ''}`).toBe(true) + // Drive the same-channel update via the picker: open on the Update + // tab, click Update Now on the current (stable) channel card, + // confirm. The install is currently stopped (REQUIRES_STOPPED), so + // no IN_PLACE_RELAUNCH chain follows — we just wait for HEAD to + // move off the installed commit. + await ensureInstallPanelMounted(ctx, _updateInstallId) + await resetIpcInvocations(ctx.app, 'run-action') + const popup = await openPickerByClickingTitlePill(ctx, { + installationId: _updateInstallId, initialTab: 'update', + }) + await popup.waitForSelector(byTestId(TID.updateActionButton('update-comfyui')), { timeout: 60_000 }) + expect(await popup.click(byTestId(TID.updateActionButton('update-comfyui')))).toBe(true) + + // Stable release has release notes → ModalDialog rich-confirm + // (`modal-confirm-button`); fall back to BaseAlert if the upstream + // release happens to have no body. + const confirmSelector = + '[data-testid="modal-confirm-button"], [data-testid="base-alert-action"]' + await popup.waitForVisible(confirmSelector, { timeout: 15_000 }) + expect(await popup.click(confirmSelector)).toBe(true) + + // Poll git HEAD instead of the runAction return value — the picker + // path forwards through pickerForwardShowProgress and does not + // resolve a result to the test process. + let headAfter = _installedCommit + await expect + .poll(() => { + headAfter = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: _comfyUIDir, encoding: 'utf-8', windowsHide: true, + }).trim() + return headAfter + }, { timeout: 540_000, intervals: [2_000, 5_000] }) + .not.toBe(_installedCommit) - const headAfter = execFileSync('git', ['rev-parse', 'HEAD'], { - cwd: _comfyUIDir, encoding: 'utf-8', windowsHide: true, - }).trim() expect(headAfter, 'update did not move HEAD off the installed (oldest stable) commit').not.toBe(_installedCommit) // The update should land on a commit reachable from origin/master that is @@ -572,9 +588,12 @@ test('update-comfyui drives the real updater and moves HEAD forward @lifecycle', expect(parseInt(aheadCount, 10), `post-update HEAD ${headAfter} is not ahead of installed commit ${_installedCommit}`).toBeGreaterThan(0) }) -test('re-launch ComfyUI after update validates the updated install runs @lifecycle', async () => { - await clickInstallTile(ctx.panel, 'ComfyUI') - await expect.poll(comfyFrontendIsLoaded, { timeout: 180_000, intervals: [1_000] }).toBe(true) +test('re-launch ComfyUI after update validates the updated install runs @real', async () => { + // Prereq for individual --grep: if comfy is somehow already up + // (e.g. greped against a hydrated profile mid-chain), the click + // would just expand the picker — skip straight to the assertion. + if (await comfyFrontendIsLoaded()) return + await launchComfyByClickingTile(ctx, 'ComfyUI') }) // --------------------------------------------------------------------------- @@ -648,24 +667,26 @@ async function getStopsFor(installationId: string): Promise { +test('captures a snapshot for the picker-driven restore test @real', async () => { // ComfyUI is running from the prior re-launch test. `snapshot-save` // is NOT in REQUIRES_STOPPED so it runs against a live install — the // snapshot just records the current state. Captured label gives us a // stable filename to grab in the restore test below. expect(_updateInstallId, 'update install id not captured').toBeTruthy() - // `clickInstallTile` in test 11 triggers `onLaunch`'s chooser-pick - // attach which calls `destroyPanelView(claimed)` (index.ts) without - // remounting — production lazily mounts a fresh install-backed - // panel on the next Settings click / comfy-lifecycle body, so - // `panel.html` doesn't exist while ComfyUI is the active body. - // The remaining picker-driven tests in this file all need - // `ctx.panel` reachable; do the lazy mount ourselves once here. - expect(await ensureInstallPanelView(ctx.app, _updateInstallId)).toBe(true) - await waitForWebContents(ctx.app, 'panel.html') - await ctx.panel.evaluate( - `window.api.runAction(${JSON.stringify(_updateInstallId)}, 'snapshot-save', { label: 'lifecycle-restore-target' })`, - ) + // Prereq for individual --grep: ensure comfy is running so the + // snapshot captures real install state. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } + // `clickInstallTile` triggers `onLaunch`'s chooser-pick attach which + // calls `destroyPanelView(claimed)` (index.ts) without remounting — + // production lazily mounts a fresh install-backed panel on the next + // Settings click / comfy-lifecycle body, so `panel.html` doesn't + // exist while ComfyUI is the active body. The remaining picker-driven + // tests in this file all need `ctx.panel` reachable; mount the panel + // ourselves via the title-bar file menu (real-click path), then drive + // the snapshot save through the picker's Snapshots tab. + await saveSnapshotViaPicker(ctx, _updateInstallId, 'lifecycle-restore-target') const list = await ctx.panel.evaluate( `window.api.getSnapshots(${JSON.stringify(_updateInstallId)})`, ) @@ -678,6 +699,218 @@ test('captures a snapshot for the picker-driven restore test @lifecycle', async expect(_snapshotHeadAtCapture).toMatch(/^[a-f0-9]{40}$/) }) +// --------------------------------------------------------------------------- +// Picker-driven copy-update — same ChannelPicker surface as the +// cross-channel update-comfyui test below, but invoked on the +// `copy-update` per-channel sibling action. Copies the source install +// to a new directory THEN runs the update on the copy, leaving the +// source completely untouched. copy-update is REQUIRES_STOPPED but +// NOT IN_PLACE_RELAUNCH — comfy stops to run the op and stays stopped +// (no auto-relaunch). +// +// Sequenced before the cross-channel update-comfyui test so the source +// is still on `stable` here; cross-channel `copy-update stable → latest` +// guarantees an actual git update happens on the copy (same-channel +// would resolve to "already up to date" since the prior update-comfyui +// test already pushed the source to the latest stable tag). +// --------------------------------------------------------------------------- + +let _copyUpdateInstallId = '' +let _copyUpdateInstallPath = '' + +test('picker-driven cross-channel copy-update (stable → latest) creates a copy + applies the update to the copy @real', async () => { + test.setTimeout(600_000) + + // copy-update is REQUIRES_STOPPED. Stop comfy via the picker Home + // icon so the action dispatches without the picker's self-stop + // preamble (see picker-stop-confirm.test.ts). + if (await comfyFrontendIsLoaded()) { + await returnToDashboardViaPickerHome(ctx) + } + + // Sanity: source install on stable so the cross-channel pick has + // somewhere to go. + const installsBefore = await ctx.panel.evaluate>( + `window.api.getInstallations()`, + ) + const before = installsBefore.find((i) => i.id === _updateInstallId) + expect(before?.updateChannel, 'source must be on stable before cross-channel copy-update').toBe('stable') + + const sourceHeadBefore = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: _comfyUIDir, encoding: 'utf-8', windowsHide: true, + }).trim() + + await resetIpcInvocations(ctx.app, 'comfy-titlepopup:start-background-op') + + await ensureInstallPanelMounted(ctx, _updateInstallId) + const popup = await openPickerByClickingTitlePill(ctx, { + installationId: _updateInstallId, initialTab: 'update', + }) + + // Draft `latest` via the ChannelPicker BaseSelect so the per-channel + // copy-update button comes alive against master tip. Drafting flips + // selectedActions to the drafted channel's + // `{ update-comfyui, copy-update, switch-channel }` set without + // mutating the install's persisted `updateChannel`. + await popup.waitForSelector('button[role="combobox"]', { timeout: 60_000 }) + expect(await popup.click('button[role="combobox"]')).toBe(true) + await popup.waitForVisible('[role="listbox"] [role="option"]', { timeout: 10_000 }) + expect( + await popup.clickByText('[role="listbox"] [role="option"]', 'Latest on GitHub'), + '"Latest on GitHub" option missing from BaseSelect listbox', + ).toBe(true) + + await popup.waitForSelector(byTestId(TID.updateActionButton('copy-update')), { timeout: 60_000 }) + expect(await popup.click(byTestId(TID.updateActionButton('copy-update')))).toBe(true) + + // copy-update's only modal is the new-install-name prompt (no + // separate confirm step on this action — `notesDetails` rides on + // the prompt's `messageDetails`). The picker drives prompts + // through `useDialogs().prompt` → BasePrompt, not the legacy + // ModalDialog, so the testids are the `basePrompt*` family. + await popup.waitForVisible(byTestId(TID.basePromptInput), { timeout: 10_000 }) + const newName = 'ComfyUI Copy-Update E2E' + await popup.evaluate( + `(() => { + const el = document.querySelector(${JSON.stringify(byTestId(TID.basePromptInput))}) + if (!el) throw new Error('prompt input not found') + el.value = ${JSON.stringify(newName)} + el.dispatchEvent(new Event('input', { bubbles: true })) + el.dispatchEvent(new Event('change', { bubbles: true })) + })()`, + ) + expect(await popup.click(byTestId(TID.basePromptAction))).toBe(true) + + // Cross-channel copy-update routes through the picker's inline-progress + // view (`resolveProgressRouting` → `'inline-picker'` for non-launch + // mutating ops). The popup stays open and streams progress in the right + // pane; `open-install-window` does NOT fire because handleCopyUpdate's + // `isChannelSwitch` branch omits `newInstallationId`. Poll + // `getInstallations` for the new record first (real ~500 MB copy → + // generous outer timeout). + const sourceIdsBefore = new Set(installsBefore.map((i) => i.id)) + await expect + .poll(async () => { + const all = await ctx.panel.evaluate>( + `window.api.getInstallations()`, + ) + return all.find((i) => !sourceIdsBefore.has(i.id)) ?? null + }, { timeout: 540_000, intervals: [2_000, 5_000] }) + .not.toBeNull() + + const installs = await ctx.panel.evaluate>( + `window.api.getInstallations()`, + ) + const newRecord = installs.find((i) => !sourceIdsBefore.has(i.id)) + expect(newRecord, 'copy-update installation not found in getInstallations').toBeDefined() + _copyUpdateInstallId = newRecord!.id + _copyUpdateInstallPath = newRecord!.installPath + + // Disk shape: copy is a full standalone tree (same shape as the + // picker pin-bottom Copy test asserts) — ComfyUI/.git + + // standalone-env + marker. Source dir is untouched. + expect(existsSync(path.join(_copyUpdateInstallPath, 'ComfyUI', '.git')), 'copy missing ComfyUI/.git').toBe(true) + expect(existsSync(path.join(_copyUpdateInstallPath, 'standalone-env')), 'copy missing standalone-env/').toBe(true) + expect(existsSync(path.join(_copyUpdateInstallPath, '.comfyui-desktop-2')), 'copy missing .comfyui-desktop-2 marker').toBe(true) + expect(existsSync(path.join(_updateInstallPath, 'ComfyUI', '.git')), 'source ComfyUI/.git missing after copy-update').toBe(true) + + // The install record exists after the COPY phase but the update-comfyui + // leg runs sequentially after — wait for the copy's HEAD to move past + // the source (master tip on `latest` is always ahead of any stable tag + // on this branch). Polling git on disk is the cheapest completion + // signal that doesn't require reaching into picker snapshot state. + const copyComfyUIDir = path.join(_copyUpdateInstallPath, 'ComfyUI') + let copyHeadAfter = sourceHeadBefore + await expect + .poll(() => { + copyHeadAfter = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: copyComfyUIDir, encoding: 'utf-8', windowsHide: true, + }).trim() + return copyHeadAfter + }, { timeout: 540_000, intervals: [2_000, 5_000] }) + .not.toBe(sourceHeadBefore) + expect(copyHeadAfter).toMatch(/^[a-f0-9]{40}$/) + expect(copyHeadAfter, 'cross-channel copy-update did not move the copy HEAD past the source').not.toBe(sourceHeadBefore) + + // The SOURCE's HEAD must NOT move — copy-update writes only to the new + // install. + + const sourceHeadAfter = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: _comfyUIDir, encoding: 'utf-8', windowsHide: true, + }).trim() + expect(sourceHeadAfter, 'source HEAD must NOT change when copy-update runs').toBe(sourceHeadBefore) + + // copy-update carried actionData.channel=latest on the picker + // background-op IPC (same Vue-reactive-proxy deep-clone bug class + // the cross-channel update-comfyui test below pins). Inline-picker + // routing dispatches via `pickerStartBackgroundOp`, recorded under + // `comfy-titlepopup:start-background-op` — not `run-action`. + interface BackgroundOpCall { + installationId?: string + actionId?: string + actionData?: { channel?: string; name?: string } + } + const bgCalls = (await getIpcInvocations(ctx.app, 'comfy-titlepopup:start-background-op')) as BackgroundOpCall[] + const copyUpdateCall = bgCalls.find( + (c) => c.installationId === _updateInstallId && c.actionId === 'copy-update', + ) + expect(copyUpdateCall, 'cross-channel copy-update not recorded').toBeDefined() + expect( + copyUpdateCall!.actionData?.channel, + 'cross-channel copy-update must carry actionData.channel=latest', + ).toBe('latest') + + // The new install record's updateChannel reflects the cross-channel + // pick (created on `latest`); source channel unchanged. + expect(newRecord?.updateChannel, 'new install must be on latest after cross-channel copy-update').toBe('latest') + const sourceAfter = installs.find((i) => i.id === _updateInstallId) + expect(sourceAfter?.updateChannel, 'source updateChannel must stay on stable').toBe('stable') + + // Cross-channel copy-update is `isChannelSwitch`, so handleCopyUpdate + // omits `newInstallationId` from the result and ProgressModal never + // calls `openInstallWindow` — no extra chooser host to close. +}) + +test('cleans up the copy-update install before the cross-channel update test runs @real', async () => { + test.setTimeout(300_000) + + // Rehydrate from the registry when running this test in isolation + // (--grep) against a profile where the prior copy-update test + // already ran and the in-process shared state was lost. Find by + // the literal name the prior test used — name match is unambiguous + // even when both installs share a `local` source. + if (!_copyUpdateInstallId) { + const all = await ctx.panel.evaluate>( + `window.api.getInstallations()`, + ) + const stray = all.find((i) => i.name === 'ComfyUI Copy-Update E2E' && i.installPath) + if (stray) { + _copyUpdateInstallId = stray.id + _copyUpdateInstallPath = stray.installPath + } + } + expect(_copyUpdateInstallId, 'no copy-update install id captured to clean up').toBeTruthy() + + // The dashboard kebab Delete path needs the chooser body visible. + // The prior test left the source install in whatever state the + // beforeAll hydration produced; when greped in isolation comfy is + // running on the source install, so flip back to the chooser before + // driving the kebab. + if (await comfyFrontendIsLoaded()) { + await returnToDashboardViaPickerHome(ctx) + } + + // Frees the ~500MB tree before the cross-channel update-comfyui + // test below flips the SOURCE install to latest. Same dashboard + // kebab → Delete path the picker-pin-bottom Copy cleanup uses. + await deleteInstallViaDashboardKebab(ctx, _copyUpdateInstallId) + + expect(existsSync(_copyUpdateInstallPath), `copy-update install dir ${_copyUpdateInstallPath} still on disk after delete`).toBe(false) + const remaining = await ctx.panel.evaluate(`window.api.getInstallations()`) + expect(remaining.find((i) => i.id === _copyUpdateInstallId), 'copy-update install record not removed after delete').toBeUndefined() + expect(remaining.find((i) => i.id === _updateInstallId), 'original install was unexpectedly removed').toBeDefined() +}) + // --------------------------------------------------------------------------- // Picker-driven update — driven through the picker's ChannelPicker. // Drafts a non-current channel ('latest') in the BaseSelect, clicks the @@ -702,7 +935,7 @@ test('captures a snapshot for the picker-driven restore test @lifecycle', async // beyond what's asserted below. // --------------------------------------------------------------------------- -test('picker-driven cross-channel update-comfyui (stable → latest) IN_PLACE_RELAUNCH while running @lifecycle', async () => { +test('picker-driven cross-channel update-comfyui (stable → latest) IN_PLACE_RELAUNCH while running @real', async () => { // Real cross-channel update: switches the install's `updateChannel` // from `stable` to `latest`, runs the master-branch update, then // relaunches in place. Stretch the timeout to cover a possible @@ -710,6 +943,12 @@ test('picker-driven cross-channel update-comfyui (stable → latest) IN_PLACE_RE // between the stable release and master. test.setTimeout(600_000) + // Prereq for individual --grep: cross-channel Update Now only + // surfaces in the picker against a running install. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } + // Sanity: install is on stable before drafting latest. const installsBefore = await ctx.panel.evaluate>( `window.api.getInstallations()`, @@ -724,21 +963,14 @@ test('picker-driven cross-channel update-comfyui (stable → latest) IN_PLACE_RE await resetIpcInvocations(ctx.app, 'stop-comfyui') await resetIpcInvocations(ctx.app, 'run-action') - // Open the picker in expanded mode on the Update tab. Channel - // metadata loads via real `check-update` against github.com for both - // stable and latest — `latest` reports an update against the master - // tip, so its cross-channel Update Now button comes alive. - await ctx.panel.evaluate( - `(() => { - window.api.openInstancePicker({ - installationId: ${JSON.stringify(_updateInstallId)}, - initialTab: 'update', - }) - return true - })()`, - ) - await waitForWebContents(ctx.app, 'comfyTitlePopup.html') - const popup = titlePopupPage(ctx.app) + // Open the picker on the Update tab via a real title-pill click + + // picker-row expand + tab click. Channel metadata loads via real + // `check-update` against github.com for both stable and latest — + // `latest` reports an update against the master tip, so its + // cross-channel Update Now button comes alive. + const popup = await openPickerByClickingTitlePill(ctx, { + installationId: _updateInstallId, initialTab: 'update', + }) // ChannelPicker renders a BaseSelect (`role="combobox"`); the // dropdown's options are `role="option"` with the channel label. @@ -821,10 +1053,16 @@ test('picker-driven cross-channel update-comfyui (stable → latest) IN_PLACE_RE expect(launchIdx, 'launch run-action should follow update-comfyui').toBeGreaterThan(0) }) -test('picker-driven snapshot-restore IN_PLACE_RELAUNCH while running @lifecycle', async () => { +test('picker-driven snapshot-restore IN_PLACE_RELAUNCH while running @real', async () => { test.setTimeout(600_000) expect(_restoreSnapshotFilename, 'restore-target snapshot not captured').toBeTruthy() + // Prereq for individual --grep: snapshot row Restore only surfaces + // in the picker against a running install. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } + // Move HEAD off the snapshot commit so the restore has work to do. // Use a parent of the snapshot commit so restore lands somewhere // different from the current working tree. @@ -839,17 +1077,9 @@ test('picker-driven snapshot-restore IN_PLACE_RELAUNCH while running @lifecycle' await resetIpcInvocations(ctx.app, 'stop-comfyui') await resetIpcInvocations(ctx.app, 'run-action') - await ctx.panel.evaluate( - `(() => { - window.api.openInstancePicker({ - installationId: ${JSON.stringify(_updateInstallId)}, - initialTab: 'snapshots', - }) - return true - })()`, - ) - await waitForWebContents(ctx.app, 'comfyTitlePopup.html') - const popup = titlePopupPage(ctx.app) + const popup = await openPickerByClickingTitlePill(ctx, { + installationId: _updateInstallId, initialTab: 'snapshots', + }) // Expand the snapshot row to reveal Restore. await popup.waitForSelector(byTestId(TID.snapshotRow(_restoreSnapshotFilename)), { timeout: 30_000 }) expect(await popup.click(byTestId(TID.snapshotRow(_restoreSnapshotFilename)))).toBe(true) @@ -904,15 +1134,22 @@ test('picker-driven snapshot-restore IN_PLACE_RELAUNCH while running @lifecycle' // invocation count for `stop-comfyui` stays at zero. // --------------------------------------------------------------------------- -test('picker compact-row Restart drives system-modal confirm + re-launch @lifecycle', async () => { +test('picker compact-row Restart drives system-modal confirm + re-launch @real', async () => { test.setTimeout(300_000) + // Prereq for individual --grep: the compact PickerRow CTA renders + // "Restart" only when the install is currently running. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } + await resetIpcInvocations(ctx.app, 'stop-comfyui') await resetIpcInvocations(ctx.app, 'run-action') - await ctx.panel.evaluate(`(() => { window.api.openInstancePicker(); return true })()`) - await waitForWebContents(ctx.app, 'comfyTitlePopup.html') - const popup = titlePopupPage(ctx.app) + // Open the picker in compact mode (no row expand) via a real + // title-pill click — the per-row Restart CTA lives directly on the + // collapsed row. + const popup = await openPickerByClickingTitlePill(ctx) // PickerRow renders its primary CTA as "Restart" when the install is // currently running — same test id either way. await popup.waitForSelector(byTestId(TID.pickerRowOpen(_updateInstallId)), { timeout: 15_000 }) @@ -959,31 +1196,26 @@ test('picker compact-row Restart drives system-modal confirm + re-launch @lifecy // one continuous op instead of stop→idle→launch flashes. // --------------------------------------------------------------------------- -test('picker pin-bottom Restart drives stop+launch under one "Restarting ComfyUI" progress title @lifecycle', async () => { +test('picker pin-bottom Restart drives stop+launch under one "Restarting ComfyUI" progress title @real', async () => { test.setTimeout(300_000) - // Sanity: prior compact-row Restart test left ComfyUI running. - await expect.poll(comfyFrontendIsLoaded, { timeout: 30_000, intervals: [500] }).toBe(true) + // Prereq for individual --grep: the pin-bottom Launch→Restart swap + // only renders when the install is currently running. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } const beforeSnapshot = await getRunningSessionSnapshot(ctx.app, _updateInstallId) expect(beforeSnapshot, 'expected a running session before pin-bottom Restart').not.toBeNull() await resetIpcInvocations(ctx.app, 'stop-comfyui') await resetIpcInvocations(ctx.app, 'run-action') - // Open the picker in expanded mode on the Settings/Config tab so the - // pin-bottom MoreMenu is visible. `initialTab: 'config'` matches the - // pin-bottom Copy test above. - await ctx.panel.evaluate( - `(() => { - window.api.openInstancePicker({ - installationId: ${JSON.stringify(_updateInstallId)}, - initialTab: 'config', - }) - return true - })()`, - ) - await waitForWebContents(ctx.app, 'comfyTitlePopup.html') - const popup = titlePopupPage(ctx.app) + // Open the picker on the Settings/Config tab via a real title-pill + // click + picker-row expand + tab click. The pin-bottom MoreMenu + // only renders inside the expanded-row layout. + const popup = await openPickerByClickingTitlePill(ctx, { + installationId: _updateInstallId, initialTab: 'config', + }) // Open the footer "More" overflow menu → the swap surfaces the // primary Launch item as `pin-bottom-action-restart` because the @@ -1053,16 +1285,19 @@ test('picker pin-bottom Restart drives stop+launch under one "Restarting ComfyUI let _copyInstallId = '' let _copyInstallPath = '' -test('picker pin-bottom Copy creates a real ~500MB copy of the install @lifecycle', async () => { +test('picker pin-bottom Copy creates a real ~500MB copy of the install @real', async () => { test.setTimeout(600_000) + // Prereq for individual --grep: ensure comfy is running so the + // file-menu Return to Dashboard has something to flip. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } + // Copy is REQUIRES_STOPPED — stop comfy via return-to-dashboard so // the IPC handler doesn't bail and the picker dispatches without a // self-stop preamble. - await returnFirstInstallHostToDashboard(ctx.app) - await expect.poll(comfyFrontendIsLoaded, { timeout: 30_000, intervals: [500] }).toBe(false) - await waitForWebContents(ctx.app, 'panel.html') - await expectChooserVisible(ctx.panel) + await returnToDashboardViaPickerHome(ctx) // Snapshot BrowserWindow ids before the copy fires. The copy emits // `open-install-window` for the NEW install, which (because no window @@ -1076,17 +1311,15 @@ test('picker pin-bottom Copy creates a real ~500MB copy of the install @lifecycl await resetIpcInvocations(ctx.app, 'open-install-window') await resetIpcInvocations(ctx.app, 'run-action') - await ctx.panel.evaluate( - `(() => { - window.api.openInstancePicker({ - installationId: ${JSON.stringify(_updateInstallId)}, - initialTab: 'config', - }) - return true - })()`, - ) - await waitForWebContents(ctx.app, 'comfyTitlePopup.html') - const popup = titlePopupPage(ctx.app) + // Drive the picker open via a real title-pill click — the chooser + // host's title bar still renders an interactive pill (with the + // `is-install-less` class), so this works from the dashboard too. + // Mount the panel via the file menu first so the chooser body is + // available as an IPC target throughout the rest of the test. + await ensureInstallPanelMounted(ctx, _updateInstallId) + const popup = await openPickerByClickingTitlePill(ctx, { + installationId: _updateInstallId, initialTab: 'config', + }) // Open the footer "More" overflow menu → click Copy. await popup.waitForVisible('[data-more-trigger]', { timeout: 15_000 }) @@ -1094,20 +1327,21 @@ test('picker pin-bottom Copy creates a real ~500MB copy of the install @lifecycl await popup.waitForVisible(byTestId(TID.pinBottomAction('copy')), { timeout: 10_000 }) expect(await popup.click(byTestId(TID.pinBottomAction('copy')))).toBe(true) - // Prompt for the copy's new name (rendered by ModalDialog's prompt - // branch inside the popup webContents). - await popup.waitForVisible(byTestId(TID.modalPromptInput), { timeout: 10_000 }) + // Prompt for the copy's new name (rendered by BasePrompt inside + // the popup webContents — the picker drives prompts through + // `useDialogs().prompt`, not the legacy `useModal().prompt`). + await popup.waitForVisible(byTestId(TID.basePromptInput), { timeout: 10_000 }) const newName = 'ComfyUI Copy E2E' await popup.evaluate( `(() => { - const el = document.querySelector(${JSON.stringify(byTestId(TID.modalPromptInput))}) + const el = document.querySelector(${JSON.stringify(byTestId(TID.basePromptInput))}) if (!el) throw new Error('prompt input not found') el.value = ${JSON.stringify(newName)} el.dispatchEvent(new Event('input', { bubbles: true })) el.dispatchEvent(new Event('change', { bubbles: true })) })()`, ) - expect(await popup.click(byTestId(TID.modalConfirm))).toBe(true) + expect(await popup.click(byTestId(TID.basePromptAction))).toBe(true) await waitForProgressTakeoverAfterPopupClose() @@ -1173,18 +1407,15 @@ test('picker pin-bottom Copy creates a real ~500MB copy of the install @lifecycl .toBe(0) }) -test('cleans up the copy install before the original delete test runs @lifecycle', async () => { +test('cleans up the copy install before the original delete test runs @real', async () => { test.setTimeout(300_000) expect(_copyInstallId, 'no copy install id captured to clean up').toBeTruthy() - // Direct runAction('delete') bypasses the confirm chain — the copy - // is stopped (never launched), so no `stop-comfyui` preamble is - // needed. Frees disk before the existing final delete test runs - // against the original. - const result = await ctx.panel.evaluate( - `window.api.runAction(${JSON.stringify(_copyInstallId)}, 'delete')`, - ) - expect(result.ok, `delete copy failed: ${result.message ?? ''}`).toBe(true) + // Delete via the dashboard kebab → Delete menu item → BaseAlert + // confirm → ProgressModal. The copy is stopped (never launched), so + // no `stop-comfyui` preamble is needed. Frees disk before the final + // delete test runs against the original. + await deleteInstallViaDashboardKebab(ctx, _copyInstallId) expect(existsSync(_copyInstallPath), `copy install dir ${_copyInstallPath} still on disk after delete`).toBe(false) const remaining = await ctx.panel.evaluate(`window.api.getInstallations()`) @@ -1211,7 +1442,7 @@ test('cleans up the copy install before the original delete test runs @lifecycle let _kebabCopyInstallId = '' let _kebabCopyInstallPath = '' -test('dashboard kebab "Copy Installation" creates a real ~500MB copy @lifecycle', async () => { +test('dashboard kebab "Copy Installation" creates a real ~500MB copy @real', async () => { test.setTimeout(600_000) // The prior cleanup test ran direct `runAction('delete')` against @@ -1244,19 +1475,19 @@ test('dashboard kebab "Copy Installation" creates a real ~500MB copy @lifecycle' // prompt for the new install name. await waitForWebContents(ctx.app, 'comfyTitlePopup.html') const popup = titlePopupPage(ctx.app) - await popup.waitForVisible(byTestId(TID.modalPromptInput), { timeout: 15_000 }) + await popup.waitForVisible(byTestId(TID.basePromptInput), { timeout: 15_000 }) const newName = 'ComfyUI Kebab Copy E2E' await popup.evaluate( `(() => { - const el = document.querySelector(${JSON.stringify(byTestId(TID.modalPromptInput))}) + const el = document.querySelector(${JSON.stringify(byTestId(TID.basePromptInput))}) if (!el) throw new Error('prompt input not found') el.value = ${JSON.stringify(newName)} el.dispatchEvent(new Event('input', { bubbles: true })) el.dispatchEvent(new Event('change', { bubbles: true })) })()`, ) - expect(await popup.click(byTestId(TID.modalConfirm))).toBe(true) + expect(await popup.click(byTestId(TID.basePromptAction))).toBe(true) // Picker hides; the panel's ProgressModal owns the copy op. await waitForProgressTakeoverAfterPopupClose() @@ -1328,7 +1559,7 @@ test('dashboard kebab "Copy Installation" creates a real ~500MB copy @lifecycle' .toBe(0) }) -test('dashboard kebab "Untrack" removes the install from the registry without touching disk @lifecycle', async () => { +test('dashboard kebab "Untrack" removes the install from the registry without touching disk @real', async () => { test.setTimeout(60_000) expect(_kebabCopyInstallId, 'no kebab-copy install id to untrack').toBeTruthy() expect(_kebabCopyInstallPath, 'no kebab-copy install path captured').toBeTruthy() @@ -1383,7 +1614,7 @@ test('dashboard kebab "Untrack" removes the install from the registry without to expect(remaining.find((i) => i.id === _updateInstallId), 'untrack must not affect the original install').toBeDefined() }) -test('cleans up the untracked kebab-copy on disk before the final Delete test runs @lifecycle', async () => { +test('cleans up the untracked kebab-copy on disk before the final Delete test runs @real', async () => { test.setTimeout(120_000) expect(_kebabCopyInstallPath, 'no kebab-copy install path to clean up').toBeTruthy() expect(existsSync(_kebabCopyInstallPath), 'kebab-copy dir already gone — Untrack test invariant violated').toBe(true) @@ -1420,14 +1651,16 @@ test('cleans up the untracked kebab-copy on disk before the final Delete test ru let _deleteInstallId = '' let _deleteInstallPath = '' -test('stops comfy and captures the installed dir state before driving delete @lifecycle', async () => { +test('stops comfy and captures the installed dir state before driving delete @real', async () => { // delete is in REQUIRES_STOPPED — stop comfy via return-to-dashboard so // the IPC handler doesn't bail on us. rtd preserves the chooser host so // we still have an IPC target for delete + getInstallations. - await returnFirstInstallHostToDashboard(ctx.app) - await expect.poll(comfyFrontendIsLoaded, { timeout: 30_000, intervals: [500] }).toBe(false) - await waitForWebContents(ctx.app, 'panel.html') - await expectChooserVisible(ctx.panel) + // Prereq for individual --grep: ensure comfy is running so the + // file-menu Return to Dashboard has something to flip. + if (!(await comfyFrontendIsLoaded())) { + await launchComfyByClickingTile(ctx, 'ComfyUI') + } + await returnToDashboardViaPickerHome(ctx) const installs = await ctx.panel.evaluate(`window.api.getInstallations()`) expect(installs.length, 'no tracked installation after install').toBeGreaterThan(0) @@ -1444,17 +1677,17 @@ test('stops comfy and captures the installed dir state before driving delete @li expect(existsSync(path.join(_deleteInstallPath, '.comfyui-desktop-2')), 'installed dir missing .comfyui-desktop-2 marker').toBe(true) }) -test('real delete wipes the fully-installed ~500MB tree off disk @lifecycle', async () => { +test('real delete wipes the fully-installed ~500MB tree off disk @real', async () => { // Recursive delete of a full standalone install can take a while on // Windows when files are large (the .venv ships thousands of small // files plus a few hundred-MB torch wheels). Stretch the timeout. test.setTimeout(300_000) expect(_deleteInstallPath, 'install path not captured').toBeTruthy() - const result = await ctx.panel.evaluate( - `window.api.runAction(${JSON.stringify(_deleteInstallId)}, 'delete')`, - ) - expect(result.ok, `runAction('delete') failed: ${result.message ?? ''}`).toBe(true) + // Delete via the dashboard kebab → Delete menu item → BaseAlert + // confirm → ProgressModal. The recursive fs.rm of the .venv (~thousands + // of small files plus the torch wheels) is the slow part. + await deleteInstallViaDashboardKebab(ctx, _deleteInstallId) // Disk verification — the entire install tree must be gone, not just // a few top-level entries. Probes both the root + a deep file the @@ -1466,3 +1699,4 @@ test('real delete wipes the fully-installed ~500MB tree off disk @lifecycle', as const remaining = await ctx.panel.evaluate(`window.api.getInstallations()`) expect(remaining.find((i) => i.id === _deleteInstallId), 'install record not removed after delete').toBeUndefined() }) + diff --git a/e2e/picker-settings-staleness.test.ts b/e2e/picker-settings-staleness.test.ts index fcad5a19..eaf45508 100644 --- a/e2e/picker-settings-staleness.test.ts +++ b/e2e/picker-settings-staleness.test.ts @@ -82,7 +82,7 @@ test.afterAll(async () => { if (installBPath) await rm(installBPath, { recursive: true, force: true }) }) -test('right pane clears stale sections when switching install A → B @lifecycle', async () => { +test('right pane clears stale sections when switching install A → B @ci', async () => { // Open the picker directly in expanded mode, pre-selected on A. // `openInstancePicker` is the same renderer-facing bridge the title // bar uses; we drive it from the panel so we don't have to chase diff --git a/e2e/picker-stop-confirm.test.ts b/e2e/picker-stop-confirm.test.ts index 36658055..0a2b1a51 100644 --- a/e2e/picker-stop-confirm.test.ts +++ b/e2e/picker-stop-confirm.test.ts @@ -153,7 +153,7 @@ async function forwardUpdateActionFromPicker(): Promise { ) } -test('Self-stops the running session and dispatches the action @lifecycle', async () => { +test('Self-stops the running session and dispatches the action @ci', async () => { await seedRunningSession(ctx.app, { installationId: INSTALL_ID, installationName: INSTALL_NAME, @@ -190,22 +190,22 @@ test('Self-stops the running session and dispatches the action @lifecycle', asyn intervals: [100, 250], }) .toBeGreaterThanOrEqual(1) + // Poll for the forwarded `update-comfyui` dispatch among the run-action + // invocations rather than the raw call count: the picker's auto- + // `check-update` watcher can land its run-action first against a + // freshly-mounted Update tab, and `stopAndWaitForExit` introduces + // a delay between stop-comfyui and the user-driven update dispatch. + // Filtering by actionId keeps this immune to both races. await expect - .poll(async () => (await getIpcInvocations(ctx.app, 'run-action')).length, { - timeout: 10_000, - intervals: [200, 500], - }) - .toBeGreaterThanOrEqual(1) - - // Find the forwarded `update-comfyui` dispatch among the run-action - // invocations. The picker's auto-`check-update` watcher can also fire - // a run-action against the freshly-mounted Update tab depending on - // release-cache freshness — index-based lookup would flake on that - // race, so we filter by actionId instead. + .poll(async () => { + const calls = await getIpcInvocations(ctx.app, 'run-action') as + { installationId?: string; actionId?: string }[] + return calls.some((c) => c.actionId === 'update-comfyui') + }, { timeout: 15_000, intervals: [200, 500] }) + .toBe(true) const runCalls = await getIpcInvocations(ctx.app, 'run-action') as { installationId?: string; actionId?: string }[] const updateCall = runCalls.find((c) => c.actionId === 'update-comfyui') - expect(updateCall).toBeDefined() expect(updateCall?.installationId).toBe(INSTALL_ID) // stop-comfyui fires exactly once — duplicate stops would point at a @@ -215,7 +215,7 @@ test('Self-stops the running session and dispatches the action @lifecycle', asyn expect(stopCalls.length).toBe(1) }) -test('Skips self-stop when the install is NOT running @lifecycle', async () => { +test('Skips self-stop when the install is NOT running @ci', async () => { // Same forward, but no seeded running session. The panel must skip // the stop-comfyui step (nothing to stop) AND skip the relaunch (no // session was open to begin with — the user wouldn't expect a diff --git a/e2e/progress-error-overflow.test.ts b/e2e/progress-error-overflow.test.ts index 81942c65..b023be97 100644 --- a/e2e/progress-error-overflow.test.ts +++ b/e2e/progress-error-overflow.test.ts @@ -64,7 +64,7 @@ test.afterAll(async () => { if (installPath) await rm(installPath, { recursive: true, force: true }) }) -test('ProgressModal error block caps its height and scrolls @lifecycle', async () => { +test('ProgressModal error block caps its height and scrolls @ci', async () => { // Drive the renderer-side dev hook: opens the ProgressModal overlay // for the seeded install and resolves the apiCall immediately with // `{ ok: false, message: LONG_ERROR }` so the store writes the long diff --git a/e2e/quit-flow.spec.ts b/e2e/quit-flow.spec.ts index 9c8d0747..e009416e 100644 --- a/e2e/quit-flow.spec.ts +++ b/e2e/quit-flow.spec.ts @@ -4,7 +4,7 @@ import { launchLauncherApp, waitForAppExit } from './support/electronHarness' test.describe('Launcher Quit Flow', () => { test.skip(process.platform !== 'darwin', 'Regression is specific to macOS quit lifecycle') - test('app.quit exits cleanly while tray-close mode is active @macos', async () => { + test('app.quit exits cleanly while tray-close mode is active @ci @macos-only', async () => { const { application, cleanup } = await launchLauncherApp() try { const launcherWindow = await application.firstWindow() diff --git a/e2e/support/electronHarness.ts b/e2e/support/electronHarness.ts index ef332767..2cc93f3b 100644 --- a/e2e/support/electronHarness.ts +++ b/e2e/support/electronHarness.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { mkdir, mkdtemp, readFile, rm } from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import process from 'node:process' @@ -155,6 +155,27 @@ export async function launchLauncherApp(options?: SeedOptions): Promise | undefined + if (reuseDir) { + try { + const raw = await readFile(path.join(appDataDir, 'settings.json'), 'utf-8') + const parsed: unknown = JSON.parse(raw) + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + persistedSettings = parsed as Record + } + } catch { /* no persisted settings yet (empty reuse dir) */ } + } + const mergedSettings: Record | undefined = + persistedSettings || options?.settings + ? { ...(options?.settings ?? {}), ...(persistedSettings ?? {}) } + : undefined + // Expose a CDP remote-debugging port so tests can connect to non-BrowserWindow // webContents (e.g. the ComfyUI WebContentsView) via chromium.connectOverCDP(). // Derive port from Playwright worker index to avoid collisions in parallel runs. @@ -169,7 +190,7 @@ export async function launchLauncherApp(options?: SeedOptions): Promise = { + update: 'Update', + snapshots: 'Snapshots', + config: 'Startup Args', +} + +/** Fully-installed standalone ComfyUI install state. Populated by + * `freshInstallStandaloneCpu` (real flow) or `hydrateInstallFromDisk` + * (reuse-mode shortcut). */ +export interface HydratedInstall { + id: string + installPath: string + comfyUIDir: string + /** git rev-parse HEAD at hydration time. Empty when the working tree + * hasn't been touched yet. */ + installedCommit: string +} + +interface InstallationLite { + id: string + installPath: string +} + +/** True iff a webContents with a localhost URL exists and is loaded — + * the canonical "ComfyUI is up" signal across @real tests. */ +export async function comfyFrontendIsLoaded(ctx: AppContext): Promise { + return ctx.app.evaluate(({ webContents }) => + webContents.getAllWebContents().some((wc) => + /^http:\/\/(127\.0\.0\.1|localhost):/.test(wc.getURL()) && !wc.isLoading(), + ), + ) +} + +/** Drive the first-use takeover end-to-end to a fully-installed standalone + * ComfyUI on the OLDEST stable release (so downstream update tests always + * see "Update available"). On Windows pins the CPU variant; on macOS/Linux + * trusts the recommended variant the host detected. + * + * Returns the hydrated install state. + * + * Inputs: real DOM clicks / value-set events on visible form fields. + * No `__e2e.*` writes. No `window.api.*` mutations. Reads via + * `window.api.getInstallations()` are observation only and allowed. */ +export async function freshInstallStandaloneCpu( + ctx: AppContext, +): Promise { + // ------------------------------------------------------------- + // Step 1 — first-use start screen + // ------------------------------------------------------------- + await ctx.panel.waitForVisible('.start-hero', { timeout: 15_000 }) + await ctx.panel.waitForVisible('[data-testid="first-use-pick-local"]') + + expect(await ctx.panel.click('[data-testid="first-use-pick-local"]')).toBe(true) + await ctx.panel.waitForVisible('[data-testid="first-use-express-install"]', { timeout: 5_000 }) + + // Express defaults to checked on Local pick — uncheck to force the + // New Install Tier 3 takeover path (covered here) instead of the + // express short-circuit (covered by FirstUseTakeover.test.ts unit). + await ctx.panel.evaluate( + `(() => { + const wrap = document.querySelector('[data-testid="first-use-express-install"]') + const cb = wrap && wrap.querySelector('input[type="checkbox"]') + if (cb && cb.checked) cb.click() + })()`, + ) + + expect(await ctx.panel.click('[data-testid="first-use-consent-tos"]')).toBe(true) + await ctx.panel.waitFor( + async () => ctx.panel.evaluate( + `!document.querySelector('[data-testid="first-use-continue"]').disabled`, + ), + { timeout: 5_000, message: 'Continue never became enabled after ticking ToS' }, + ) + expect(await ctx.panel.click('[data-testid="first-use-continue"]')).toBe(true) + + // ------------------------------------------------------------- + // Step 2 — New Install Configure screen + // ------------------------------------------------------------- + await ctx.panel.waitForVisible('.brand-takeover-root', { timeout: 10_000 }) + await ctx.panel.waitFor( + async () => ctx.app.evaluate(({ webContents }) => { + const wc = webContents.getAllWebContents().find((w) => w.getURL().includes('panel.html')) + if (!wc) return false + return wc.executeJavaScript(`(() => { + const btn = document.querySelector('.brand-primary.config-continue') + return !!btn && !btn.disabled + })()`) as Promise + }), + { timeout: 60_000, message: 'Continue button never became enabled (form did not pre-fill)' }, + ) + + // Expand Advanced + pick the OLDEST stable release so downstream + // update tests have something to update to. + expect(await ctx.panel.click('.config-advanced__summary')).toBe(true) + await ctx.panel.waitForSelector('#source-fields button[role="combobox"]', { timeout: 5_000 }) + expect(await ctx.panel.click('#source-fields button[role="combobox"]')).toBe(true) + await ctx.panel.waitForVisible('[role="listbox"] [role="option"]', { timeout: 10_000 }) + expect( + await ctx.panel.evaluate( + `(() => { + const opts = document.querySelectorAll('[role="listbox"] [role="option"]') + if (opts.length === 0) return false + opts[opts.length - 1].click() + return true + })()`, + ), + 'failed to click oldest release option', + ).toBe(true) + + // Variant re-resolves when release changes — wait for Continue to come back. + await ctx.panel.waitFor( + async () => ctx.app.evaluate(({ webContents }) => { + const wc = webContents.getAllWebContents().find((w) => w.getURL().includes('panel.html')) + if (!wc) return false + return wc.executeJavaScript(`(() => { + const btn = document.querySelector('.brand-primary.config-continue') + return !!btn && !btn.disabled + })()`) as Promise + }), + { timeout: 60_000, message: 'Continue never re-enabled after picking oldest release' }, + ) + + // CPU pin on Windows only (macOS publishes only mac-mps, linux only GPU variants). + if (process.platform === 'win32') { + await ctx.panel.waitForSelector('.brand-variant-row', { timeout: 5_000 }) + expect( + await ctx.panel.clickByText('.brand-variant-row', 'CPU'), + 'CPU variant row clicked', + ).toBe(true) + await ctx.panel.waitFor( + async () => ctx.panel.evaluate( + `(() => { + const sel = document.querySelector('.brand-variant-row--selected .brand-variant-row__label') + return !!sel && /CPU/i.test(sel.textContent || '') + })()`, + ), + { timeout: 5_000, message: 'CPU variant did not become the selected variant row' }, + ) + } + + // ------------------------------------------------------------- + // Step 3 — Continue + wait for install + auto-launch + // ------------------------------------------------------------- + expect(await ctx.panel.clickByText('.brand-primary', 'Continue')).toBe(true) + await ctx.panel.waitForVisible('.brand-progress', { timeout: 10_000 }) + + // ~500 MB download + extract + auto-launch. Generous timeout because + // CI runners and dev boxes vary wildly. + await expect + .poll(() => comfyFrontendIsLoaded(ctx), { timeout: 480_000, intervals: [1_000, 2_000] }) + .toBe(true) + + return await captureInstallStateFromDisk(ctx) +} + +/** True iff the install on disk has the minimal pieces `handleLaunch` + * needs: `ComfyUI/main.py` and either `ComfyUI/.venv/Scripts/python.exe` + * (Windows) / `ComfyUI/.venv/bin/python3` (POSIX) — matches the layout + * produced by `createEnv` in `src/main/sources/standalone/install.ts`. + * + * A registry entry whose disk state is missing these pieces is + * considered corrupt (typical cause: a prior run's terminal delete + * test was interrupted partway through wiping the install tree) and + * must not be silently hydrated — launching it would fail with the + * "no Python environment found" error and the test would time out + * blaming the launch, not the corrupt state. */ +function isLaunchableInstall(installPath: string): boolean { + const comfyMain = path.join(installPath, 'ComfyUI', 'main.py') + const venvPython = process.platform === 'win32' + ? path.join(installPath, 'ComfyUI', '.venv', 'Scripts', 'python.exe') + : path.join(installPath, 'ComfyUI', '.venv', 'bin', 'python3') + return existsSync(comfyMain) && existsSync(venvPython) +} + +/** Read the live install state off disk (id + path from + * `getInstallations`, commit from `git rev-parse`). Used by both + * `freshInstallStandaloneCpu` (after a fresh install) and + * `hydrateInstallFromDisk` (reuse-mode). */ +async function captureInstallStateFromDisk(ctx: AppContext): Promise { + const installs = await ctx.panel.evaluate>( + `window.api.getInstallations()`, + ) + // Prefer the original install over any copies the lifecycle suite + // leaves behind. Copies carry `copiedFromName`; the source install + // does not. Within each group prefer entries whose disk state is + // actually launchable so a partially-deleted leftover from a prior + // run doesn't shadow a fresh source install that's still intact. + const locals = installs.filter((i) => typeof i.installPath === 'string' && i.installPath.length > 0) + const sources = locals.filter((i) => !i.copiedFromName) + const pickFrom = sources.length > 0 ? sources : locals + const local = pickFrom.find((i) => isLaunchableInstall(i.installPath)) ?? pickFrom[0] + if (!local) throw new Error('no local install record found in getInstallations()') + + const comfyUIDir = path.join(local.installPath, 'ComfyUI') + let installedCommit = '' + try { + installedCommit = execFileSync('git', ['rev-parse', 'HEAD'], { + cwd: comfyUIDir, encoding: 'utf-8', windowsHide: true, + }).trim() + } catch { /* partial hydration — git dir may not exist on a half-built profile */ } + + return { + id: local.id, + installPath: local.installPath, + comfyUIDir, + installedCommit, + } +} + +/** Click the named install tile on the chooser and wait for ComfyUI to load. */ +export async function launchComfyByClickingTile( + ctx: AppContext, + nameSubstring: string, +): Promise { + await clickInstallTile(ctx.panel, nameSubstring) + await expect + .poll(() => comfyFrontendIsLoaded(ctx), { timeout: 180_000, intervals: [1_000, 2_000] }) + .toBe(true) +} + +/** Click the title-bar instance pill → picker → `.picker-home` Home + * icon to flip an install-backed host back to the chooser body. The + * Home icon dispatches `bridge.activate('return-to-dashboard')` — + * the production equivalent of the removed file-menu item (the + * install-host file menu was trimmed in #497, with the Home icon in + * the picker chips row as the sole escape hatch). + * + * Waits for the chooser body to reappear (in-place flip — same window + * id, panel.html is rebuilt). */ +export async function returnToDashboardViaPickerHome(ctx: AppContext): Promise { + await ctx.titleBar.waitForVisible('.title-install-pill', { timeout: 15_000 }) + expect(await ctx.titleBar.click('.title-install-pill'), 'title-install-pill click dispatched').toBe(true) + await waitForWebContents(ctx.app, 'comfyTitlePopup.html') + + const popup = titlePopupPage(ctx.app) + // `.picker-home` only renders on install-hosted pickers (the + // dashboard's own picker hides it — `isInstallHost` v-if). The + // test surface comfy is running, so the picker IS install-hosted. + await popup.waitForVisible('.picker-home', { timeout: 10_000 }) + expect(await popup.click('.picker-home'), '.picker-home click').toBe(true) + + // `returnToDashboard` confirms the "Stop & Return" prompt on one + // of two surfaces for a running local install — the install-backed + // panel webContents when it is still alive (Settings / lifecycle + // visited since launch), otherwise a shell system modal because + // the chooser-pick attach destroyed the panelView. Both render a + // BaseAlert with the same TID; poll either surface. Either page's + // `exists` throws while its webContents doesn't exist yet, so wrap + // each probe in a swallow-on-throw. + const sysModal = systemModalPage(ctx.app) + const probe = async (page: WebContentsPage): Promise => { + try { return await page.exists(byTestId(TID.baseAlertAction)) } + catch { return false } + } + let confirmSurface: WebContentsPage | null = null + await expect.poll(async () => { + if (await probe(ctx.panel)) { confirmSurface = ctx.panel; return true } + if (await probe(sysModal)) { confirmSurface = sysModal; return true } + return false + }, { timeout: 10_000, intervals: [200, 400, 800], message: 'return-to-dashboard confirm did not appear on panel or system modal' }).toBe(true) + expect(await confirmSurface!.click(byTestId(TID.baseAlertAction)), 'return-to-dashboard confirm clicked').toBe(true) + + // After the flip the comfyView no longer loads a localhost URL and + // panel.html is rebuilt as the chooser body. + await expect + .poll(() => comfyFrontendIsLoaded(ctx), { timeout: 30_000, intervals: [500] }) + .toBe(false) + await waitForWebContents(ctx.app, 'panel.html') + await expectChooserVisible(ctx.panel) +} + +/** Top-level prereq for any @real test that needs a fully-installed + * + currently-launched ComfyUI. Hydrates from `LIFECYCLE_REUSE_DIR` + * when set (skipping the ~500 MB download), otherwise drives a real + * install via `freshInstallStandaloneCpu`. + * + * Always leaves the app in the same observable state on return: + * ComfyUI is running, the install-backed panel view is mounted, and + * the returned `HydratedInstall` matches what's on disk. */ +export async function ensureInstalledAndLaunched( + ctx: AppContext, +): Promise { + const reuseDir = process.env['LIFECYCLE_REUSE_DIR'] + if (reuseDir) { + // Wait briefly for the chooser to mount. On a hydrated profile + // firstUseCompleted is already true so we land on the chooser body + // directly. On an empty reuse dir we'd fall through to the install path. + try { + await ctx.panel.waitForVisible('.chooser-view', { timeout: 10_000 }) + } catch { /* fresh boot may still be on the first-use takeover */ } + + let hydrated: HydratedInstall | null = null + try { + hydrated = await captureInstallStateFromDisk(ctx) + } catch { /* no install yet — fall through */ } + + // Skip hydration when the picked install isn't actually launchable + // on disk (typical cause: a prior suite run's terminal delete test + // was interrupted and left the install tree in a partial state). + // Clicking the tile would fail with the "no Python environment + // found" error and the test would time out blaming the launch. + // Fall through to a fresh install instead. + if (hydrated && !isLaunchableInstall(hydrated.installPath)) { + console.log(`[lifecycle-harness] hydration skipped — install at ${hydrated.installPath} is missing ComfyUI/main.py or .venv (likely partial-delete leftover from a prior run); running fresh install`) + hydrated = null + } + + if (hydrated) { + // Hydrated profile: launch the existing install via real click + // and let the test proceed from a "ComfyUI running" surface. + await launchComfyByClickingTile(ctx, 'ComfyUI') + await ensureInstallPanelMounted(ctx, hydrated.id) + // Re-capture so installedCommit reflects the post-launch HEAD + // (no-op on stable installs, useful when the previous run left + // the working tree at a different commit). + return await captureInstallStateFromDisk(ctx) + } + } + + // Cold start: drive the full first-use install. The install flow + // auto-launches into ComfyUI as its terminal step. + const installed = await freshInstallStandaloneCpu(ctx) + // Auto-launch dropped the panel.html (chooser-pick attach destroys + // it). Rebuild it so subsequent ctx.panel.evaluate calls land. + await ensureInstallPanelMounted(ctx, installed.id) + return installed +} + +/** Force-build the install-backed `panel.html` so subsequent + * `ctx.panel.evaluate` reads (`window.api.getInstallations`, + * `window.api.getSnapshots`, …) reach a live renderer. + * + * After a chooser-pick attach the install-backed PanelApp is + * destroyed (`destroyPanelView(claimed)` in `main/index.ts`) and only + * re-built on demand — Settings, comfy-lifecycle progress, etc. The + * install-host file menu was trimmed (#497) so there is no longer a + * user-facing gesture whose sole job is "mount the panel", and the + * picker popup is its own webContents (does NOT trigger panel mount). + * + * Resolves the gap with `__e2e.ensureInstallPanelView(installationId)`, + * which invokes the same idempotent `ensurePanelView(..., 'comfy-lifecycle')` + * production code path the next user interaction would run. This is a + * *read-trigger* primitive — no fake test data is seeded — and is + * scoped to this support file so test bodies never reach for `__e2e.*` + * directly. No-op when the panel is already mounted. */ +export async function ensureInstallPanelMounted( + ctx: AppContext, installationId: string, +): Promise { + try { + await waitForWebContents(ctx.app, 'panel.html', 1_000) + return + } catch { /* fall through to the forced mount */ } + expect( + await e2eEnsureInstallPanelView(ctx.app, installationId), + 'main rejected ensurePanelView — installation entry missing or window destroyed', + ).toBe(true) + await waitForWebContents(ctx.app, 'panel.html', 10_000) +} + +/** Click the title-bar `.title-install-pill` to open the InstancePicker + * popup. The pill renders on both install-backed and install-less + * (chooser) hosts as an interactive `