Skip to content

Commit b6087d0

Browse files
authored
Merge pull request #199 from exposwifty31/ci/speedup-2026-08
perf(ci): cache Playwright browsers, parallelize workers, tighten timeouts
2 parents e3472e6 + d81a507 commit b6087d0

4 files changed

Lines changed: 111 additions & 31 deletions

File tree

.github/workflows/ci.yml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ jobs:
3232
persist-credentials: false
3333

3434
- name: 📦 pnpm
35-
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v4.4.0
35+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
3636
with:
3737
run_install: false
3838

@@ -77,7 +77,7 @@ jobs:
7777
persist-credentials: false
7878

7979
- name: 📦 pnpm
80-
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v4.4.0
80+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
8181
with:
8282
run_install: false
8383

@@ -121,9 +121,9 @@ jobs:
121121
ports: ["5432:5432"]
122122
options: >-
123123
--health-cmd pg_isready
124-
--health-interval 10s
125-
--health-timeout 5s
126-
--health-retries 5
124+
--health-interval 2s
125+
--health-timeout 3s
126+
--health-retries 20
127127
128128
env:
129129
NODE_ENV: test
@@ -138,7 +138,7 @@ jobs:
138138
persist-credentials: false
139139

140140
- name: 📦 pnpm
141-
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v4.4.0
141+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
142142
with:
143143
run_install: false
144144

@@ -173,9 +173,9 @@ jobs:
173173
ports: ["5432:5432"]
174174
options: >-
175175
--health-cmd pg_isready
176-
--health-interval 10s
177-
--health-timeout 5s
178-
--health-retries 5
176+
--health-interval 2s
177+
--health-timeout 3s
178+
--health-retries 20
179179
180180
env:
181181
NODE_ENV: test
@@ -190,7 +190,7 @@ jobs:
190190
persist-credentials: false
191191

192192
- name: 📦 pnpm
193-
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v4.4.0
193+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
194194
with:
195195
run_install: false
196196

@@ -222,7 +222,7 @@ jobs:
222222
persist-credentials: false
223223

224224
- name: 📦 pnpm
225-
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v4.4.0
225+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
226226
with:
227227
run_install: false
228228

@@ -343,7 +343,7 @@ jobs:
343343
persist-credentials: false
344344

345345
- name: 📦 pnpm
346-
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v4.4.0
346+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
347347
with:
348348
run_install: false
349349

.github/workflows/playwright.yml

Lines changed: 36 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@ concurrency:
1616
jobs:
1717
test:
1818
name: 🎭 Playwright E2E (shard ${{ matrix.shard }})
19-
timeout-minutes: 45
19+
# Was 45. The suite's own globalTimeout is 12 min and a healthy shard finishes
20+
# in ~2; 45 only ever bought a hung run the right to burn 45 minutes of runner
21+
# time before anyone noticed (run 32217586129 did exactly that).
22+
timeout-minutes: 20
2023
runs-on: ubuntu-latest
2124

2225
# Split the allowlisted CI suite across parallel shards to cut PR
@@ -37,9 +40,9 @@ jobs:
3740
- 5432:5432
3841
options: >-
3942
--health-cmd="pg_isready -U vettrack -d vettrack_test"
40-
--health-interval=10s
41-
--health-timeout=5s
42-
--health-retries=5
43+
--health-interval=2s
44+
--health-timeout=3s
45+
--health-retries=20
4346
4447
env:
4548
NODE_ENV: test
@@ -57,20 +60,40 @@ jobs:
5760
persist-credentials: false
5861

5962
- name: 📦 pnpm
60-
uses: pnpm/action-setup@v6.0.10
63+
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
6164
with:
6265
run_install: false
6366

6467
- name: 🟢 Node.js
65-
uses: actions/setup-node@v7.0.0
68+
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
6669
with:
6770
node-version-file: ".nvmrc"
6871
cache: "pnpm"
6972

7073
- name: 📥 Install dependencies
7174
run: pnpm install --frozen-lockfile
7275

