> ## Documentation Index
> Fetch the complete documentation index at: https://docs.odds-api.io/llms.txt
> Use this file to discover all available pages before exploring further.

# 2026 05 06 ci pr tests

# PR Test Workflow Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Add a GitHub Actions workflow that runs all unit tests, typecheck, and lint for `v3/`, `account-service/`, and `web/` on every PR, and gate merges on it passing.

**Architecture:** One workflow file with three parallel per-subproject jobs plus an aggregator job. Path filters skip irrelevant jobs; the aggregator (`all-checks`) is the single required status check so branch protection works correctly with skipped jobs.

**Tech Stack:** GitHub Actions, `actions/setup-go@v5`, `actions/setup-node@v4`, `pnpm/action-setup@v4`, Go 1.24, Node 22, pnpm.

**Spec:** `docs/superpowers/specs/2026-05-06-ci-pr-tests-design.md`

***

## File Structure

**Create:**

* `.github/workflows/pr-tests.yml` — the workflow
* `.github/workflows/README.md` — branch-protection setup instructions

**Modify:**

* `web/package.json` — add `typecheck` script
* `v3/websocket/load_test.go` — guard heavy test with `testing.Short()`
* `v3/websocket/load_stress_test.go` — guard heavy tests
* `v3/websocket/hub_performance_test.go` — guard the performance/throughput tests (not the simple unit ones)
* `v3/websocket/replay_benchmark_test.go` — guard the latency test
* `v3/websocket/throughput_benchmark_test.go` — guard the throughput tests
* `v3/websocket/peak_hour_fixes_test.go` — guard only the integration test

No new Go test files; no new TS test files.

***

## Task 1: Add `typecheck` script to `web/package.json`

**Files:**

* Modify: `web/package.json`

* [ ] **Step 1: Read current scripts block**

