Skip to content

Commit ea4f050

Browse files
midagedevclaude
andcommitted
GDK-1702: CI cost — staticcheck skips Go-free pushes, e2e shards balance by measured weight, five browser specs move to vitest
Staticcheck runs only when a push touches Go, go.mod/go.sum, the script or the workflow (fail-open: a filter error runs it). tools/e2e-partition.sh partitions e2e specs into N bins by longest-processing-time over e2e/shard-weights.tsv (measured seconds) instead of file order, and --check keeps the weights file honest against the spec list. Assertions that need no browser (menu loading, settings copy, palette entry, activity visibility, mirror-status wording) leave Playwright for vitest. Audit axis 9 of the v0.22 release audit (GDK-1698). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 3e851d9 commit ea4f050

13 files changed

Lines changed: 923 additions & 217 deletions

.github/workflows/ci.yml

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -273,7 +273,56 @@ jobs:
273273
steps:
274274
- uses: actions/checkout@v4
275275

276+
# GDK-1702: this job billed ~5 min of setup+install per push while
277+
# the census measured most pushes touching no Go at all. It is a gate
278+
# (the cross-platform list is empty since 90e27bdf), so the skip must
279+
# never hide a Go change — hence the fail-open rules below. GitHub Actions has no
280+
# per-job paths filter (`on:` filters are workflow-level and would
281+
# skip every job), so the first step diffs the change and every step
282+
# below is gated on its output.
283+
#
284+
# It fails OPEN, and that is the contract: a forced push whose before
285+
# is unreachable, a first push (all-zero before), workflow_dispatch,
286+
# an empty event base, or any diff error runs the job. A gate skipped
287+
# by mistake is the failure mode that matters; a gate run for nothing
288+
# is five minutes.
289+
#
290+
# The path list is the Go surface plus the job's own inputs: the
291+
# script it runs, and this workflow (a change here can rewrite the
292+
# gate itself). `git diff A B` compares trees without needing shared
293+
# history, so two depth-1 objects are enough.
294+
- name: Did this change touch Go?
295+
id: gofilter
296+
env:
297+
EVENT_NAME: ${{ github.event_name }}
298+
BEFORE: ${{ github.event.before }}
299+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
300+
SHA: ${{ github.sha }}
301+
run: |
302+
run_job() { echo "go=true" >> "$GITHUB_OUTPUT"; echo "staticcheck: $1"; exit 0; }
303+
skip_job() { echo "go=false" >> "$GITHUB_OUTPUT"; echo "staticcheck: $1"; exit 0; }
304+
case "$EVENT_NAME" in
305+
pull_request) base="$BASE_SHA" ;;
306+
push) base="$BEFORE" ;;
307+
*) run_job "event '$EVENT_NAME' has no before — running (fail open)" ;;
308+
esac
309+
if [ -z "$base" ] || printf '%s' "$base" | grep -Eq '^0+$'; then
310+
run_job "no usable base sha (first push or forced push) — running (fail open)"
311+
fi
312+
if ! git fetch --no-tags --depth=1 origin "$base" 2>/dev/null; then
313+
run_job "base $base not fetchable — running (fail open)"
314+
fi
315+
if ! changed="$(git diff --name-only "$base" "$SHA")"; then
316+
run_job "git diff failed — running (fail open)"
317+
fi
318+
n=$(printf '%s\n' "$changed" | grep -c . || true)
319+
if printf '%s\n' "$changed" | grep -Eq '\.go$|^go\.mod$|^go\.sum$|^desktop/|^tools/staticcheck\.sh$|^\.github/workflows/ci\.yml$'; then
320+
run_job "Go-touching change in ${n} changed path(s)"
321+
fi
322+
skip_job "no Go-touching change in ${n} changed path(s)"
323+
276324
- name: Set up Go
325+
if: steps.gofilter.outputs.go == 'true'
277326
uses: actions/setup-go@v5
278327
with:
279328
go-version-file: go.mod
@@ -283,11 +332,13 @@ jobs:
283332
# artefact is the script's classifier, not staticcheck. It runs over
284333
# fixtures, needs no toolchain, and takes a second.
285334
- name: staticcheck.sh self-test
335+
if: steps.gofilter.outputs.go == 'true'
286336
run: bash tools/staticcheck.sh --self-test
287337

