Step 11.1: installable PWA
CI / check (push) Successful in 6m11s Details

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
fireshaper 2026-10-05 17:53:56 -04:00
parent 6b56af7d1e
commit feb84aecaa
18 changed files with 3464 additions and 23 deletions

View File

@ -32,3 +32,7 @@ jobs:
- run: pnpm typecheck - run: pnpm typecheck
- run: pnpm lint - run: pnpm lint
- run: pnpm coverage - run: pnpm coverage
# Browser tests against the production build (Phase 11: PWA and offline).
- run: pnpm --filter @deadhead/web build
- run: pnpm --filter @deadhead/web exec playwright install --with-deps chromium
- run: pnpm --filter @deadhead/web e2e

3
.gitignore vendored
View File

@ -6,3 +6,6 @@ coverage/
.env.local .env.local
.DS_Store .DS_Store
.pnpm-store/ .pnpm-store/
# Playwright output
apps/web/test-results/
apps/web/playwright-report/

View File

@ -1240,3 +1240,11 @@ Implementation decisions not spelled out in `memory-bank/implementation-plan.md`
- **Why:** after the deal a fresh player couldn't fuel the Courier and the Clipper from Earth at once (~921 cr against ~890). At 2.1 the pair costs ~645 cr (Courier 161, Clipper 484), and the scripted fresh-account check now flies both together with no top-up. - **Why:** after the deal a fresh player couldn't fuel the Courier and the Clipper from Earth at once (~921 cr against ~890). At 2.1 the pair costs ~645 cr (Courier 161, Clipper 484), and the scripted fresh-account check now flies both together with no top-up.
- **2.1, not 2:** Jupiter Cloud Platform is 2, and Earth must stay the most expensive launch (GDD §launch cost, `locations.test.ts`). Only Earth's value changed. - **2.1, not 2:** Jupiter Cloud Platform is 2, and Earth must stay the most expensive launch (GDD §launch cost, `locations.test.ts`). Only Earth's value changed.
- **Effects:** every departure from Earth costs 30 % less fuel; payouts don't use the multiplier, so the difference is profit, and the deal loan is paid off sooner. Moon launches (0.5) stay 4.2× cheaper than Earth. The Step 3.x fuel hand calculation now uses 2.1. - **Effects:** every departure from Earth costs 30 % less fuel; payouts don't use the multiplier, so the difference is profit, and the deal loan is paid off sooner. Moon launches (0.5) stay 4.2× cheaper than Earth. The Step 3.x fuel hand calculation now uses 2.1.
## Step 11.1
- **Installability is tested with Chrome's own check**: Lighthouse removed its PWA category in version 12, so the planned "Lighthouse PWA audit" can't run. The Playwright test calls the DevTools `Page.getInstallabilityErrors`, which is what that audit reported, and expects an empty list.
- **Browser tests run against `vite preview` of the real build**, with no API server; installability and caching don't need one. They live in `apps/web/e2e/*.e2e.ts` (not picked up by Vitest) and Phase 12's end-to-end tests can reuse the setup.
- **Icons are generated placeholders** (the Courier sprite on `#080d1d`), with the maskable icon keeping the ship inside the 80 % safe zone. Replace by putting a logo through `scripts/icons.mjs`.
- **Precache is ~6.7 MB**, mostly the @2x map art the CR-003 amendment asks for. Only woff2 fonts are cached (every supporting browser uses them before woff).
- **Auto-update**: a new build's service worker takes over on the next load without a prompt; there is no player data in the cache to lose.

View File

