// Local-only: the "a newer Executor is published" nudge, on BOTH surfaces a // user meets it. A forced dist-tags signal (`EXECUTOR_NPM_DIST_TAGS`) stands in // for the npm registry so the check is deterministic and offline: // // 1. CLI: `executor web --foreground` prints an "Update available" line // under its ready banner (recorded as terminal.cast). // 2. Web: the same server's `/v1/app/npm/dist-tags` lights up the shell's // sidebar UpdateCard (captured as a step screenshot). // // Both read the one resolver in `@executor-js/api` (update-check.ts), so the // terminal line and the sidebar card can never disagree about the verdict. import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; import { expect } from "@effect/vitest"; import { Effect } from "effect"; import { scenario } from "../src/scenario"; import { Browser, Cli, RunDir, Target } from "../src/services"; import { withLocalServer } from "./local-server"; const repoRoot = fileURLToPath(new URL("../../", import.meta.url)); // A version far above anything we'd really publish, so the running build is // unambiguously "behind" regardless of the actual package version. const FORCED_LATEST = "99.0.0"; const DIST_TAGS = JSON.stringify({ latest: FORCED_LATEST, beta: `${FORCED_LATEST}-beta.1` }); scenario( "Local update · the CLI ready banner nudges to upgrade when a newer version is published", { timeout: 180_000 }, Effect.gen(function* () { const cli = yield* Cli; const runDir = yield* RunDir; const dataDir = mkdtempSync(join(tmpdir(), "executor-update-cli-")); yield* cli .session( ["bun", "run", "apps/cli/src/main.ts", "web", "--foreground", "--port", "0"], async (term) => { // The notice prints right under the "Executor is ready." banner, just // before "Press Ctrl+C to stop.": wait for that line so we know the // whole banner (notice included) has rendered. const snapshot = await term.screen.waitUntil( (current) => current.text.includes("Press Ctrl+C to stop"), { timeoutMs: 120_000 }, ); const screen = snapshot.text; expect(screen, "the banner announces an available update").toContain("Update available"); expect(screen, "it names the published version").toContain(FORCED_LATEST); expect(screen, "it prints the upgrade command").toContain("npm i -g executor@latest"); // Graceful shutdown so the PTY closes instead of leaking the server. await term.keyboard.press("Control+C"); }, { cwd: repoRoot, env: { EXECUTOR_DATA_DIR: dataDir, EXECUTOR_SCOPE_DIR: dataDir, EXECUTOR_NPM_DIST_TAGS: DIST_TAGS, }, record: join(runDir, "terminal.cast"), viewport: { cols: 120, rows: 40 }, }, ) .pipe(Effect.ensuring(Effect.sync(() => rmSync(dataDir, { recursive: true, force: true })))); }), ); scenario( "Local update · the web shell sidebar surfaces the update-available card", { timeout: 180_000 }, Effect.gen(function* () { const cli = yield* Cli; const browser = yield* Browser; const target = yield* Target; const runDir = yield* RunDir; const identity = yield* target.newIdentity(); yield* withLocalServer( cli, runDir, ({ url }) => browser.session(identity, async ({ page, step }) => { await step("Open the console via the CLI's ?_token URL", async () => { await page.goto(url, { waitUntil: "domcontentloaded" }); // The console renders past the auth gate: the Integrations page and // its built-in Executor source row load (proves auth + data, not // just the static shell). await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); await page.getByText("Tool providers available").waitFor({ timeout: 30_000 }); }); await step("The sidebar surfaces the update-available card", async () => { // The card renders once useLatestVersion resolves the forced // dist-tags served by /v1/app/npm/dist-tags. await page.getByText("Update available").waitFor({ timeout: 30_000 }); await page.getByText(`v${FORCED_LATEST}`).waitFor({ timeout: 5_000 }); await page .getByText("npm i -g executor@latest", { exact: true }) .first() .waitFor({ timeout: 5_000 }); }); }), { env: { EXECUTOR_NPM_DIST_TAGS: DIST_TAGS } }, ); }), ); scenario( "Local update · the desktop bridge shows a native restart-to-update card, not the npm command", { timeout: 180_000 }, Effect.gen(function* () { const cli = yield* Cli; const browser = yield* Browser; const target = yield* Target; const runDir = yield* RunDir; const identity = yield* target.newIdentity(); // Drives the SAME shell the Electron renderer loads, with a stand-in for the // desktop preload bridge (window.executor). A real packaged Electron run is // covered by e2e/desktop/update-card.test.ts; this proves the renderer half // (native card shown, npm command suppressed) in the browser harness. yield* withLocalServer(cli, runDir, ({ url }) => browser.session(identity, async ({ page, step }) => { await page.addInitScript((version: string) => { let status = { state: "downloaded", version }; const subscribers: Array<(next: typeof status) => void> = []; Object.assign(window, { executor: { getUpdateStatus: () => Promise.resolve(status), onUpdateStatus: (cb: (next: typeof status) => void) => { subscribers.push(cb); return () => {}; }, installUpdate: () => { status = { state: "installing", version }; for (const cb of subscribers) cb(status); return Promise.resolve(); }, }, }); }, FORCED_LATEST); await step("Open the desktop console", async () => { await page.goto(url, { waitUntil: "domcontentloaded" }); await page.getByRole("heading", { name: "Integrations" }).waitFor({ timeout: 60_000 }); await page.getByText("Tool providers available").waitFor({ timeout: 30_000 }); }); await step("The sidebar shows a native Restart to update card", async () => { await page.getByText("Update available").waitFor({ timeout: 30_000 }); await page.getByText(`v${FORCED_LATEST}`).waitFor({ timeout: 5_000 }); await page.getByRole("button", { name: "Restart to update" }).waitFor({ timeout: 5_000 }); // The desktop card never shows the npm command the web/CLI card does. expect( await page.getByText("npm i -g", { exact: false }).count(), "the desktop card shows no npm command", ).toBe(0); }); await step("Clicking Restart drives the native install action", async () => { await page.getByRole("button", { name: "Restart to update" }).click(); await page.getByText("Restarting…").waitFor({ timeout: 5_000 }); }); }), ); }), );