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);
}
});