288338
# Pinned on purpose: staticcheck gains checks between releases, and
289339
# @latest would turn a new check into a CI change nobody made.
290340
- name: Install staticcheck
341+
if: steps.gofilter.outputs.go == 'true'
291342
run: |
292343
go install honnef.co/go/tools/cmd/staticcheck@v0.7.0
293344
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
@@ -301,6 +352,7 @@ jobs:
301352
# second copy of the mirror-flaky apt step in this file for a job that
302353
# cannot fail. The root module gets all three either way.
303354
- name: staticcheck over the GOOS matrix
355+
if: steps.gofilter.outputs.go == 'true'
304356
run: bash tools/staticcheck.sh
305357

306358
e2e:
@@ -312,6 +364,15 @@ jobs:
312364
# seeded home, so e2e/playwright.config.ts keeps workers: 1 and
313365
# fullyParallel: false — raising workers inside one runner is the
314366
# unsafe variant (single shared served instance) and stays out.
367+
#
368+
# GDK-1702: which files land on which shard is owned by
369+
# tools/e2e-partition.sh — dealt by the measured table
370+
# (e2e/shard-weights.tsv), because --shard's count-based split sent
371+
# 220/297/261 s of test time to the three runners and the longest
372+
# shard is the job's wall clock. --check runs in every shard before
373+
# the run: a spec that escaped the deal (new file outside the table,
374+
# stale weight row, glob drifting from what playwright collects)
375+
# fails the job that would otherwise have skipped it silently.
315376
strategy:
316377
fail-fast: false
317378
matrix:
@@ -478,8 +539,14 @@ jobs:
478539
echo "::error::playwright chromium download failed three times — browser cache, not apt, not the tests"
479540
exit 1
480541
542+
# Positional e2e/-prefixed paths on purpose: --shard=N/3 splits by
543+
# count (balanced only if every file costs the same), while the
544+
# partitioner deals files by measured seconds.
545+
- name: E2E partition check
546+
run: bash tools/e2e-partition.sh --check 3
547+
481548
- name: Run browser E2E
482-
run: npx playwright test --config e2e/playwright.config.ts --shard=${{ matrix.shard }}/3
549+
run: npx playwright test --config e2e/playwright.config.ts $(bash tools/e2e-partition.sh ${{ matrix.shard }} 3)
483550

484551
# Nested module (desktop/go.mod). package main imports wails v3, which does
485552
# not compile on Linux with CGO_ENABLED=0 (undefined pointer in the linux

