171 lines · 7.3 KB · TypeScript
RawDownload
// 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 integration 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 });        });      }),    );  }),);