import type { Dispatch, PlayerState } from "@deadhead/schema"; import { expect, test, type Page } from "@playwright/test"; import { samplePlayerState, shipExtras } from "../test/fixtures"; /** * A company with one ship in flight (Earth → Moon, 20 minutes left) and one idle * at Earth, as the server would return it. */ function midgame(now: number): PlayerState { const base = samplePlayerState({ tutorial: null }); const flight: Dispatch = { id: "d1", shipId: "s-fly", pilotId: "p2", contract: null, originId: "earth", destinationId: "moon", tutorial: false, departureAt: now - 60_000, arrivalAt: now + 20 * 60_000, distanceMillionKm: 0.38, fuelCost: 160, expectedPayout: 0, seed: 1, status: "in-flight", resolvedAt: null, }; return { ...base, company: { id: "co1", name: "Deadhead Freight", credits: 50_000, loanBalance: 0 }, bays: [{ id: "b1", locationId: "earth" }], ships: [ { id: "s-idle", modelId: "courier", name: "Dusty Mule", status: "idle", locationId: "earth", dispatchId: null, pilotId: "avatar", lastArrivalAt: 0, ...shipExtras("courier"), }, { id: "s-fly", modelId: "clipper", name: "Slow Ox", status: "in-flight", locationId: null, dispatchId: "d1", pilotId: "p2", lastArrivalAt: 0, ...shipExtras("clipper"), }, ], pilots: [ { ...base.pilots[0]!, assignedShipId: "s-idle" }, { ...base.pilots[0]!, id: "p2", name: "Rae Okafor", isAvatar: false, assignedShipId: "s-fly", status: "in-flight", locationId: null, }, ], activeDispatches: [flight], }; } /** Stands in for the API server; with `offline` set, every request fails as if unplugged. */ async function fakeServer(page: Page) { const server = { offline: false, state: midgame(Date.now()) }; await page.route("**/api/**", async (route) => { if (server.offline) return route.abort("internetdisconnected"); const path = new URL(route.request().url()).pathname; if (path === "/api/state") return route.fulfill({ json: server.state }); if (path === "/api/time") return route.fulfill({ json: { realTime: Date.now(), gameTime: 0 } }); if (path.endsWith("/board")) { return route.fulfill({ json: { contracts: [], periodEndsAt: Date.now() + 600_000 } }); } return route.fulfill({ status: 404, json: { error: { code: "not-found", message: "Not in this test" } }, }); }); return server; } const progress = async (page: Page) => Number(await page.getByRole("progressbar").first().getAttribute("aria-valuenow")); test.describe("offline shell (Step 11.2)", () => { test("opens from the cache with the last state, keeps flights moving, and blocks actions", async ({ page, context, }) => { const server = await fakeServer(page); await page.goto("/fleet"); await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible(); await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined)); await expect(page.getByRole("button", { name: /Deadhead/ })).toBeEnabled(); // Network off, then a full reload: everything must come from the device. server.offline = true; await context.setOffline(true); await page.reload(); await expect(page.getByRole("heading", { level: 1, name: "Fleet" })).toBeVisible(); await expect(page.getByText(/Offline — showing last update from/)).toBeVisible(); await expect(page.getByText("Slow Ox")).toBeVisible(); // The flight's progress bar keeps advancing from the cached dispatch. const before = await progress(page); await expect.poll(() => progress(page), { timeout: 10_000 }).toBeGreaterThan(before); // Server actions are disabled; reading and navigating still work. await expect(page.getByRole("button", { name: /Deadhead/ })).toBeDisabled(); await page.getByRole("link", { name: "Shop" }).first().click(); await expect(page.getByRole("button", { name: "Buy Courier" })).toBeDisabled(); // Settings (CR-011): server-backed ones off, display ones still work. await page.goto("/settings").catch(() => undefined); await expect(page.getByRole("heading", { level: 1, name: "Settings" })).toBeVisible(); for (const name of ["Rename", "Back up company", "Restore a company", "Start a new company"]) { await expect(page.getByRole("button", { name })).toBeDisabled(); } const clock24 = page.getByRole("radio", { name: "24-hour" }); await page.getByText("24-hour", { exact: true }).click(); await expect(clock24).toBeChecked(); // Back online: the banner goes and actions return. server.offline = false; await context.setOffline(false); await expect(page.getByText(/Offline — showing last update/)).toHaveCount(0); await expect(page.getByRole("button", { name: "Rename" })).toBeEnabled(); }); });