diff --git a/.github/workflows/server-ci.yml b/.github/workflows/server-ci.yml index 5ee7027..88c9ec2 100644 --- a/.github/workflows/server-ci.yml +++ b/.github/workflows/server-ci.yml @@ -64,7 +64,10 @@ jobs: NODE_ENV: test DOTENV_CONFIG_PATH: .env.test NODE_OPTIONS: --require=dotenv/config - run: pnpm test -- --run --json --outputFile=test-results.json + # `--run` is a Vitest flag; Jest 29 aborts with "Unrecognized option + # \"run\"" before running a single suite, so no test-results.json was + # written and the JUnit conversion below failed on the missing file. + run: pnpm test -- --json --outputFile=test-results.json - name: Convert Jest results to JUnit XML if: always() diff --git a/frontend/app/manifest.test.ts b/frontend/app/manifest.test.ts new file mode 100644 index 0000000..2642e68 --- /dev/null +++ b/frontend/app/manifest.test.ts @@ -0,0 +1,59 @@ +/** + * PWA manifest screenshots (issue #293). + * + * Chrome's Android install sheet only shows previews when the manifest has a + * `screenshots` array whose entries are tagged with a `form_factor`, so the + * assertions below pin both the metadata and the actual files on disk — a + * manifest pointing at a missing or wrong-sized image silently degrades to + * the minimal install banner. + */ + +import fs from "fs"; +import path from "path"; + +import manifest from "./manifest"; + +const PUBLIC_DIR = path.join(process.cwd(), "public"); + +/** Reads the width/height out of a PNG's IHDR chunk. */ +function pngSize(file: string): { width: number; height: number } { + const buf = fs.readFileSync(file); + // 8-byte signature, then a length (4) + "IHDR" (4), then width/height. + expect(buf.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + expect(buf.subarray(12, 16).toString("ascii")).toBe("IHDR"); + return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) }; +} + +describe("manifest screenshots", () => { + const screenshots = manifest().screenshots ?? []; + + it("declares at least two screenshots", () => { + expect(screenshots.length).toBeGreaterThanOrEqual(2); + }); + + it("tags every screenshot as a narrow (mobile) viewport", () => { + for (const shot of screenshots) { + expect(shot).toMatchObject({ form_factor: "narrow" }); + } + }); + + it("describes each screenshot as a portrait mobile image", () => { + for (const shot of screenshots) { + expect(shot.type).toBe("image/png"); + expect(shot.sizes).toBe("1080x1920"); + expect(shot.src).toMatch(/^\/screenshots\/.+\.png$/); + } + }); + + it.each((manifest().screenshots ?? []).map((s) => [s.src, s] as const))( + "%s exists on disk at the declared size", + (src, shot) => { + const file = path.join(PUBLIC_DIR, src.replace(/^\//, "")); + expect(fs.existsSync(file)).toBe(true); + + const { width, height } = pngSize(file); + const [w, h] = (shot.sizes ?? "").split("x").map(Number); + expect({ width, height }).toEqual({ width: w, height: h }); + } + ); +}); diff --git a/frontend/app/manifest.ts b/frontend/app/manifest.ts index 2927bf9..cf37721 100644 --- a/frontend/app/manifest.ts +++ b/frontend/app/manifest.ts @@ -1,6 +1,42 @@ import type { MetadataRoute } from "next"; import { SITE_NAME, SITE_DESCRIPTION } from "./lib/seo"; +/** + * Screenshot descriptor accepted by the W3C Web App Manifest spec. + * + * Next's `MetadataRoute.Manifest` types only cover `src`/`sizes`/`type`, but + * Chrome's richer install prompt keys off `form_factor` (and the `label` that + * goes with it). Extending the entry type keeps the manifest fully typed while + * still being assignable to Next's narrower shape. + */ +type Screenshot = NonNullable[number] & { + form_factor?: "narrow" | "wide"; + label?: string; +}; + +/** + * Phone-sized previews shown in Chrome's "Add to Home Screen" sheet + * (issue #293). Both are 1080x1920 (9:16), the portrait viewport the app + * targets, and both are tagged `form_factor: "narrow"` so Chrome treats them + * as mobile screenshots in the richer install prompt. + */ +const SCREENSHOTS: Screenshot[] = [ + { + src: "/screenshots/signup-mobile.png", + sizes: "1080x1920", + type: "image/png", + form_factor: "narrow", + label: "Create an account with your phone number", + }, + { + src: "/screenshots/sell-mobile.png", + sizes: "1080x1920", + type: "image/png", + form_factor: "narrow", + label: "Create a listing and set your own rate", + }, +]; + /** * Web App Manifest (issue #107). * @@ -40,5 +76,6 @@ export default function manifest(): MetadataRoute.Manifest { purpose: "maskable", }, ], + screenshots: SCREENSHOTS, }; } diff --git a/frontend/app/wallet/WithdrawModal.tsx b/frontend/app/wallet/WithdrawModal.tsx index 82d84dd..a1f6c50 100644 --- a/frontend/app/wallet/WithdrawModal.tsx +++ b/frontend/app/wallet/WithdrawModal.tsx @@ -494,6 +494,10 @@ export default function WithdrawModal({ {/* Submit button */}