Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -121,9 +121,9 @@ jobs:
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--health-interval 2s
--health-timeout 3s
--health-retries 20

env:
NODE_ENV: test
Expand Down Expand Up @@ -173,9 +173,9 @@ jobs:
ports: ["5432:5432"]
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
--health-interval 2s
--health-timeout 3s
--health-retries 20

env:
NODE_ENV: test
Expand Down
47 changes: 37 additions & 10 deletions .github/workflows/playwright.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@ concurrency:
jobs:
test:
name: 🎭 Playwright E2E (shard ${{ matrix.shard }})
timeout-minutes: 45
# Was 45. The suite's own globalTimeout is 12 min and a healthy shard finishes
# in ~2; 45 only ever bought a hung run the right to burn 45 minutes of runner
# time before anyone noticed (run 32217586129 did exactly that).
timeout-minutes: 20
runs-on: ubuntu-latest

# Split the allowlisted CI suite across parallel shards to cut PR
Expand All @@ -37,9 +40,9 @@ jobs:
- 5432:5432
options: >-
--health-cmd="pg_isready -U vettrack -d vettrack_test"
--health-interval=10s
--health-timeout=5s
--health-retries=5
--health-interval=2s
--health-timeout=3s
--health-retries=20

env:
NODE_ENV: test
Expand All @@ -57,22 +60,43 @@ jobs:
persist-credentials: false

- name: 📦 pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
run_install: false

- name: 🟢 Node.js
uses: actions/setup-node@v7.0.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: ".nvmrc"
cache: "pnpm"

- name: 📥 Install dependencies
run: pnpm install --frozen-lockfile

# Chromium + its OS deps were reinstalled from scratch on every shard of every
# run (~20s each). Key on the resolved @playwright/test version so a version
# bump busts it and nothing else does.
- name: 🔎 Resolve Playwright version
id: pw
run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"

- name: ♻️ Cache Playwright browsers
id: pw-cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: ~/.cache/ms-playwright
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}

# On a cache hit only the apt-level deps are (re)asserted, which is the cheap
# half; the browser download — the expensive half — is skipped.
- name: 🌐 Install Playwright Browsers
if: steps.pw-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium

- name: 🌐 Install Playwright OS deps (cached browser)
if: steps.pw-cache.outputs.cache-hit == 'true'
run: pnpm exec playwright install-deps chromium

- name: 🏗️ Build frontend
run: |
# Dev-bypass auth for E2E: omit Clerk key (empty GitHub secret still sets the var).
Expand Down Expand Up @@ -111,17 +135,20 @@ jobs:
TEST_BASE_URL: http://127.0.0.1:3001

- name: 📤 Upload dev log on failure
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ failure() }}
with:
name: vettrack-playwright-dev-log-shard-${{ matrix.shard }}
path: /tmp/vettrack-playwright.log
retention-days: 30

# Was `!cancelled()` — uploading (and retaining for 30 days) the HTML report
# of a fully green shard costs time on every run and is read by nobody. On
# failure it is exactly what you want, so keep it there.
- name: 📊 Upload Playwright report
uses: actions/upload-artifact@v7
if: ${{ !cancelled() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: ${{ failure() }}
with:
name: playwright-report-shard-${{ matrix.shard }}
path: playwright-report/
retention-days: 30
retention-days: 14
12 changes: 10 additions & 2 deletions playwright.shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,18 @@ export function sharedPlaywrightConfig(
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
// CI ran single-worker on a 4-vCPU runner, so the suite was serialized for no
// reason: the mutating specs each create uniquely-suffixed fixtures and delete
// them in a `finally`, so they do not contend. `50%` (2 workers on the standard
// runner) leaves headroom for the API server + Postgres sharing the box.
// Override with PW_WORKERS if a shard ever proves contended.
workers: process.env.PW_WORKERS || (process.env.CI ? '50%' : undefined),
timeout: 30_000,
globalTimeout: 12 * 60 * 1000,
reporter: process.env.CI ? [['list'], ['html']] : 'html',
// `html` on CI wrote a full report directory on every run; the workflow only
// uploads it on failure now, so generating it always was pure overhead. `blob`
// is not needed — shards are reported independently.
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'html',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
// Visual-regression baselines are platform-specific (font rendering) and
// the repo had none before board-states.spec.ts. `toHaveScreenshot`
// comparisons therefore run only when PW_VISUAL=1 is set — functional
Expand Down
36 changes: 30 additions & 6 deletions tests/board-states.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,18 @@ test.describe("TV board phase 1 — visual board states", () => {
test("stale — connection loss escalates to the last-known takeover", async ({ page }) => {
// The tracker flips to `stale` after STALE_AFTER_MISSED_POLLS (15) consecutive
// missed poll cycles (pinned in tests/use-display-connection.test.ts) — ≈2 min at
// the ~8 s failed-cycle cadence. Slow by design (cadence-aware, exit-only); the
// 180 s test budget + 150 s assertion timeout leave margin over the ~2 min floor.
test.setTimeout(180_000);
// the ~8 s failed-cycle cadence.
//
// That escalation is driven purely by timers (poll interval + TanStack retry
// backoff), so observing it does not require two REAL minutes of CI wall-clock:
// this one test was 120 s of a 126 s shard. Playwright's clock drives those
// timers instead. Measured locally: 2.0 min -> 5.5 s, same assertions.
//
// `install({ time })` — not a bare `install()`. A bare install freezes time
// before any app script runs and `page.goto` then never settles (verified: the
// navigation itself times out). Seeding a start time lets timers fire normally
// during load, and only the explicit `runFor` calls below advance them after.
await page.clock.install({ time: new Date("2026-08-13T10:18:00.000Z") });
let served = false;
await page.route("**/api/display/snapshot", (route) => {
if (served) return route.abort("connectionrefused");
Expand All @@ -261,10 +270,25 @@ test.describe("TV board phase 1 — visual board states", () => {

// First (only) successful poll renders a quiet board…
await expect(page.getByTestId("board-state-strip")).toHaveAttribute("data-state", "all_clear");

// …then missed polls escalate live → delayed → stale (exit-only takeover).
await expect(page.getByTestId("board-state-strip")).toHaveAttribute("data-state", "stale", {
timeout: 150_000,
});
// Advance one failed-cycle (~8 s) at a time, yielding the event loop between
// ticks so each aborted fetch and its retry backoff actually resolve — a single
// large jump collapses the cycles and the counter never advances.
// The escalation lands at ~232 s of driven time (the retry backoff means a
// failed cycle costs more than the bare 5 s poll interval), so 40 ticks of 8 s
// leaves real margin over the observed floor without adding wall-clock cost.
for (let i = 0; i < 40; i++) {
const state = await page
.getByTestId("board-state-strip")
.getAttribute("data-state")
.catch(() => null);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (state === "stale") break;
await page.clock.runFor(8_000);
await page.waitForTimeout(120);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

await expect(page.getByTestId("board-state-strip")).toHaveAttribute("data-state", "stale");
const takeover = page.getByTestId("board-takeover");
await expect(takeover).toHaveAttribute("data-kind", "stale");
// Last-known state stays on screen, labeled — never silently zeroed.
Expand Down
Loading