@ -0,0 +1,59 @@
import { expect, test } from "@playwright/test";
test.describe("installable PWA (Step 11.1)", () => {
test("Chrome reports no installability errors", async ({ page, context }) => {
await page.goto("/");
await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
// The check Lighthouse's PWA audit used (Lighthouse 12+ no longer has one).
const cdp = await context.newCDPSession(page);
const { installabilityErrors } = await cdp.send("Page.getInstallabilityErrors");
expect(installabilityErrors).toEqual([]);
});
test("has a standalone manifest with the game's name and icons", async ({ request }) => {
const manifest = await (await request.get("/manifest.webmanifest")).json();
expect(manifest).toMatchObject({
name: "Deadhead: Space Freight Tycoon",
display: "standalone",
theme_color: "#080d1d",
});
const sizes = manifest.icons.map((i: { sizes: string; purpose?: string }) => i.sizes);
expect(sizes).toEqual(expect.arrayContaining(["192x192", "512x512"]));
expect(manifest.icons.some((i: { purpose?: string }) => i.purpose === "maskable")).toBe(true);
for (const icon of manifest.icons) expect((await request.get(icon.src)).ok()).toBe(true);
});
test("precaches the app shell, fonts and generated map art, not the source art", async ({
page,
}) => {
await page.goto("/");
await page.evaluate(() => navigator.serviceWorker.ready.then(() => undefined));
// Wait for the install to finish filling the precache.
await expect
.poll(async () =>
page.evaluate(async () => {
const urls: string[] = [];
for (const name of await caches.keys()) {
for (const r of await (await caches.open(name)).keys()) urls.push(r.url);
}
return urls;
}),
)
.toEqual(
expect.arrayContaining([
expect.stringContaining("/index.html"),
expect.stringMatching(/\/assets\/map-gen\/body-earth@2x\.png/),
expect.stringMatching(/\/assets\/.*chakra-petch.*\.woff2/),
expect.stringMatching(/\/assets\/.*barlow.*\.woff2/),
]),
);
const urls = await page.evaluate(async () => {
const all: string[] = [];
for (const name of await caches.keys()) {
for (const r of await (await caches.open(name)).keys()) all.push(r.url);
}
return all;
});
expect(urls.filter((u) => u.includes("/assets/map/"))).toEqual([]);
});
});

View File

@ -3,6 +3,9 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#080d1d" />
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png" />
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png" />
<title>Deadhead: Space Freight Tycoon</title> <title>Deadhead: Space Freight Tycoon</title>
</head> </head>
<body> <body>

View File

@ -8,7 +8,9 @@
"build": "node scripts/map-art.mjs --if-available && vite build", "build": "node scripts/map-art.mjs --if-available && vite build",
"map-art": "node scripts/map-art.mjs", "map-art": "node scripts/map-art.mjs",
"preview": "vite preview", "preview": "vite preview",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit",
"icons": "node scripts/icons.mjs",
"e2e": "playwright test"
}, },
"dependencies": { "dependencies": {
"@deadhead/content": "workspace:*", "@deadhead/content": "workspace:*",
@ -25,6 +27,7 @@
"zustand": "^5.0.15" "zustand": "^5.0.15"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.63.0",
"@tailwindcss/vite": "^4.3.3", "@tailwindcss/vite": "^4.3.3",
"@testing-library/jest-dom": "^7.0.1", "@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3", "@testing-library/react": "^16.3.3",
@ -35,6 +38,8 @@
"jsdom": "^30.1.1", "jsdom": "^30.1.1",
"sharp": "^0.35.5", "sharp": "^0.35.5",
"tailwindcss": "^4.3.3", "tailwindcss": "^4.3.3",
"vite": "^8.3.2" "vite": "^8.3.2",
"vite-plugin-pwa": "^2.0.0",
"workbox-window": "^7.4.1"
} }
} }

View File