76+
# Chromium was re-downloaded on every shard of every run. Key on the resolved
77+
# Playwright version so a version bump busts it and nothing else does.
78+
- name: 🔎 Resolve Playwright version
79+
id: pw
80+
run: echo "version=$(pnpm exec playwright --version | awk '{print $2}')" >> "$GITHUB_OUTPUT"
81+
82+
- name: ♻️ Cache Playwright browsers
83+
id: pw-cache
84+
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
85+
with:
86+
path: ~/.cache/ms-playwright
87+
key: playwright-${{ runner.os }}-${{ steps.pw.outputs.version }}
88+
89+
# Only on a cache MISS, and this is the step that actually costs: `--with-deps`
90+
# shells out to apt, which measured 232s on its own when run separately. The
91+
# ubuntu-latest image already ships chromium's shared libraries, so on a cache
92+
# hit we deliberately run NOTHING here rather than re-asserting apt packages
93+
# that are already present — that reasserting was slower than the download it
94+
# replaced (measured: browser install 0s, install-deps 232s).
7395
- name: 🌐 Install Playwright Browsers
96+
if: steps.pw-cache.outputs.cache-hit != 'true'
7497
run: pnpm exec playwright install --with-deps chromium
7598

7699
- name: 🏗️ Build frontend
@@ -111,17 +134,20 @@ jobs:
111134
TEST_BASE_URL: http://127.0.0.1:3001
112135

113136
- name: 📤 Upload dev log on failure
114-
uses: actions/upload-artifact@v7
137+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
115138
if: ${{ failure() }}
116139
with:
117140
name: vettrack-playwright-dev-log-shard-${{ matrix.shard }}
118141
path: /tmp/vettrack-playwright.log
119142
retention-days: 30
120143

144+
# Was `!cancelled()` — uploading (and retaining for 30 days) the HTML report
145+
# of a fully green shard costs time on every run and is read by nobody. On
146+
# failure it is exactly what you want, so keep it there.
121147
- name: 📊 Upload Playwright report
122-
uses: actions/upload-artifact@v7
123-
if: ${{ !cancelled() }}
148+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
149+
if: ${{ failure() }}
124150
with:
125151
name: playwright-report-shard-${{ matrix.shard }}
126152
path: playwright-report/
127-
retention-days: 30
153+
retention-days: 14