e2e/docs-ux.spec.ts

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -835,6 +835,14 @@ test.describe('docs empty states', () => {
835835
* ("Fetching documents") — at the same moment, about the same pass. Read
836836
* together, issue sync and document sync looked like two systems. These pin
837837
* that every surface renders the same string, and that the count is in it.
838+
*
839+
* The issues-pass wording and the announce threshold moved to vitest by the
840+
* GDK-1702 cost ladder: the sentence is web/src/lib/mirror-status.test.ts
841+
* (busyLabel's issues branch, the 4-digit comma, and the GDK-460 scans of
842+
* which surface may render it), and the elapsed-time rule is
843+
* web/src/stores/issues-activity-visibility.test.ts (ACTIVITY_MIN_VISIBLE_MS
844+
* and the `a.running && old` gate). The browser keeps the running half —
845+
* the document pass below crosses chip, CTA and sidebar end to end.
838846
*/
839847
test.describe('sync status is one sentence', () => {
840848
const API = apiURL('/api/v1/issues/')
@@ -890,35 +898,6 @@ test.describe('sync status is one sentence', () => {
890898
await expect(page.getByTestId('sidebar-sync-now')).toContainText('Sync log')
891899
await expect(page.getByTestId('sidebar-sync-now')).not.toContainText(expected)
892900
})
893-
894-
test('an issue pass says issues, not documents', async ({ page }) => {
895-
await activity(page, { running: true, source: 'issues', fetched: 6932 })
896-
await gotoApp(page)
897-
898-
// GDK-460: busy wording is the chip's. The sidebar keeps its own name.
899-
await expect(page.getByTestId('freshness-chip')).toContainText('Syncing issues · 6,932', {
900-
timeout: 20_000,
901-
})
902-
await expect(page.getByTestId('sidebar-sync-now')).toContainText('Sync log')
903-
await expect(page.getByTestId('sidebar-sync-now')).not.toContainText('Syncing issues')
904-
})
905-
906-
test('a pass is announced only once it has run long enough to wonder about', async ({ page }) => {
907-
// The watch loop finishes an incremental in a second or two, every minute.
908-
// Narrating those would put a blinking status in front of someone all day;
909-
// the six-minute backfill is what needed saying. So the rule is elapsed
910-
// time, and this asserts both halves of it rather than the quiet first
911-
// instant, which would pass with no rule at all.
912-
await activity(page, { running: true, source: 'issues', fetched: 2 }, 500)
913-
await gotoApp(page)
914-
915-
const chip = page.getByTestId('freshness-chip')
916-
await page.waitForTimeout(1_500) // two polls in, still young
917-
await expect(chip).not.toContainText('Syncing')
918-
// Same stubbed pass, now old enough: the wording appears without anything
919-
// else changing, which is the threshold and not a coincidence of timing.
920-
await expect(chip).toContainText('Syncing issues', { timeout: 15_000 })
921-
})
922901
})
923902

924903
test.describe('sync status at rest', () => {

e2e/menu-loading.spec.ts

Lines changed: 7 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ import { MENU_ORIGIN_TIMEOUT_MS } from '../web/src/lib/menu-loading'
2020
* note) when one exists, otherwise the catalog's failure sentence and a
2121
* Retry.
2222
*
23+
* The detail-picker half of the contract moved to vitest (GDK-1702 cost
24+
* ladder): withMenuTimeout's timing and the picker's cached-catalog
25+
* fallback are web/src/lib/menu-loading.test.ts — the fallback is pinned
26+
* as a source scan there, because the picker cannot mount in the unit
27+
* project and the fallback's claims are its source. This spec keeps the
28+
* real path: one menu opened against a stalled origin, end to end.
29+
*
2330
* The stalled routes fulfill after 60 s — far past every assertion — so the
2431
* pre-fix tree hangs exactly like the unreachable origin did, and the
2532
* post-fix fallback state stays put for the assertions (no late-arriving
@@ -94,63 +101,4 @@ test.describe('menu origin wait is capped (GDK-1566)', () => {
94101

95102
expect(appConsoleErrors(errors), `console errors:\n${errors.join('\n')}`).toEqual([])
96103
})
97-
98-
test('detail priority picker: timeout falls back to the site catalog with the offline note', async ({
99-
page,
100-
}) => {
101-
const errors = attachConsoleErrors(page)
102-
// Prime the site catalog first: the bulk menu loads it instantly.
103-
await page.route('**/api/v1/issues/priorities/', async (route) => {
104-
if (route.request().method() !== 'GET') return route.continue()
105-
await route.fulfill({
106-
status: 200,
107-
contentType: 'application/json',
108-
json: { priorities: [{ id: '1', name: 'Highest' }, { id: '3', name: 'Medium' }] },
109-
})
110-
})
111-
await gotoApp(page)
112-
await selectBulkRows(page, 1)
113-
114-
await page.getByRole('button', { name: en['bulk.changePriority'], exact: true }).click()
115-
const bulkMenu = page.getByTestId('bulk-priority-menu')
116-
await expect(bulkMenu.getByText('Highest')).toBeVisible()
117-
await page.keyboard.press('Escape')
118-
await expect(bulkMenu).toBeHidden()
119-
// Clear the bulk selection so the detail panel is the only surface.
120-
await page.keyboard.press('Escape')
121-
await expect(page.getByTestId('bulk-bar')).toBeHidden()
122-
123-
// Open the first issue; stall only its per-key catalog.
124-
const row = page.locator('[data-testid="issue-list-scroller"] [data-issue-key]').first()
125-
const key = await row.getAttribute('data-issue-key')
126-
expect(key).toBeTruthy()
127-
await page.route(`**/api/v1/issues/${key}/priorities/`, async (route) => {
128-
if (route.request().method() !== 'GET') return route.continue()
129-
await stall()
130-
await route.fulfill({
131-
status: 200,
132-
contentType: 'application/json',
133-
json: { priorities: [{ id: '9', name: 'Per-key Only' }] },
134-
})
135-
})
136-
await row.click()
137-
const panel = page.getByTestId('issue-detail-panel')
138-
await expect(panel).toHaveClass(/is-open/)
139-
140-
await page.keyboard.press('p')
141-
const menu = page.getByRole('listbox', { name: en['common.priority'] })
142-
await expect(menu).toBeVisible()
143-
await expect(menu.getByText(en['common.loading'])).toBeVisible()
144-
145-
await expect(menu.getByText(en['common.loading'])).toBeHidden({
146-
timeout: MENU_ORIGIN_TIMEOUT_MS + 4_000,
147-
})
148-
// The per-key answer never came, so the rows are the cached site catalog
149-
// (not "Per-key Only"), and the note says so.
150-
await expect(menu.getByText('Highest')).toBeVisible()
151-
await expect(menu.getByText('Per-key Only')).toHaveCount(0)
152-
await expect(menu.getByTestId('menu-cached-note')).toHaveText(en['app.offlineBanner'])
153-
154-
expect(appConsoleErrors(errors), `console errors:\n${errors.join('\n')}`).toEqual([])
155-
})
156104
})

e2e/mirror-instant.spec.ts

Lines changed: 30 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,17 @@ import {
2222
* value moves when and only when the mirror does, WAL included — is pinned in
2323
* Go (internal/store/version_test.go, internal/server/focus_test.go).
2424
*
25+
* One test, both halves of the contract (GDK-1702 cost ladder merged the
26+
* two: each paid a full boot + anchor to drive the same rig, and the
27+
* windows combine cleanly):
28+
* - a still mirror must not pull (the control window before the burst);
29+
* - moves reach the pool within INSTANT_MS — not on the backstop;
30+
* - moves inside one 500ms tick coalesce into exactly one pull, and a
31+
* mirror that settles does not pull again (the settle window doubles
32+
* as the still-mirror control on the post-pull state).
33+
*
2534
* FAIL-first: with the client ignoring mirrorVersion, the injected issue
26-
* appears only on the backstop tick, and this stopped on
35+
* appears only on the backstop tick, and this stops on
2736
* 'the board must pull within 3s of the mirror moving'.
2837
*/
2938

@@ -99,7 +108,9 @@ async function installRig(page: Page): Promise<Rig> {
99108
}
100109

101110
test.describe('GDK-1170 a write elsewhere reaches an open board', () => {
102-
test('the board pulls when mirrorVersion moves, not on the 15s backstop', async ({ page }) => {
111+
test('the board pulls on a move, within the instant window, exactly once — never on ticks or stacked', async ({
112+
page,
113+
}) => {
103114
const errors = attachConsoleErrors(page)
104115
const rig = await installRig(page)
105116
await gotoApp(page)
@@ -119,42 +130,33 @@ test.describe('GDK-1170 a write elsewhere reaches an open board', () => {
119130
await page.waitForTimeout(INSTANT_MS) // duration is the contract: no pull while the mirror sits still
120131
expect(rig.deltas(), 'a still mirror must not pull a delta on the 500ms tick').toBe(anchored)
121132

122-
rig.arm()
123-
rig.bump()
124-
125-
// The assertion waits on the state itself: the injected issue is in the
126-
// pool, which is only true after a delta landed and was applied.
127-
await expect(
128-
page.getByText(new RegExp(`${DEMO_ISSUE_COUNT + 1} issues`)).first(),
129-
'the board must pull within 3s of the mirror moving',
130-
).toBeVisible({ timeout: INSTANT_MS })
131-
132-
expect(rig.deltas(), 'exactly one delta for one move').toBe(anchored + 1)
133-
expect(appConsoleErrors(errors)).toEqual([])
134-
})
135-
136-
test('a mirror that keeps moving pulls once per move, never stacked', async ({ page }) => {
137-
const errors = attachConsoleErrors(page)
138-
const rig = await installRig(page)
139-
await gotoApp(page)
140-
await expect(page.getByText(new RegExp(`${DEMO_ISSUE_COUNT} issues`)).first()).toBeVisible({
141-
timeout: 30_000,
142-
})
143-
await page.waitForResponse((r) => r.url().includes('/delta/'), { timeout: 30_000 })
144-
const anchored = rig.deltas()
145-
146133
// Three moves inside one 500ms tick. The tab must not fire three deltas —
147-
// a poll that stacks requests is a new defect, not a fixed one.
134+
// a poll that stacks requests is a new defect, not a fixed one — and it
135+
// must not fire zero either (the pre-fix client ignored the version and
136+
// waited for the backstop).
137+
rig.arm()
148138
rig.bump()
149139
rig.bump()
150140
rig.bump()
151141

152142
await expect
153143
.poll(() => rig.deltas(), { timeout: INSTANT_MS })
154144
.toBeGreaterThan(anchored)
155-
// Settle, then count. Still short of the backstop at anchored + 15s.
145+
146+
// Settle, then count. Still short of the backstop at anchored + 15s:
147+
// the burst coalesced into one pull, and a mirror that went still again
148+
// does not pull a follow-up — the same still-mirror control, on the
149+
// post-pull state.
156150
await page.waitForTimeout(INSTANT_MS) // duration is the contract: no follow-up pull after the burst settles
157151
expect(rig.deltas(), 'three moves inside one tick are one pull').toBe(anchored + 1)
152+
153+
// The assertion waits on the state itself: the injected issue is in the
154+
// pool, which is only true after a delta landed and was applied.
155+
await expect(
156+
page.getByText(new RegExp(`${DEMO_ISSUE_COUNT + 1} issues`)).first(),
157+
'the move must reach the pool within 3s',
158+
).toBeVisible({ timeout: INSTANT_MS })
159+
158160
expect(BACKSTOP_MS).toBeGreaterThan(2 * INSTANT_MS)
159161
expect(appConsoleErrors(errors)).toEqual([])
160162
})

e2e/palette.spec.ts

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import { test, expect } from '@playwright/test'
22
import { apiURL, attachConsoleErrors, gotoApp, searchInput, DEMO_ISSUE_COUNT_EN_RE } from './helpers'
3-
import { en } from '../web/src/lib/i18n/en'
43

54
test.describe('command palette', () => {
65
test('Cmd+K opens it, typing stays local, Enter opens the issue detail', async ({ page }) => {
@@ -445,23 +444,10 @@ test.describe('command palette', () => {
445444
expect(errors, `console errors:\n${errors.join('\n')}`).toEqual([])
446445
})
447446

448-
test('GDK-472: palette entry names its scope; empty palette is one phrase', async ({
449-
page,
450-
}) => {
451-
const errors = attachConsoleErrors(page)
452-
await gotoApp(page)
453-
454-
const entry = page.getByTestId('palette-open')
455-
await expect(entry).toContainText('Search everything')
456-
await expect(entry.locator('kbd')).toBeVisible()
457-
458-
await entry.click()
459-
const palette = page.getByRole('dialog', { name: 'Command palette' })
460-
await expect(palette).toBeVisible()
461-
const box = palette.getByRole('combobox')
462-
await expect(box).toHaveAttribute('placeholder', en['palette.placeholder'])
463-
await expect(palette.getByTestId('palette-empty-hint')).toHaveCount(0)
464-
465-
expect(errors, `console errors:\n${errors.join('\n')}`).toEqual([])
466-
})
447+
// GDK-472 (entry names its scope; empty palette is one phrase) moved to
448+
// web/src/components/palette/palette-entry.test.ts by the GDK-1702 cost
449+
// ladder: every assertion read source or catalog strings, and opening
450+
// the palette to read them back cost a boot. Entry click-through — open,
451+
// type, Enter — is the real path this spec keeps (above and in
452+
// usearch.spec.ts).
467453
})

0 commit comments

Comments
 (0)