@ -0,0 +1,21 @@
import { defineConfig, devices } from "@playwright/test";
const PORT = 4173;
/**
* Browser tests against the production build (Phase 11): service worker,
* install and offline behaviour can't be tested in jsdom. Run `pnpm build`
* first; `pnpm e2e` serves dist/ with `vite preview`.
*/
export default defineConfig({
testDir: "e2e",
testMatch: "**/*.e2e.ts",
fullyParallel: false,
retries: process.env.CI ? 1 : 0,
use: { baseURL: `http://127.0.0.1:${PORT}`, ...devices["Desktop Chrome"] },
webServer: {
command: `pnpm exec vite preview --port ${PORT} --strictPort --host 127.0.0.1`,
url: `http://127.0.0.1:${PORT}`,
reuseExistingServer: !process.env.CI,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@ -0,0 +1,50 @@
// Generates the PWA and browser icons (Step 11.1) from the Courier sprite on the
// app's space background. Placeholder art until the user supplies a logo.
//
// node scripts/icons.mjs writes public/icons/*.png
import { mkdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const webRoot = join(dirname(fileURLToPath(import.meta.url)), "..");
const outDir = join(webRoot, "public/icons");
/** --color-space-900 in src/index.css: the theme and background color. */
export const BACKGROUND = "#080d1d";
/**
* Each icon: size in px and how much of it the ship fills. Maskable icons keep
* the ship inside the central 80 % safe zone, so they fill less.
*/
export const ICONS = [
{ file: "icon-192.png", size: 192, fill: 0.78 },
{ file: "icon-512.png", size: 512, fill: 0.78 },
{ file: "maskable-512.png", size: 512, fill: 0.56 },
{ file: "apple-touch-icon.png", size: 180, fill: 0.7 },
{ file: "favicon-32.png", size: 32, fill: 0.9 },
];
sharp.cache(false);
mkdirSync(outDir, { recursive: true });
const source = readFileSync(join(webRoot, "public/assets/map/ship-courier.png"));
const trimmed = await sharp(source).trim().toBuffer();
for (const { file, size, fill } of ICONS) {
const box = Math.round(size * fill);
const ship = await sharp(trimmed)
.resize(box, box, { fit: "inside" })
.toBuffer({ resolveWithObject: true });
await sharp({
create: { width: size, height: size, channels: 4, background: BACKGROUND },
})
.composite([
{
input: ship.data,
left: Math.round((size - ship.info.width) / 2),
top: Math.round((size - ship.info.height) / 2),
},
])
.png({ compressionLevel: 9 })
.toFile(join(outDir, file));
}
console.log(`icons: wrote ${ICONS.length} files to ${outDir}`);

View File

@ -5,5 +5,5 @@
"jsx": "react-jsx", "jsx": "react-jsx",
"types": ["vite/client"] "types": ["vite/client"]
}, },
"include": ["src", "test", "vite.config.ts", "vitest.config.ts"] "include": ["src", "test", "e2e", "vite.config.ts", "vitest.config.ts", "playwright.config.ts"]
} }

View File