playwright.shared.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,22 @@ export function sharedPlaywrightConfig(
8080
fullyParallel: true,
8181
forbidOnly: !!process.env.CI,
8282
retries: process.env.CI ? 2 : 0,
83-
workers: process.env.CI ? 1 : undefined,
83+
// CI ran single-worker on a 4-vCPU runner, so the suite was serialized for no
84+
// reason: the mutating specs each create uniquely-suffixed fixtures and delete
85+
// them in a `finally`, so they do not contend. `50%` (2 workers on the standard
86+
// runner) leaves headroom for the API server + Postgres sharing the box.
87+
// Override with PW_WORKERS if a shard ever proves contended.
88+
workers: process.env.PW_WORKERS || (process.env.CI ? '50%' : undefined),
8489
timeout: 30_000,
8590
globalTimeout: 12 * 60 * 1000,
86-
reporter: process.env.CI ? [['list'], ['html']] : 'html',
91+
// `list` gives readable step output; `html` still WRITES `playwright-report/` on
92+
// every CI run — `open: 'never'` only suppresses the viewer, it does not skip
93+
// generation. Kept deliberately: the workflow uploads that directory `if: failure()`
94+
// (.github/workflows/playwright.yml), and a trace-linked HTML report is what makes a
95+
// red shard diagnosable. `blob` would be cheaper but needs a `merge-reports` step to
96+
// become readable, which trades a cost paid on green runs for friction paid on red
97+
// ones — the wrong way round. Shards are reported independently, so nothing is merged.
98+
reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'html',
8799
// Visual-regression baselines are platform-specific (font rendering) and
88100
// the repo had none before board-states.spec.ts. `toHaveScreenshot`
89101
// comparisons therefore run only when PW_VISUAL=1 is set — functional

tests/board-states.spec.ts

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -243,12 +243,28 @@ test.describe("TV board phase 1 — visual board states", () => {
243243
test("stale — connection loss escalates to the last-known takeover", async ({ page }) => {
244244
// The tracker flips to `stale` after STALE_AFTER_MISSED_POLLS (15) consecutive
245245
// missed poll cycles (pinned in tests/use-display-connection.test.ts) — ≈2 min at
246-
// the ~8 s failed-cycle cadence. Slow by design (cadence-aware, exit-only); the
247-
// 180 s test budget + 150 s assertion timeout leave margin over the ~2 min floor.
248-
test.setTimeout(180_000);
246+
// the ~8 s failed-cycle cadence.
247+
//
248+
// That escalation is driven purely by timers (poll interval + TanStack retry
249+
// backoff), so observing it does not require two REAL minutes of CI wall-clock:
250+
// this one test was 120 s of a 126 s shard. Playwright's clock drives those
251+
// timers instead. Measured locally: 2.0 min -> 5.5 s, same assertions.
252+
//
253+
// `install({ time })` — not a bare `install()`. A bare install freezes time
254+
// before any app script runs and `page.goto` then never settles (verified: the
255+
// navigation itself times out). Seeding a start time lets timers fire normally
256+
// during load, and only the explicit `runFor` calls below advance them after.
257+
await page.clock.install({ time: new Date("2026-08-13T10:18:00.000Z") });
249258
let served = false;
259+
// Counts the FAILED polls. It is the only deterministic signal that a driven tick
260+
// actually produced a request — see the loop below, which waits on it instead of on
261+
// real time.
262+
let abortedPolls = 0;
250263
await page.route("**/api/display/snapshot", (route) => {
251-
if (served) return route.abort("connectionrefused");
264+
if (served) {
265+
abortedPolls++;
266+
return route.abort("connectionrefused");
267+
}
252268
served = true;
253269
return route.fulfill({
254270
status: 200,
@@ -261,10 +277,36 @@ test.describe("TV board phase 1 — visual board states", () => {
261277

262278
// First (only) successful poll renders a quiet board…
263279
await expect(page.getByTestId("board-state-strip")).toHaveAttribute("data-state", "all_clear");
280+
264281
// …then missed polls escalate live → delayed → stale (exit-only takeover).
265-
await expect(page.getByTestId("board-state-strip")).toHaveAttribute("data-state", "stale", {
266-
timeout: 150_000,
267-
});
282+
// Advance one failed-cycle (~8 s) at a time, yielding the event loop between
283+
// ticks so each aborted fetch and its retry backoff actually resolve — a single
284+
// large jump collapses the cycles and the counter never advances.
285+
// The escalation lands at ~232 s of driven time (the retry backoff means a
286+
// failed cycle costs more than the bare 5 s poll interval), so 40 ticks of 8 s
287+
// leaves real margin over the observed floor without adding wall-clock cost.
288+
for (let i = 0; i < 40; i++) {
289+
// No `.catch(() => null)`: a locator timeout or a closed page is a real failure and
290+
// must surface here, at its source. Swallowing it read as "not stale yet", so the
291+
// loop span another 39 ticks and the run died 40 iterations later on the final
292+
// assertion, pointing at the wrong line.
293+
const state = await page.getByTestId("board-state-strip").getAttribute("data-state");
294+
if (state === "stale") break;
295+
296+
const before = abortedPolls;
297+
await page.clock.runFor(8_000);
298+
// The tick is only finished once the poll it drove has actually reached the route
299+
// handler. The previous `waitForTimeout(120)` was REAL wall-clock — a guess at how
300+
// long an aborted fetch plus its retry backoff take, and the one wall-clock
301+
// dependency left in a test whose whole purpose is not to have any. Polling the
302+
// counter is the same wait expressed as a fact instead of an estimate; it also
303+
// returns as soon as the request lands rather than always costing the full budget.
304+
await expect
305+
.poll(() => abortedPolls, { timeout: 5_000, intervals: [5, 10, 25, 50] })
306+
.toBeGreaterThan(before);
307+
}
308+
309+
await expect(page.getByTestId("board-state-strip")).toHaveAttribute("data-state", "stale");
268310
const takeover = page.getByTestId("board-takeover");
269311
await expect(takeover).toHaveAttribute("data-kind", "stale");
270312
// Last-known state stays on screen, labeled — never silently zeroed.

0 commit comments

Comments
 (0)