Run: `grep -n typecheck web/package.json`
Expected: no matches (script doesn't exist yet)

* [ ] **Step 2: Add the script**

In `web/package.json`, in the `"scripts"` object, add this line after `"test:watch": "vitest"`:

```json theme={null}
    "typecheck": "tsc --noEmit"
```

The `"scripts"` block should end up looking like:

```json theme={null}
"scripts": {
  "dev": "next dev --turbopack",
  "build": "next build",
  "start": "next start",
  "lint": "biome check .",
  "lint:fix": "biome check . --write",
  "format": "biome format .",
  "format:fix": "biome format . --write",
  "clean": "rm -rf node_modules .next",
  "postbuild": "next-sitemap",
  "test": "vitest run",
  "test:watch": "vitest",
  "typecheck": "tsc --noEmit"
}
```

* [ ] **Step 3: Run it locally to verify it works**

Run: `cd web && pnpm typecheck`
Expected: exits 0 with no output (or known pre-existing errors — note them, but don't fix as part of this task; if there are errors, jump to Task 2 and come back)

* [ ] **Step 4: Commit**

```bash theme={null}
git add web/package.json
git commit -m "chore(web): add typecheck script"
```

***

## Task 2: Fix any pre-existing typecheck errors in `web/`

**Only do this task if Task 1 step 3 surfaced errors.** Skip if it was clean.

**Files:**

* Modify: whatever files `tsc --noEmit` complains about

* [ ] **Step 1: Capture the full error list**

Run: `cd web && pnpm typecheck 2>&1 | tee /tmp/web-tsc-errors.log`

* [ ] **Step 2: Triage**

Read `/tmp/web-tsc-errors.log`. For each error:

* If it's a real type bug → fix it
* If it's noise from a misconfigured `tsconfig.json` (e.g. excluded files being included) → fix the `tsconfig.json` instead

Do NOT widen types with `any` or add `// @ts-ignore` to make errors disappear.

* [ ] **Step 3: Re-run until clean**

Run: `cd web && pnpm typecheck`
Expected: exits 0 with no output

* [ ] **Step 4: Commit**

```bash theme={null}
git add web/
git commit -m "fix(web): resolve typecheck errors surfaced by new script"
```

***

## Task 3: Audit and guard heavy Go tests with `testing.Short()`

The CI workflow will run `go test -short ./...`. Tests that should be excluded need a `t.Skip` guard.

**Files to modify** (the audit list — verify each before editing):

* `v3/websocket/load_test.go` — `TestLoadSimulation_50Clients_HighThroughput`
* `v3/websocket/load_stress_test.go` — `TestLoadStress_50Clients_MaxThroughput`, `TestLoadStress_200Clients`
* `v3/websocket/hub_performance_test.go` — `TestHubThroughput`, `TestHubBackpressure`, `TestHubLastKnownStateCleanup` (audit each first — skip only the truly heavy ones; the file also has lightweight tests like `TestHubClientFiltering`, `TestHubSportFilter`, `TestHubStatusFilter` that should NOT be guarded)
* `v3/websocket/replay_benchmark_test.go` — `TestReplayBatchProcessingLatency`
* `v3/websocket/throughput_benchmark_test.go` — `TestThroughputComparison`, `TestMessageDeliveryGuarantee`, `TestBackpressureHandling`
* `v3/websocket/peak_hour_fixes_test.go` — only `TestPeakHourCoalescingIntegration`. The other Test funcs in that file (`TestCoalescing*`, `TestWorkerPool*`, `TestBookmakerSet*`, `TestGetBookmakerSetConsistency`) are simple unit tests — leave them.

**How to decide what's "heavy":** A test is heavy if it takes longer than \~2 seconds, spawns >100 goroutines, runs >1k iterations, or sleeps for hundreds of ms in loops. When in doubt, run it locally and time it. If `go test -run TestX -v ./websocket` takes >5s on your machine, skip it under `-short`.

* [ ] **Step 1: Audit one file**

Pick one file from the list. Read it. For each `func Test*` confirm whether it should be skipped under `-short`. Make a list.

* [ ] **Step 2: Add the skip guard**

For each test you decided to guard, add this as the FIRST line inside the function body:

```go theme={null}
if testing.Short() {
    t.Skip("skipping heavy test in -short mode")
}
```

Example, for `TestLoadSimulation_50Clients_HighThroughput` in `v3/websocket/load_test.go`:

```go theme={null}
func TestLoadSimulation_50Clients_HighThroughput(t *testing.T) {
	if testing.Short() {
		t.Skip("skipping heavy test in -short mode")
	}
	hub := NewHub()
	defer hub.Stop()
	go hub.Run()
	// ...rest unchanged
}
```

* [ ] **Step 3: Repeat for all files in the audit list**

* [ ] **Step 4: Verify the short suite passes locally**

Run: `cd v3 && go test -short -race ./...`
Expected: all tests pass; the heavy ones report as `--- SKIP:`. Should complete in well under a minute.

If you see any test panic or hang, that's a real bug — fix the root cause, don't skip the test.

* [ ] **Step 5: Verify the full suite still works (sanity check, optional but recommended)**

Run a single heavy test without `-short` to confirm it still runs:

Run: `cd v3 && go test -run TestLoadSimulation_50Clients_HighThroughput -v ./websocket`
Expected: PASS (not skipped)

* [ ] **Step 6: Commit**

```bash theme={null}
git add v3/websocket/
git commit -m "test(v3): guard heavy websocket tests with testing.Short()"
```

***

## Task 4: Verify all subproject test/lint commands work locally

This catches problems with the local commands BEFORE we put them in CI, where failures are slower to debug.

**Files:** none

* [ ] **Step 1: Confirm Go suite passes under `-short`**

Run:

```bash theme={null}
cd v3
gofmt -l .
go vet ./...
go build ./...
go test -short -race ./...
```

Expected: `gofmt -l .` produces no output; everything else exits 0.

If `gofmt -l .` lists files, run `gofmt -w <files>` and commit those changes separately:

```bash theme={null}
git add v3/
git commit -m "style(v3): apply gofmt"
```

* [ ] **Step 2: Confirm account-service commands work**

Run:

```bash theme={null}
cd account-service
pnpm install --frozen-lockfile
pnpm typecheck
pnpm exec biome check .
pnpm test
```

Expected: every command exits 0.

If `biome check` reports errors, run `pnpm exec biome check . --write` and commit. If `pnpm typecheck` reports errors, fix them — same rule as Task 2: no `any`, no `@ts-ignore` band-aids.

* [ ] **Step 3: Confirm web commands work**

Run:

```bash theme={null}
cd web
pnpm install --frozen-lockfile
pnpm typecheck
pnpm lint
pnpm test
pnpm build
```

Expected: every command exits 0.

If `pnpm lint` reports errors, run `pnpm lint:fix` and commit. If `pnpm build` fails, fix the underlying issue.

* [ ] **Step 4: Commit any fixups**

```bash theme={null}
git status
# If there are fixups:
git add <files>
git commit -m "chore: fix lint/format issues surfaced by CI prep"
```

***

## Task 5: Create the workflow file

**Files:**

* Create: `.github/workflows/pr-tests.yml`

* [ ] **Step 1: Create directory if needed**

Run: `mkdir -p .github/workflows`

* [ ] **Step 2: Write the workflow**

Create `.github/workflows/pr-tests.yml` with this exact content:

```yaml theme={null}
name: PR Tests

on:
  pull_request:
    branches: [main]

# Cancel in-progress runs when a new commit is pushed to the same PR
concurrency:
  group: pr-tests-${{ github.event.pull_request.number }}
  cancel-in-progress: true

jobs:
  v3-go:
    name: v3 (Go)
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: v3
    steps:
      - uses: actions/checkout@v4

      - name: Check if v3 changed
        id: changes
        uses: dorny/paths-filter@v3
        with:
          filters: |
            v3:
              - 'v3/**'
              - '.github/workflows/pr-tests.yml'

      - uses: actions/setup-go@v5
        if: steps.changes.outputs.v3 == 'true'
        with:
          go-version-file: v3/go.mod
          cache-dependency-path: v3/go.sum

      - name: gofmt
        if: steps.changes.outputs.v3 == 'true'
        run: |
          unformatted=$(gofmt -l .)
          if [ -n "$unformatted" ]; then
            echo "::error::Files are not gofmt'd:"
            echo "$unformatted"
            exit 1
          fi

      - name: go vet
        if: steps.changes.outputs.v3 == 'true'
        run: go vet ./...

      - name: go build
        if: steps.changes.outputs.v3 == 'true'
        run: go build ./...

      - name: go test (-short)
        if: steps.changes.outputs.v3 == 'true'
        run: go test -short -race ./...

  account-service:
    name: account-service (TS)
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: account-service
    steps:
      - uses: actions/checkout@v4

      - name: Check if account-service changed
        id: changes
        uses: dorny/paths-filter@v3
        with:
          filters: |
            account:
              - 'account-service/**'
              - '.github/workflows/pr-tests.yml'

      - uses: pnpm/action-setup@v4
        if: steps.changes.outputs.account == 'true'
        with:
          version: 9

      - uses: actions/setup-node@v4
        if: steps.changes.outputs.account == 'true'
        with:
          node-version: 22
          cache: pnpm
          cache-dependency-path: account-service/pnpm-lock.yaml

      - name: Install
        if: steps.changes.outputs.account == 'true'
        run: pnpm install --frozen-lockfile

      - name: Typecheck
        if: steps.changes.outputs.account == 'true'
        run: pnpm typecheck

      - name: Lint (biome)
        if: steps.changes.outputs.account == 'true'
        run: pnpm exec biome check .

      - name: Test
        if: steps.changes.outputs.account == 'true'
        run: pnpm test

  web:
    name: web (Next.js)
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: web
    steps:
      - uses: actions/checkout@v4

      - name: Check if web changed
        id: changes
        uses: dorny/paths-filter@v3
        with:
          filters: |
            web:
              - 'web/**'
              - '.github/workflows/pr-tests.yml'

      - uses: pnpm/action-setup@v4
        if: steps.changes.outputs.web == 'true'
        with:
          version: 9

      - uses: actions/setup-node@v4
        if: steps.changes.outputs.web == 'true'
        with:
          node-version: 22
          cache: pnpm
          cache-dependency-path: web/pnpm-lock.yaml

      - name: Install
        if: steps.changes.outputs.web == 'true'
        run: pnpm install --frozen-lockfile

      - name: Typecheck
        if: steps.changes.outputs.web == 'true'
        run: pnpm typecheck

      - name: Lint (biome)
        if: steps.changes.outputs.web == 'true'
        run: pnpm lint

      - name: Test
        if: steps.changes.outputs.web == 'true'
        run: pnpm test

      - name: Build
        if: steps.changes.outputs.web == 'true'
        run: pnpm build

  all-checks:
    name: all-checks
    needs: [v3-go, account-service, web]
    if: always()
    runs-on: ubuntu-latest
    steps:
      - name: Verify all required jobs succeeded or were skipped
        run: |
          declare -A results
          results[v3-go]="${{ needs.v3-go.result }}"
          results[account-service]="${{ needs.account-service.result }}"
          results[web]="${{ needs.web.result }}"

          failed=0
          for job in "${!results[@]}"; do
            result="${results[$job]}"
            echo "$job: $result"
            if [ "$result" = "failure" ] || [ "$result" = "cancelled" ]; then
              failed=1
            fi
          done

          if [ $failed -eq 1 ]; then
            echo "::error::One or more required jobs failed or were cancelled"
            exit 1
          fi
          echo "All required jobs passed or were correctly skipped."
```

**Note on the path-filter pattern:** Each job uses `dorny/paths-filter@v3` *inside* the job (after checkout) instead of GitHub's top-level `paths:` filter, then guards every meaningful step with `if: steps.changes.outputs.X == 'true'`. This pattern lets the job always run (so it always reports a status) but no-op when its files didn't change. It's the workaround for the well-known "skipped path-filtered jobs block required-checks branch protection" issue.

* [ ] **Step 3: Validate the YAML locally**

Run: `python3 -c "import yaml; yaml.safe_load(open('.github/workflows/pr-tests.yml'))"`
Expected: no output, exit 0.

If you don't have Python with PyYAML, alternatively use `yq` or just rely on the GitHub validation when you push — but local validation catches syntax errors before the CI round-trip.

* [ ] **Step 4: Commit**

```bash theme={null}
git add .github/workflows/pr-tests.yml
git commit -m "ci: add PR tests workflow for v3, account-service, and web"
```

***

## Task 6: Document branch-protection setup

**Files:**

* Create: `.github/workflows/README.md`

* [ ] **Step 1: Write the README**

Create `.github/workflows/README.md` with this content:

```markdown theme={null}
# CI Workflows

## `pr-tests.yml`

Runs unit tests, typecheck, and lint for every PR targeting `main`.

**Jobs:**
- `v3-go` — Go: gofmt, vet, build, `go test -short -race ./...`
- `account-service` — TypeScript: typecheck, biome lint, vitest
- `web` — Next.js: typecheck, biome lint, vitest, `next build`
- `all-checks` — aggregator that fails if any of the above failed

Each job uses an in-job path filter so it no-ops when its subproject's files didn't change.
The `all-checks` job always runs and is the single status check that branch protection requires.

## Required setup (one-time, manual)

Branch protection cannot be enabled by a workflow — toggle it in the GitHub UI:

1. Go to **Repo Settings → Branches → Branch protection rules**
2. Click **Add branch protection rule** (or edit the existing rule for `main`)
3. **Branch name pattern:** `main`
4. Enable **Require a pull request before merging**
5. Enable **Require status checks to pass before merging**
   - Enable **Require branches to be up to date before merging**
   - In the search box, type `all-checks` and select it
6. Recommended: enable **Do not allow bypassing the above settings** (applies to admins too)
7. Save

After this is set, PRs cannot be merged into `main` until `all-checks` is green.

## Excluding heavy tests from CI

Go load/stress/throughput tests are guarded with `testing.Short()` and skipped under `go test -short`.
Run them locally with `go test ./websocket -run TestLoadStress -v` (drop the `-short` flag).

## Adding a new subproject

1. Add a new job to `pr-tests.yml` modeled on the existing three
2. Add it to the `needs:` list of `all-checks`
3. Add it to the `results[...]` map in the `all-checks` script
```

* [ ] **Step 2: Commit**

```bash theme={null}
git add .github/workflows/README.md
git commit -m "docs(ci): document PR test workflow and branch-protection setup"
```

***

## Task 7: End-to-end validation on a real PR

**Files:** none — this validates the workflow against GitHub.

* [ ] **Step 1: Push the branch and open a PR**

```bash theme={null}
git push -u origin HEAD
gh pr create --title "ci: add PR tests workflow" --body "$(cat <<'EOF'
## Summary
- Adds `.github/workflows/pr-tests.yml` running unit tests, typecheck, and lint for `v3/`, `account-service/`, and `web/` on every PR
- Adds an `all-checks` aggregator job to use as the single required status check
- Guards heavy Go load/stress tests with `testing.Short()` so CI runs fast
- Adds `typecheck` script to `web/package.json`
- Documents branch-protection setup in `.github/workflows/README.md`

See `docs/superpowers/specs/2026-05-06-ci-pr-tests-design.md` for design.

## Test plan
- [ ] All three jobs pass on this PR
- [ ] `all-checks` passes
- [ ] Branch protection enabled in repo settings (manual step — see workflow README)
- [ ] Verify on a follow-up PR that touching only one subproject correctly skips the others while still letting `all-checks` pass
EOF
)"
```

* [ ] **Step 2: Watch the run**

Run: `gh pr checks --watch`
Expected: all four jobs (`v3 (Go)`, `account-service (TS)`, `web (Next.js)`, `all-checks`) report success.

* [ ] **Step 3: Triage failures**

If a job fails:

1. Run `gh run view --log-failed` to get failing step output
2. Reproduce locally with the exact command from that step (e.g. `cd web && pnpm test`)
3. Fix the root cause — do NOT loosen the check to make it green. If a test is flaky in CI but passes locally, that's a CI environment issue worth fixing (timing, missing env var, etc.), not something to retry-loop around.
4. Push fix; checks rerun automatically.

* [ ] **Step 4: Enable branch protection**

Once all checks are green, follow the instructions in `.github/workflows/README.md` to enable branch protection. This is a manual step in the GitHub UI.

* [ ] **Step 5: Verify branch protection works**

After enabling, confirm in the PR view that the merge button is gated on `all-checks`. If `all-checks` is not listed as required, the rule was misconfigured — go back to step 4.

* [ ] **Step 6: Merge the PR**

Once green and protection is verified, merge.

```bash theme={null}
gh pr merge --squash
```

***

## Done criteria

* [ ] `.github/workflows/pr-tests.yml` exists and is valid YAML
* [ ] `.github/workflows/README.md` exists with setup instructions
* [ ] `web/package.json` has a `typecheck` script
* [ ] Heavy Go tests skip under `-short`; lightweight ones still run
* [ ] All four jobs report success on the bootstrapping PR
* [ ] Branch protection is enabled and gates merges on `all-checks`
* [ ] PR is merged