@ -5,6 +5,45 @@ import { readFileSync } from "node:fs";
import { rm } from "node:fs/promises"; import { rm } from "node:fs/promises";
import { join } from "node:path"; import { join } from "node:path";
import { defineConfig, type Plugin } from "vite"; import { defineConfig, type Plugin } from "vite";
import { VitePWA } from "vite-plugin-pwa";
/** --color-space-900: the app's background, used for the splash and browser chrome. */
const THEME_COLOR = "#080d1d";
/**
* Installable PWA (Step 11.1): manifest, and a service worker that precaches the
* app shell, the self-hosted fonts (CR-005) and the generated map art (CR-003).
* API calls are never cached; offline behaviour is Step 11.2.
*/
const pwa = VitePWA({
registerType: "autoUpdate",
injectRegister: "auto",
includeAssets: ["icons/favicon-32.png", "icons/apple-touch-icon.png"],
manifest: {
name: "Deadhead: Space Freight Tycoon",
short_name: "Deadhead",
description: "Run a freight company across the solar system.",
theme_color: THEME_COLOR,
background_color: THEME_COLOR,
display: "standalone",
orientation: "any",
start_url: "/",
scope: "/",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
},
workbox: {
globPatterns: ["**/*.{js,css,html,woff2,png,webp,svg}"],
// The source art is only a build input (decision 0.5.37); never precache it.
globIgnores: ["assets/map/**"],
navigateFallback: "/index.html",
navigateFallbackDenylist: [/^\/api\//],
cleanupOutdatedCaches: true,
},
});
/** /**
* The user's source map art (public/assets/map) is large and only an input to * The user's source map art (public/assets/map) is large and only an input to
@ -45,7 +84,7 @@ function buildInfo() {
export default defineConfig({ export default defineConfig({
define: buildInfo(), define: buildInfo(),
plugins: [react(), tailwindcss(), omitSourceArt()], plugins: [react(), tailwindcss(), omitSourceArt(), pwa],
server: { server: {
// Same-domain API in development too (decision 0.5.20). The server listens on // Same-domain API in development too (decision 0.5.20). The server listens on
// IPv4 only, so target 127.0.0.1 rather than localhost. // IPv4 only, so target 127.0.0.1 rather than localhost.

View File

@ -648,16 +648,16 @@ Steps 10M.1–10M.5 are specified in `memory-bank/change-requests/CR-013-veteran
### Step 11.1 — Installable PWA ### Step 11.1 — Installable PWA
- Add `vite-plugin-pwa` with a manifest (name "Deadhead: Space Freight Tycoon", icons, theme color, standalone display) and a service worker that caches the app shell. - Add `vite-plugin-pwa` with a manifest (name "Deadhead: Space Freight Tycoon", icons, theme color, standalone display) and a service worker that caches the app shell.
- **Test:** A Lighthouse PWA audit in CI reports the app as installable. Manual check: the app installs to a phone home screen and launches standalone. - **Test:** A Lighthouse PWA audit in CI reports the app as installable. *(As built: Lighthouse 12+ has no PWA category, so a Playwright test asks Chrome directly — `Page.getInstallabilityErrors`, the check that audit used — and expects none.)* Manual check: the app installs to a phone home screen and launches standalone.
- **Amended by CR-003:** The service worker precaches the generated map assets in `public/assets/map-gen/` (not the source art in `public/assets/map/`, which the build omits). - **Amended by CR-003:** The service worker precaches the generated map assets in `public/assets/map-gen/` (not the source art in `public/assets/map/`, which the build omits).
- **Amended by CR-005:** The self-hosted font files (`@fontsource` Chakra Petch and Barlow, bundled into `dist/assets`) are part of the offline cache. - **Amended by CR-005:** The self-hosted font files (`@fontsource` Chakra Petch and Barlow, bundled into `dist/assets`) are part of the offline cache.
### Step 11.2 — Offline shell behavior ### Step 11.2 — Offline shell behavior
- When offline, the app loads from cache and shows the last known state with a clear "Offline — showing last update" banner. In-flight progress bars keep advancing from cached dispatch records. Actions are disabled until back online. - When offline, the app loads from cache and shows the last known state with a clear "Offline — showing last update" banner. In-flight progress bars keep advancing from cached dispatch records. Actions are disabled until back online.
- **Test:** Playwright test with network disabled after first load: the app renders, the offline banner shows, progress bars advance, and action buttons are disabled. - **Test:** Playwright test with network disabled after first load: the app renders, the offline banner shows, progress bars advance, and action buttons are disabled.
- **Amended by CR-011:** Settings that need the server (rename, backup, restore, new company, developer tools) are disabled while offline; display and help settings still work.
--- ---
- **Amended by CR-011:** Settings that need the server (rename, backup, restore, new company, developer tools) are disabled while offline; display and help settings still work.
## Phase 12 — Balance Harness and End-to-End Tests ## Phase 12 — Balance Harness and End-to-End Tests

View File

@ -296,3 +296,8 @@ Spec: `memory-bank/change-requests/CR-013-veterans-clipper.md` (received as "CR-
**Phase 10M complete — waiting for review.** **Phase 10M complete — waiting for review.**
- [x] **Follow-up: Earth launch cost 3 → 2.1** (user decision). Fixes the post-deal fuel shortfall: the fresh-account check (deal, Courier and Clipper in flight together, Clipper Priority delivery unlocking goal 4) passes with no developer top-up. Fuel hand-calculation test updated. - [x] **Follow-up: Earth launch cost 3 → 2.1** (user decision). Fixes the post-deal fuel shortfall: the fresh-account check (deal, Courier and Clipper in flight together, Clipper Priority delivery unlocking goal 4) passes with no developer top-up. Fuel hand-calculation test updated.
## Phase 11 — PWA
- [x] **Step 11.1 — Installable PWA.** `vite-plugin-pwa` 2.0 (generateSW, auto-update): manifest "Deadhead: Space Freight Tycoon" / "Deadhead", standalone, theme and background `#080d1d`, icons 192, 512 and maskable 512 (plus favicon and Apple touch icon in `index.html`). The service worker precaches the app shell, the self-hosted font files (woff2) and all 25 generated map images (64 entries, ~6.7 MB); source art in `assets/map/` is excluded; `/api` is never served from the cache. Icons come from `scripts/icons.mjs` (`pnpm --filter @deadhead/web icons`): the Courier sprite on the space background, placeholder until there is a logo. Playwright is set up for browser tests against the production build (`apps/web/playwright.config.ts`, `e2e/*.e2e.ts`, `pnpm --filter @deadhead/web e2e`), and CI builds the web app, installs Chromium and runs them. Tests (`e2e/install.e2e.ts`): Chrome reports no installability errors; the manifest has the name, standalone display, both icon sizes and a maskable icon, all served; the precache holds index.html, map-gen art and both fonts, and no source art. **Manual check for the user:** install to a phone home screen and launch standalone.
- [ ] Step 11.2 — Offline shell behavior

File diff suppressed because it is too large Load Diff