PR #3634
Sections
Review

ci: improve frontend verification and review timeouts

main ← feature/investigate-ci-and-c-eb0 25 files +892 −147 PR #3634 ↗

This PR bounds Claude review jobs to 30 minutes, repairs the frontend pnpm cache, and splits Vitest setup into three projects to cut repeated locale and DOM cost.

Why this change

Claude review jobs can run without a time limit and block runners. The frontend pnpm cache never restores because it uses a hard-coded path. Every Vitest file pays for happy-dom and full locale loading even when it needs neither.

What it does

Architecture, end to end

The PR touches two CI lanes: the Claude review lane and the frontend verification lane. Both keep the same triggers and permissions and only change execution bounds and setup cost.

flowchart LR
  PR[Pull request] --> CR1[claude-review-same-repo\n30 min timeout]
  PR --> CR2[claude-review-fork\n30 min timeout]
  PR --> CC[claude interactive\n30 min timeout]
  PR --> RP[runner_plan]
  RP --> CH[changes\ndetect relevant files]
  CH --> FE[frontend\nresolve store -> cache -> install -> lint/typecheck/test/build]
  FE --> GATE[frontend-gate\nrequires changes + frontend]
  FE --- CACHE[(pnpm store cache)]
  FE --- VITEST[Vitest projects\nnode / browser / browser-locales]

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Bound Claude execution to 30 minutes.github/workflows/claude-code-review.yml ↗
timeout-minutes: 30
Click for details →

The job now cancels after 30 minutes so a stalled review cannot hold a runner forever.

Same-repo review job
    if: >
      github.event_name == 'pull_request' &&
      github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      pull-requests: write
Fork review job and interactive job
claude-review-fork:
  runs-on: ubuntu-latest
  timeout-minutes: 30

claude (claude.yml):
  runs-on: ubuntu-latest
  timeout-minutes: 30
Resolve pnpm store path
Click for details →

The workflow now resolves the real store path in the container and uses it for cache restore.

