diff --git a/.github/workflows/ward-flow.yml b/.github/workflows/ward-flow.yml index e788f4262..c7ce0b48c 100644 --- a/.github/workflows/ward-flow.yml +++ b/.github/workflows/ward-flow.yml @@ -377,15 +377,26 @@ jobs: - name: Seed release system packages if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" + # WebKit's own packages too, so the offline install covers both engines when the mirror is down. + # Same path as the save step, because the path is part of the cache version. + - name: Restore release WebKit system packages + if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} + uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 + with: + path: ~/.cache/ward-release-apt + key: ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}-${{ steps.shared-version.outputs.image }} + restore-keys: | + ward-release-apt-${{ runner.os }}-webkit-${{ steps.shared-version.outputs.version }}- + - name: Seed release WebKit system packages + if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} + run: node scripts/ward-ci-public/apt-archive-cache.mjs seed "$HOME/.cache/ward-release-apt" - name: Install Chromium and WebKit for the shared-mode journeys id: shared-chromium timeout-minutes: 10 if: ${{ !cancelled() && steps.install.outcome == 'success' && steps.backend-install.outcome == 'success' }} env: PLAYWRIGHT_CACHE_HIT: ${{ steps.shared-cache.outputs.cache-hit }} - run: | - node scripts/ward-ci-public/install-chromium.mjs - node node_modules/playwright/cli.js install --with-deps webkit + run: node scripts/ward-ci-public/install-chromium.mjs chromium webkit - name: Run the shared-mode browser journey timeout-minutes: 20 if: ${{ !cancelled() && steps.shared-chromium.outcome == 'success' }} diff --git a/knip.json b/knip.json index 76b004981..139a2f86a 100644 --- a/knip.json +++ b/knip.json @@ -21,5 +21,5 @@ } }, "ignoreDependencies": ["@axe-core/playwright", "playwright-core", "rimraf", "tsx"], - "ignoreBinaries": ["pgrep", "openssl", "jq"] + "ignoreBinaries": ["pgrep", "openssl", "jq", "dpkg", "dpkg-query"] } diff --git a/scripts/ward-ci-public/install-chromium.mjs b/scripts/ward-ci-public/install-chromium.mjs index cdaa7da06..61efdca64 100644 --- a/scripts/ward-ci-public/install-chromium.mjs +++ b/scripts/ward-ci-public/install-chromium.mjs @@ -1,6 +1,8 @@ import { spawnSync } from "node:child_process"; +import { existsSync, readdirSync } from "node:fs"; import { fileURLToPath, pathToFileURL } from "node:url"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; +import { APT_ARCHIVES } from "./apt-archive-cache.mjs"; const root = fileURLToPath(new URL("../../", import.meta.url)); const cli = resolve(root, "node_modules/playwright/cli.js"); @@ -21,7 +23,10 @@ export function waitForPackageManager(deadline, now = Date.now) { /** * Six-minute shared budget, two attempts per operation; the workflow adds an eight-minute outer bound. - * @param {{ browsers?: string[], cacheHit?: boolean, execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options + * When the seed step has left cached system packages in apt's archive folder, they are installed + * first with no network at all, then each browser must launch; only if that fails does the setup + * fall back to Playwright's own apt-get, which needs the runner's Ubuntu mirror. + * @param {{ browsers?: string[], cacheHit?: boolean, cachedDebs?: string[], execute?: (command: string, args: string[], timeoutMs: number) => { status: number | null, error?: { code?: string }, signal?: string | null }, now?: () => number, log?: (message: string) => void, waitForIdle?: (deadline: number) => void }} options */ export function installBrowsers({ browsers = ["chromium"], @@ -30,6 +35,7 @@ export function installBrowsers({ now = Date.now, log = console.log, waitForIdle = execute === runBounded ? (deadline) => waitForPackageManager(deadline, now) : () => {}, + cachedDebs = execute === runBounded ? withoutDowngrades(archiveDebs()) : [], } = {}) { if ( !browsers.length || @@ -39,6 +45,7 @@ export function installBrowsers({ throw new Error("Invalid browser selection; name unique supported engines"); const label = browsers.length === 1 && browsers[0] === "chromium" ? "Chromium" : "Playwright browser"; const deadline = now() + 360_000; + if (cachedDebs.length && installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) === 0) return 0; const operations = cacheHit ? [ ["install-deps", ...browsers], @@ -67,6 +74,92 @@ export function installBrowsers({ return 0; } +/** The cached .deb files the seed step copied in; none when the cache missed. */ +export function archiveDebs(dir = APT_ARCHIVES) { + return existsSync(dir) + ? readdirSync(dir) + .filter((name) => name.endsWith(".deb")) + .sort() + .map((name) => join(dir, name)) + : []; +} + +/** Each installed package's version, keyed by "name arch", read once from dpkg. */ +function installedVersions() { + const result = spawnSync("dpkg-query", ["-W", "-f", "${Package} ${Architecture} ${Version}\\n"], { + encoding: "utf8", + }); + const versions = new Map(); + for (const line of result.status === 0 ? result.stdout.split("\n") : []) { + const [name, arch, version] = line.split(" "); + if (version) versions.set(`${name} ${arch}`, version); + } + return versions; +} + +/** True when dpkg orders version `a` before version `b`. */ +function olderThan(a, b) { + return spawnSync("dpkg", ["--compare-versions", a, "lt", b]).status === 0; +} + +/** + * A cache saved on an older runner image can hold a package older than the one installed. Naming + * that file asks apt for a downgrade, which `-y` refuses, and the whole offline install fails. Leave + * such files out; archive names are `name_version_arch.deb`, with an epoch's colon written `%3a`. + */ +export function withoutDowngrades(debs, installed = installedVersions(), isOlder = olderThan) { + return debs.filter((path) => { + const parts = path + .split("/") + .pop() + .replace(/\.deb$/, "") + .split("_"); + if (parts.length !== 3) return true; + const [name, rawVersion, arch] = parts; + const current = installed.get(`${name} ${arch}`); + const version = decodeURIComponent(rawVersion); + return !current || current === version || !isOlder(version, current); + }); +} + +/** A fixed script; the Playwright path and the engine names arrive as arguments, never as code. */ +const LAUNCH_PROBE = `const [playwright, ...names] = process.argv.slice(1); +const pw = require(playwright); +(async () => { + for (const name of names) { + const browser = await pw[name].launch(); + await browser.close(); + } +})().catch((error) => { + console.error(error); + process.exit(1); +});`; + +/** + * The runner's Ubuntu mirror can be unreachable for hours (11 Nov 2026: "Temporary failure + * resolving 'azure.archive.ubuntu.com'"), and Playwright's install-deps always runs apt-get update + * first. Install the cached packages as local files with downloads switched off, fetch the browsers + * alone, and pass only if every browser launches. Any failure hands back to the online path. + */ +function installOffline({ browsers, cachedDebs, execute, now, deadline, label, log }) { + log(`${label} setup: installing ${cachedDebs.length} cached system packages without the network.`); + const steps = [ + ["sudo", ["apt-get", "install", "-y", "--no-download", "--no-install-recommends", ...cachedDebs], 120_000], + [process.execPath, [cli, "install", ...browsers], 120_000], + [process.execPath, ["-e", LAUNCH_PROBE, resolve(root, "node_modules/playwright"), ...browsers], 60_000], + ]; + for (const [command, args, limit] of steps) { + const remaining = deadline - now(); + const result = remaining > 0 ? execute(command, args, Math.min(limit, remaining)) : undefined; + if (!result || result.error || result.status !== 0) { + log(`${label} setup: the offline install did not finish; trying the runner's apt mirror.`); + return 1; + } + } + log(`${label} setup: installed from cached system packages; every browser launches.`); + return 0; +} + // Existing consumers retain their default Chromium-only command and retry budget. /** Compatibility entry point for callers requesting Chromium only. @param {Parameters[0]} [options] */ export function installChromium(options) { diff --git a/src/components/ward-management/coordinator/coordinator-screen.tsx b/src/components/ward-management/coordinator/coordinator-screen.tsx index 9267d0609..6dcc0fa97 100644 --- a/src/components/ward-management/coordinator/coordinator-screen.tsx +++ b/src/components/ward-management/coordinator/coordinator-screen.tsx @@ -74,6 +74,7 @@ import cards from "./home-cards.module.css"; import styles from "./home.module.css"; import { HomeEdPressure } from "./home-ed-pressure"; import { PlannedList } from "./home-activity-lazy"; +import { useHomeCardsSettled } from "./home-cards-settled"; import { useHomeLayout } from "./home-layout"; import { BedsGapCard, HomePlacementDrawer, HomeReleaseSheet, MyListCard } from "./home-list-lazy"; import list from "./home-list.module.css"; @@ -592,6 +593,8 @@ export function CoordinatorScreen() { // Plan item 2i: the hero's Act now is main's service-scoped Act now figure. const heroActNow = actNowCount; const chosenAlert = selectedAlertId ? alertEntries.find((entry) => entry.item.id === selectedAlertId) : undefined; + // Layout shift on Home (11 Nov 2026): the columns appear once the cards above My list and Later today have arrived. + const cardsSettled = useHomeCardsSettled(!isPhone && !chosenAlert); const releaseAlert = releaseAlertId ? alertEntries.find((entry) => entry.item.id === releaseAlertId) : undefined; const waitEntries = alertEntries.filter((entry) => entry.group === "wait"); // Home flow mock-up (Q340): a waiting alert is a mark on its patient's Flow row. @@ -974,6 +977,7 @@ export function CoordinatorScreen() { data-testid="ward-coordinator-region-grid" data-layout={layout} data-shortlist-open={hasPanelSubject} + data-cards-pending={cardsSettled ? undefined : "true"} ref={queueFocusRef} >
diff --git a/src/components/ward-management/coordinator/home-cards-settled.ts b/src/components/ward-management/coordinator/home-cards-settled.ts new file mode 100644 index 000000000..597f38f5d --- /dev/null +++ b/src/components/ward-management/coordinator/home-cards-settled.ts @@ -0,0 +1,43 @@ +"use client"; + +import { useCallback, useEffect, useState, useSyncExternalStore } from "react"; + +import { bedsGapCard } from "./home-list-lazy"; +import { sinceBanner } from "./home-since-banner-lazy"; + +/** Home never stays hidden longer than this for a card that is slow or fails to load. */ +const HOME_CARDS_WAIT_MS = 1500; + +/** + * Layout shift on Home (11 Nov 2026): Home draws on the client, and the two cards it shows at rest + * from split code (the "since you were last here" line, and Beds that fit off a phone) landed a + * moment after My list and Later today and pushed them down (0.106 on a desktop, 0.064 on a phone). + * Home's columns now stay hidden, still taking their space, until those cards have arrived, then + * appear whole. Both are fetched as soon as Home's code runs, so the wait is normally nothing. + * After `HOME_CARDS_WAIT_MS` Home shows anyway and the late card drops in, as before. + */ +export function useHomeCardsSettled(needBeds: boolean): boolean { + const subscribe = useCallback( + (listener: () => void) => { + const stopBanner = sinceBanner.subscribe(listener); + const stopBeds = needBeds ? bedsGapCard.subscribe(listener) : () => undefined; + return () => { + stopBanner(); + stopBeds(); + }; + }, + [needBeds], + ); + const loaded = useSyncExternalStore( + subscribe, + () => sinceBanner.ready() && (!needBeds || bedsGapCard.ready()), + () => false, + ); + const [waitedOut, setWaitedOut] = useState(false); + useEffect(() => { + if (loaded || waitedOut) return; + const timer = window.setTimeout(() => setWaitedOut(true), HOME_CARDS_WAIT_MS); + return () => window.clearTimeout(timer); + }, [loaded, waitedOut]); + return loaded || waitedOut; +} diff --git a/src/components/ward-management/coordinator/home-list-lazy.tsx b/src/components/ward-management/coordinator/home-list-lazy.tsx index b1ada57cb..9918d2efb 100644 --- a/src/components/ward-management/coordinator/home-list-lazy.tsx +++ b/src/components/ward-management/coordinator/home-list-lazy.tsx @@ -2,6 +2,9 @@ import dynamic from "next/dynamic"; +import { WF_PHONE_QUERY } from "@/components/wf"; + +import { loadEarly } from "../load-early"; import { preloadWhenIdle } from "../preload-when-idle"; // A1K-01 / DR-03 (26 Oct 2026): the server draws My list's rows before their code arrives, so their // styles load with Home. Without them the rows paint as plain text, then grow into cards and push @@ -15,7 +18,6 @@ import "@/components/ward-management/alerts/alerts.module.css"; * idle, as the shortlist panels are (see shortlist-panel-lazy.tsx). */ const loadPlacementDrawer = () => import("./home-placement-drawer"); -const loadBedsGapCard = () => import("./beds-gap-card"); /* * A1K-01 (26 Oct 2026): My list is the first thing on Home, so it now loads with the page. As a lazy @@ -27,10 +29,22 @@ export { MyListCard } from "./my-list-card"; export const HomePlacementDrawer = dynamic(() => loadPlacementDrawer().then((m) => m.HomePlacementDrawer), { loading: () => null, }); -export const BedsGapCard = dynamic(() => loadBedsGapCard().then((m) => m.BedsGapCard), { loading: () => null }); + +/* + * Beds that fit sits above Later today at rest on a wider screen. Arriving late, it pushed Later + * today down about 390 px (layout shift on Home, 11 Nov 2026), so off a phone it is fetched as soon + * as Home's code runs and Home holds its columns until it arrives (`useHomeCardsSettled`). A phone + * never shows it, so a phone still fetches it only when idle. + */ +export const bedsGapCard = loadEarly(() => import("./beds-gap-card").then((m) => m.BedsGapCard)); +export const BedsGapCard = bedsGapCard.Card; preloadWhenIdle(loadPlacementDrawer); -preloadWhenIdle(loadBedsGapCard); +const wide = + typeof window !== "undefined" && + (typeof window.matchMedia !== "function" || !window.matchMedia(WF_PHONE_QUERY).matches); +if (wide) bedsGapCard.start(); +else preloadWhenIdle(async () => bedsGapCard.start()); // H-0977: Home's in-place "Release bed" sheet, fetched on first use so it stays off the first load. export const HomeReleaseSheet = dynamic(() => import("./home-release-sheet").then((m) => m.HomeReleaseSheet), { diff --git a/src/components/ward-management/coordinator/home-since-banner-lazy.tsx b/src/components/ward-management/coordinator/home-since-banner-lazy.tsx index 83e52f2a4..8eb312ed6 100644 --- a/src/components/ward-management/coordinator/home-since-banner-lazy.tsx +++ b/src/components/ward-management/coordinator/home-since-banner-lazy.tsx @@ -1,18 +1,15 @@ "use client"; -import dynamic from "next/dynamic"; - -import { preloadWhenIdle } from "../preload-when-idle"; +import { loadEarly } from "../load-early"; /** * Phone budget: the "since you were last here" line brings the last-look comparison and its sheet - * with it. It renders nothing until something is new, so it stays off Home's first load and is - * fetched when the browser is idle, as Later today is (see home-activity-lazy.tsx). + * with it, so it stays off Home's first load. It is on screen at rest (before any review it counts + * from the start of the shift), so it is fetched as soon as Home's code runs and Home holds its + * columns until it arrives (`useHomeCardsSettled`): arriving late, it pushed My list down (layout + * shift on Home, 11 Nov 2026). */ -const loadSinceBanner = () => import("./home-since-banner"); - -export const HomeSinceBanner = dynamic(() => loadSinceBanner().then((m) => m.HomeSinceBanner), { - loading: () => null, -}); +export const sinceBanner = loadEarly(() => import("./home-since-banner").then((m) => m.HomeSinceBanner)); +export const HomeSinceBanner = sinceBanner.Card; -preloadWhenIdle(loadSinceBanner); +sinceBanner.start(); diff --git a/src/components/ward-management/coordinator/home.module.css b/src/components/ward-management/coordinator/home.module.css index ad593e57c..5fe1a3350 100644 --- a/src/components/ward-management/coordinator/home.module.css +++ b/src/components/ward-management/coordinator/home.module.css @@ -316,6 +316,12 @@ min-width: 0; } +/* Layout shift on Home (11 Nov 2026): held hidden, still taking its space, until the cards at the + top of each column have arrived (useHomeCardsSettled), so nothing lands on rows already shown. */ +.grid[data-cards-pending] { + visibility: hidden; +} + @media (min-width: 62.5625rem) { /* Three equal columns at one height, a little under a window tall (the shell's rail height less room for the hero), each scrolling inside itself, so the page itself barely scrolls. Tablet diff --git a/src/components/ward-management/load-early.tsx b/src/components/ward-management/load-early.tsx new file mode 100644 index 000000000..5f246ef88 --- /dev/null +++ b/src/components/ward-management/load-early.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { type ComponentType, useSyncExternalStore } from "react"; + +/** + * A code-split card that Home shows at rest (the "since you were last here" line, Beds that fit). + * It stays out of the first load like any lazy card, but `start` fetches it as soon as Home's code + * runs instead of when the browser is idle, and Home holds its columns until it has arrived + * (`useHomeCardsSettled`), so the card never lands on top of rows already on screen (layout shift + * on Home, 11 Nov 2026). Once loaded it draws in the same render, unlike `next/dynamic`, which + * always waits one pass even when the code is already there. A failed fetch is tried again the next + * time the card mounts. Does nothing on the server. + */ +export function loadEarly

