deadhead-space-freight-tycoon/apps/web/e2e/offline.e2e.ts

140 lines
5.0 KiB
TypeScript

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();
});
});