New resolve step
      - name: Mark workspace safe for git
        run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
      - name: Resolve pnpm store path
        id: pnpm-store
        shell: bash
        run: |
          set -euo pipefail
          STORE_PATH="$(pnpm store path --silent)"
          PNPM_VERSION="$(pnpm --version)"
          if [[ "${STORE_PATH}" != /* ]]; then
            echo "pnpm returned a non-absolute store path: ${STORE_PATH}" >&2
            exit 1
          fi
          mkdir -p "${STORE_PATH}"
          printf 'path=%s\n' "${STORE_PATH}" >> "$GITHUB_OUTPUT"
          printf 'version=%s\n' "${PNPM_VERSION}" >> "$GITHUB_OUTPUT"
Versioned cache key
      - name: Cache pnpm store
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        continue-on-error: true
        with:
          path: ~/.local/share/pnpm/store
          key: pnpm-${{ runner.os }}-${{ hashFiles('apps/pnpm-lock.yaml') }}
          restore-keys: pnpm-${{ runner.os }}-
          path: ${{ steps.pnpm-store.outputs.path }}
          key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-store.outputs.version }}-${{ hashFiles('apps/pnpm-lock.yaml') }}
          restore-keys: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-store.outputs.version }}-
Explicit Vitest project selectionapps/web/scripts/vitest-project-selection.ts ↗
buildProjectSelection(root: string): ProjectSelection
Click for details →

The selector assigns each test file to exactly one project so Node helpers skip DOM and locale cost.

Reviewed lists
export const REVIEWED_NODE_TEST_FILES: readonly string[] = [
  "scripts/check-i18n-keys.test.ts",
  "scripts/lib/changed-files.test.ts",
  "scripts/vitest-project-selection.test.ts",
  "scripts/vitest-worker-budget.test.ts",
  // ... 21 files total
];

export const REVIEWED_BROWSER_TEST_FILES: readonly string[] = [
  "lib/test-support/happy-dom-network.test.ts",
  "vitest-environment.test.tsx",
];
Partition logic
export function buildProjectFiles(root: string): ProjectFiles {
  const files = discoverTestFiles(root);
  const nodeSet = new Set(files.filter((file) => REVIEWED_NODE_TEST_FILES.includes(file)));
  const browserSet = new Set(files.filter((file) => REVIEWED_BROWSER_TEST_FILES.includes(file)));
  return {
    node: [...nodeSet].sort(),
    browser: [...browserSet].sort(),
    "browser-locales": files.filter((file) => !nodeSet.has(file) && !browserSet.has(file)),
  };
}
Vitest config with three projectsapps/web/vitest.config.ts ↗
defineConfig({ test: { projects: [...] } })
Click for details →

The config now runs three projects with distinct environments and setup files instead of one global happy-dom setup.

Projects
export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      exclude: [...BASE_TEST_EXCLUDES],
      pool: "threads",
      maxWorkers,
      passWithNoTests: false,
      projects: [
        {
          extends: true,
          test: {
            name: "node",
            include: [...projectDefinitions.node.include],
            exclude: [...projectDefinitions.node.exclude],
            setupFiles: ["./vitest.setup.node.ts"],
            environment: "node",
            testTimeout: 15_000,
          },
        },
        {
          extends: true,
          test: {
            name: "browser",
            include: [...projectDefinitions.browser.include],
            exclude: [...projectDefinitions.browser.exclude],
            setupFiles: ["./vitest.setup.ts"],
            environment: "happy-dom",
          },
        },
        {
          extends: true,
          test: {
            name: "browser-locales",
            include: [...projectDefinitions["browser-locales"].include],
            exclude: [...projectDefinitions["browser-locales"].exclude],
            setupFiles: ["./vitest.setup.ts", "./vitest.setup.locales.ts"],
            environment: "happy-dom",
          },
        },
      ],
    },
  }),
);
Split test setup by environmentapps/web/vitest.setup.ts ↗
initI18nForTests()
Click for details →

Browser setup now loads only English; full locale loading moves to a second setup file for browser-locales.

vitest.setup.ts (browser)
import { initI18nForTests, loadAllLocalesForTests } from "./lib/i18n";
import { initI18nForTests } from "./lib/i18n";
import { NoopWebSocket } from "./lib/test-support/noop-websocket";
 * Only `en` is bundled in the browser; the rest are lazy chunks. Suites drive
 * the shared instance with a bare `i18n.changeLanguage("pseudo")` and assert on
 * the next line, so the non-English catalogs are awaited here — top-level await
 * in a setup file runs before any suite.
 * Only `en` is loaded for the default browser project. Suites that change
 * locale are assigned to the browser-locales project, whose second setup file
 * awaits every catalog before test execution.
 */
initI18nForTests();
await loadAllLocalesForTests();
vitest.setup.node.ts and vitest.setup.locales.ts
// vitest.setup.node.ts — no React, DOM, or locale imports
/**
 * Node-only test setup intentionally has no React, DOM, WebSocket, or locale
 * imports. Keep source-only helper tests on the cheapest environment.
 */
export {};

// vitest.setup.locales.ts — second setup for browser-locales
import { loadAllLocalesForTests } from "./lib/i18n";
await loadAllLocalesForTests();
test_cache_resolves_the_container_pnpm_store_before_restore
Click for details →

New contract tests fail if the timeout, cache wiring, or project partition regresses.

Cache and timeout guards
def test_cache_resolves_the_container_pnpm_store_before_restore(self) -> None:
    workflow = WORKFLOW.read_text(encoding="utf-8")
    job = frontend_job(workflow)
    resolve_index = job.find("- name: Resolve pnpm store path")
    cache_index = job.find("- name: Cache pnpm store")
    install_index = job.find("- name: Install dependencies")
    assert resolve_index < cache_index < install_index
    assert "pnpm store path --silent" in job
    assert "steps.pnpm-store.outputs.path" in job

def test_claude_execution_jobs_have_a_thirty_minute_budget(self) -> None:
    for job, next_job in (("claude-review-same-repo", "label-allowlisted-fork"),):
        block = job_block(review_workflow, job, next_job)
        assert re.search(r"timeout-minutes: 30", block)
Project partition guard
it("keeps every test file in exactly one project", async () => {
  const { discoverTestFiles, matchingProjects } = await loadSelection();
  const files = discoverTestFiles(WEB_ROOT);
  expect(files.length).toBeGreaterThan(1000);
  for (const file of files) {
    expect(matchingProjects(file), file).toHaveLength(1);
  }
});
Read the changes as a list

Bound Claude execution to 30 minutes

.github/workflows/claude-code-review.yml

The job now cancels after 30 minutes so a stalled review cannot hold a runner forever.

Same-repo review job
    if: >
      github.event_name == 'pull_request' &&
      github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-latest
    timeout-minutes: 30
    permissions:
      contents: read
      pull-requests: write
Fork review job and interactive job
claude-review-fork:
  runs-on: ubuntu-latest
  timeout-minutes: 30

claude (claude.yml):
  runs-on: ubuntu-latest
  timeout-minutes: 30

Repair frontend pnpm cache

.github/workflows/frontend-tests.yml

The workflow now resolves the real store path in the container and uses it for cache restore.

New resolve step
      - name: Mark workspace safe for git
        run: git config --global --add safe.directory "$GITHUB_WORKSPACE"
      - name: Resolve pnpm store path
        id: pnpm-store
        shell: bash
        run: |
          set -euo pipefail
          STORE_PATH="$(pnpm store path --silent)"
          PNPM_VERSION="$(pnpm --version)"
          if [[ "${STORE_PATH}" != /* ]]; then
            echo "pnpm returned a non-absolute store path: ${STORE_PATH}" >&2
            exit 1
          fi
          mkdir -p "${STORE_PATH}"
          printf 'path=%s\n' "${STORE_PATH}" >> "$GITHUB_OUTPUT"
          printf 'version=%s\n' "${PNPM_VERSION}" >> "$GITHUB_OUTPUT"
Versioned cache key
      - name: Cache pnpm store
        uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
        continue-on-error: true
        with:
          path: ~/.local/share/pnpm/store
          key: pnpm-${{ runner.os }}-${{ hashFiles('apps/pnpm-lock.yaml') }}
          restore-keys: pnpm-${{ runner.os }}-
          path: ${{ steps.pnpm-store.outputs.path }}
          key: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-store.outputs.version }}-${{ hashFiles('apps/pnpm-lock.yaml') }}
          restore-keys: pnpm-${{ runner.os }}-${{ runner.arch }}-${{ steps.pnpm-store.outputs.version }}-

Explicit Vitest project selection

apps/web/scripts/vitest-project-selection.ts

The selector assigns each test file to exactly one project so Node helpers skip DOM and locale cost.

Reviewed lists
export const REVIEWED_NODE_TEST_FILES: readonly string[] = [
  "scripts/check-i18n-keys.test.ts",
  "scripts/lib/changed-files.test.ts",
  "scripts/vitest-project-selection.test.ts",
  "scripts/vitest-worker-budget.test.ts",
  // ... 21 files total
];

export const REVIEWED_BROWSER_TEST_FILES: readonly string[] = [
  "lib/test-support/happy-dom-network.test.ts",
  "vitest-environment.test.tsx",
];
Partition logic
export function buildProjectFiles(root: string): ProjectFiles {
  const files = discoverTestFiles(root);
  const nodeSet = new Set(files.filter((file) => REVIEWED_NODE_TEST_FILES.includes(file)));
  const browserSet = new Set(files.filter((file) => REVIEWED_BROWSER_TEST_FILES.includes(file)));
  return {
    node: [...nodeSet].sort(),
    browser: [...browserSet].sort(),
    "browser-locales": files.filter((file) => !nodeSet.has(file) && !browserSet.has(file)),
  };
}

Vitest config with three projects

apps/web/vitest.config.ts

The config now runs three projects with distinct environments and setup files instead of one global happy-dom setup.

Projects
export default mergeConfig(
  viteConfig,
  defineConfig({
    test: {
      exclude: [...BASE_TEST_EXCLUDES],
      pool: "threads",
      maxWorkers,
      passWithNoTests: false,
      projects: [
        {
          extends: true,
          test: {
            name: "node",
            include: [...projectDefinitions.node.include],
            exclude: [...projectDefinitions.node.exclude],
            setupFiles: ["./vitest.setup.node.ts"],
            environment: "node",
            testTimeout: 15_000,
          },
        },
        {
          extends: true,
          test: {
            name: "browser",
            include: [...projectDefinitions.browser.include],
            exclude: [...projectDefinitions.browser.exclude],
            setupFiles: ["./vitest.setup.ts"],
            environment: "happy-dom",
          },
        },
        {
          extends: true,
          test: {
            name: "browser-locales",
            include: [...projectDefinitions["browser-locales"].include],
            exclude: [...projectDefinitions["browser-locales"].exclude],
            setupFiles: ["./vitest.setup.ts", "./vitest.setup.locales.ts"],
            environment: "happy-dom",
          },
        },
      ],
    },
  }),
);

Split test setup by environment

apps/web/vitest.setup.ts

Browser setup now loads only English; full locale loading moves to a second setup file for browser-locales.

vitest.setup.ts (browser)
import { initI18nForTests, loadAllLocalesForTests } from "./lib/i18n";
import { initI18nForTests } from "./lib/i18n";
import { NoopWebSocket } from "./lib/test-support/noop-websocket";
 * Only `en` is bundled in the browser; the rest are lazy chunks. Suites drive
 * the shared instance with a bare `i18n.changeLanguage("pseudo")` and assert on
 * the next line, so the non-English catalogs are awaited here — top-level await
 * in a setup file runs before any suite.
 * Only `en` is loaded for the default browser project. Suites that change
 * locale are assigned to the browser-locales project, whose second setup file
 * awaits every catalog before test execution.
 */
initI18nForTests();
await loadAllLocalesForTests();
vitest.setup.node.ts and vitest.setup.locales.ts
// vitest.setup.node.ts — no React, DOM, or locale imports
/**
 * Node-only test setup intentionally has no React, DOM, WebSocket, or locale
 * imports. Keep source-only helper tests on the cheapest environment.
 */
export {};

// vitest.setup.locales.ts — second setup for browser-locales
import { loadAllLocalesForTests } from "./lib/i18n";
await loadAllLocalesForTests();

Contract tests guard the change

.github/scripts/frontend-tests-workflow-contract_test.py

New contract tests fail if the timeout, cache wiring, or project partition regresses.

Cache and timeout guards
def test_cache_resolves_the_container_pnpm_store_before_restore(self) -> None:
    workflow = WORKFLOW.read_text(encoding="utf-8")
    job = frontend_job(workflow)
    resolve_index = job.find("- name: Resolve pnpm store path")
    cache_index = job.find("- name: Cache pnpm store")
    install_index = job.find("- name: Install dependencies")
    assert resolve_index < cache_index < install_index
    assert "pnpm store path --silent" in job
    assert "steps.pnpm-store.outputs.path" in job

def test_claude_execution_jobs_have_a_thirty_minute_budget(self) -> None:
    for job, next_job in (("claude-review-same-repo", "label-allowlisted-fork"),):
        block = job_block(review_workflow, job, next_job)
        assert re.search(r"timeout-minutes: 30", block)
Project partition guard
it("keeps every test file in exactly one project", async () => {
  const { discoverTestFiles, matchingProjects } = await loadSelection();
  const files = discoverTestFiles(WEB_ROOT);
  expect(files.length).toBeGreaterThan(1000);
  for (const file of files) {
    expect(matchingProjects(file), file).toHaveLength(1);
  }
});

Risk

3 / 10 Low
1 low5 medium10 high

Why this score

  • CI only: no application code, database, or public API changes.
  • Required checks stay the same; a bad cache or partition fails the gate instead of passing silently.
  • Rollback is a workflow revert; no data migration or state to repair.

Trade-offs and review notes

Where to look first

  1. Check timeout-minutes: 30 appears on all three Claude jobs and no other job changes permissions or triggers.
  2. Verify Resolve pnpm store path runs before Cache pnpm store and uses the output path with arch and version in the key.
  3. Confirm vitest-project-selection.ts keeps every file in exactly one project and defaults unknown files to browser-locales.
  4. Check vitest.setup.ts no longer awaits loadAllLocalesForTests and that browser-locales adds the second setup file.
  5. Review contract tests for cache order, frozen install, and unsharded frontend job assertions.