(loader: () => Promise>) { + let loaded: ComponentType

| null = null; + let pending: Promise | null = null; + const listeners = new Set<() => void>(); + + const start = () => { + if (loaded || pending || typeof window === "undefined") return; + pending = loader().then( + (component) => { + loaded = component; + for (const listener of listeners) listener(); + }, + () => { + pending = null; + }, + ); + }; + const subscribe = (listener: () => void) => { + listeners.add(listener); + start(); + return () => { + listeners.delete(listener); + }; + }; + const ready = () => loaded !== null; + const current = () => loaded; + const none = () => null; + + function EarlyCard(props: P) { + const Card = useSyncExternalStore(subscribe, current, none); + return Card ? : null; + } + + return { Card: EarlyCard, start, subscribe, ready }; +} diff --git a/tests/release-gate-journeys.spec.ts b/tests/release-gate-journeys.spec.ts index 62a729de1..9d35c2e74 100644 --- a/tests/release-gate-journeys.spec.ts +++ b/tests/release-gate-journeys.spec.ts @@ -236,6 +236,10 @@ test("Morning bed meeting: a ward confirms its numbers and the coordinator's cap test("Transfer with transport: the ED books transport, the officer delivers, and the ward confirms arrival", async ({ page, }, testInfo) => { + // Three desks, ten accessibility checks and three desk switches: the longest journey here. Phone + // WebKit on a hosted runner took 1.3 min on 11 Nov at 10:31Z and 2.1 min by 18:00Z with no step + // hanging (each click 1.5 to 3 s, each check about 2 s), so it alone gets three minutes. + test.setTimeout(180_000); // WF-004 is seeded pulled into its accepted ward with a linked stay waiting for the patient, and // no transport booked yet. A hand-authored transport job has no stay, so arrival could only move // the ward's count; this one proves the patient's own stay becomes an occupied bed. diff --git a/tests/ward-browser-install.test.ts b/tests/ward-browser-install.test.ts index 7d5bbcb3b..d4ec9fc0e 100644 --- a/tests/ward-browser-install.test.ts +++ b/tests/ward-browser-install.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { installChromium } from "../scripts/ward-ci-public/install-chromium.mjs"; +import { installChromium, withoutDowngrades } from "../scripts/ward-ci-public/install-chromium.mjs"; describe("bounded release browser setup", () => { it("installs both required engines on a cold runner", () => { @@ -34,6 +34,74 @@ describe("bounded release browser setup", () => { ]); }); + it("installs cached system packages without the network and stops once every browser launches", () => { + const commands: string[][] = []; + expect( + installChromium({ + browsers: ["chromium", "webkit"], + cacheHit: true, + cachedDebs: ["/var/cache/apt/archives/a.deb", "/var/cache/apt/archives/b.deb"], + log: () => {}, + execute(command, args) { + commands.push([command, ...args]); + return { status: 0 }; + }, + }), + ).toBe(0); + expect(commands).toHaveLength(3); + expect(commands[0]).toEqual([ + "sudo", + "apt-get", + "install", + "-y", + "--no-download", + "--no-install-recommends", + "/var/cache/apt/archives/a.deb", + "/var/cache/apt/archives/b.deb", + ]); + expect(commands[1].slice(2)).toEqual(["install", "chromium", "webkit"]); + expect(commands[2][1]).toBe("-e"); + expect(commands[2].slice(-2)).toEqual(["chromium", "webkit"]); + expect(commands.flat()).not.toContain("install-deps"); + expect(commands.flat()).not.toContain("--with-deps"); + }); + + it("falls back to the apt mirror when a browser does not launch after the offline install", () => { + const commands: string[][] = []; + expect( + installChromium({ + cachedDebs: ["/var/cache/apt/archives/a.deb"], + log: () => {}, + execute(command, args) { + commands.push([command, ...args]); + return { status: args[0] === "-e" ? 1 : 0 }; + }, + }), + ).toBe(0); + expect(commands).toHaveLength(4); + expect(commands[3].slice(2)).toEqual(["install", "--with-deps", "chromium"]); + }); + + it("leaves out a cached package older than the installed one, so apt is never asked to downgrade", () => { + const dir = "/var/cache/apt/archives"; + const installed = new Map([ + ["libnss3 amd64", "2:3.98-1build1"], + ["fonts-liberation all", "1:1.07.4-11"], + ]); + const older = (a: string, b: string) => a < b; + expect( + withoutDowngrades( + [ + `${dir}/libnss3_2%3a3.90-1_amd64.deb`, + `${dir}/fonts-liberation_1%3a1.07.4-11_all.deb`, + `${dir}/libwoff1_1.0.2-2build1_amd64.deb`, + ], + installed, + older, + ), + ).toEqual([`${dir}/fonts-liberation_1%3a1.07.4-11_all.deb`, `${dir}/libwoff1_1.0.2-2build1_amd64.deb`]); + }); + it("refuses an unsupported or empty engine selection without invoking a command", () => { const execute = () => { throw new Error("must not start an installation"); diff --git a/tests/ward-no-invented-screen-figures.test.ts b/tests/ward-no-invented-screen-figures.test.ts index cea5ec235..87575e2d9 100644 --- a/tests/ward-no-invented-screen-figures.test.ts +++ b/tests/ward-no-invented-screen-figures.test.ts @@ -174,6 +174,12 @@ const CONSTANT_EXCLUSIONS: ReadonlyArray<{ id: string; why: string; file?: RegEx file: /^referrals\/use-referral-match-state\.tsx$/, name: /^ACCEPT_SECOND_TAP_GUARD_MS$/, }, + { + id: "home-cards-wait", + why: "How long Home holds its columns for its two early cards before showing anyway (item 13, 11 Nov 2026), so the page does not shift; a loading pause, never shown, not a limit.", + file: /^coordinator\/home-cards-settled\.ts$/, + name: /^HOME_CARDS_WAIT_MS$/, + }, { id: "recovery-verifier-lifetime", why: "How long this browser keeps the one-use PKCE secret for a password recovery link it asked for (SEC-2, Josh, 6 Nov 2026); a sign-in security timer, never shown, not a clinical limit.",