diff --git a/.agents/skills/address-pr-review-comments/SKILL.md b/.agents/skills/address-pr-review-comments/SKILL.md index b562696..0232d4a 100644 --- a/.agents/skills/address-pr-review-comments/SKILL.md +++ b/.agents/skills/address-pr-review-comments/SKILL.md @@ -25,7 +25,7 @@ description: Inspect, triage, implement, test, and reply to unresolved BrainRot ## Verify and respond 1. Batch compatible fixes, then review the consolidated diff once. Run `npm run update-metadata` after game-file changes and check its output against the implementation. -2. Run one applicable change-scoped path from `verify-changes` after the fixes are complete. Reuse passing evidence while the relevant inputs remain unchanged; the updated pull request reruns the complete lint, build, and Playwright gate in CI. +2. Run one applicable change-scoped path from `verify-changes` after the fixes are complete. Reuse passing evidence while the relevant inputs remain unchanged; the updated pull request reruns complete quality checks and impact-selected Playwright coverage in CI. 3. Commit fixes with a Gitmoji subject and the repository's required co-author trailer, then push normally. 4. Reply to each valid thread with the implementing model, concise fix summary, and commit identifier. 5. Reply to skipped comments with the triage rationale. Resolve a thread only after its fix or rationale is published. diff --git a/.agents/skills/verify-changes/SKILL.md b/.agents/skills/verify-changes/SKILL.md index 3c119a0..4117966 100644 --- a/.agents/skills/verify-changes/SKILL.md +++ b/.agents/skills/verify-changes/SKILL.md @@ -1,6 +1,6 @@ --- name: verify-changes -description: Validate BrainRot repository changes with process-safe, change-scoped local checks while leaving the complete lint, build, and Playwright gate to required pull request CI. Use after implementation, before committing or opening a PR, or when diagnosing test and build failures. +description: Validate BrainRot repository changes with process-safe, change-scoped local checks while leaving complete quality checks and impact-selected Playwright coverage to required pull request CI. Use after implementation, before committing or opening a PR, or when diagnosing test and build failures. --- # Verify Changes @@ -35,6 +35,6 @@ Verification-only commands may run without creating a branch. Before applying an ## Rely on the pull request gate -1. GitHub Actions runs lint, the production build, and the complete Playwright suite for every pull request. -2. Agents may publish a PR after the selected local path passes. Do not add a second verifier pass or duplicate the full CI gate locally unless one of the exceptions above applies. +1. GitHub Actions runs complete lint, production build, asset, and selector checks plus impact-selected Playwright tests for every pull request. CI/test infrastructure and unclassified executable changes fall back to the full browser suite. +2. Agents may publish a PR after the selected local path passes. Do not add a second verifier pass or duplicate the CI-selected browser coverage locally unless one of the exceptions above applies. 3. The required aggregate CI check is authoritative before merge. Use its merged HTML report and retained traces when diagnosing failures. diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index 91cb849..cd7d1e4 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -16,6 +16,58 @@ env: NODE_VERSION: "22" jobs: + scope: + name: Select Playwright tests + runs-on: ubuntu-latest + timeout-minutes: 5 + outputs: + mode: ${{ steps.pull_request.outputs.mode || steps.merge_group.outputs.mode || steps.manual.outputs.mode }} + grep: ${{ steps.pull_request.outputs.grep || steps.merge_group.outputs.grep || steps.manual.outputs.grep }} + summary: ${{ steps.pull_request.outputs.summary || steps.merge_group.outputs.summary || steps.manual.outputs.summary }} + steps: + - name: Check out repository history + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Select pull request tests + if: ${{ github.event_name == 'pull_request' }} + id: pull_request + run: >- + node scripts/select-playwright-tests.js + --base "${{ github.event.pull_request.base.sha }}" + --head "${{ github.event.pull_request.head.sha }}" + --github-output "$GITHUB_OUTPUT" + + - name: Select merge group tests + if: ${{ github.event_name == 'merge_group' }} + id: merge_group + run: >- + node scripts/select-playwright-tests.js + --base "${{ github.event.merge_group.base_sha }}" + --head "${{ github.event.merge_group.head_sha }}" + --github-output "$GITHUB_OUTPUT" + + - name: Select the full manual suite + if: ${{ github.event_name == 'workflow_dispatch' }} + id: manual + run: >- + node scripts/select-playwright-tests.js + --force-full + --github-output "$GITHUB_OUTPUT" + + - name: Publish selection summary + env: + SUMMARY: ${{ steps.pull_request.outputs.summary || steps.merge_group.outputs.summary || steps.manual.outputs.summary }} + run: | + echo "### Playwright selection" >> "$GITHUB_STEP_SUMMARY" + echo "$SUMMARY" >> "$GITHUB_STEP_SUMMARY" + quality: name: Quality runs-on: ubuntu-latest @@ -42,6 +94,9 @@ jobs: - name: Test game asset tooling run: npm run test:game-assets + - name: Test CI selection + run: npm run test:ci-selection + - name: Lint run: npm run lint @@ -50,6 +105,8 @@ jobs: playwright: name: Playwright ${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + needs: scope + if: ${{ needs.scope.outputs.mode != 'none' }} runs-on: ubuntu-latest timeout-minutes: 15 strategy: @@ -73,8 +130,15 @@ jobs: - name: Install Chromium run: npx playwright install --with-deps chromium - - name: Run Playwright shard - run: npm test -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + - name: Run full Playwright shard + if: ${{ needs.scope.outputs.mode == 'full' }} + run: npm test -- --pass-with-no-tests --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} + + - name: Run scoped Playwright shard + if: ${{ needs.scope.outputs.mode == 'scoped' }} + env: + PLAYWRIGHT_GREP: ${{ needs.scope.outputs.grep }} + run: npm test -- --grep "$PLAYWRIGHT_GREP" --pass-with-no-tests --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} - name: Upload shard report if: ${{ !cancelled() }} @@ -87,8 +151,8 @@ jobs: merge-reports: name: Playwright report - if: ${{ !cancelled() }} - needs: playwright + if: ${{ !cancelled() && needs.scope.outputs.mode != 'none' }} + needs: [scope, playwright] runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -125,17 +189,33 @@ jobs: required: name: Required PR checks if: ${{ always() }} - needs: [quality, playwright, merge-reports] + needs: [scope, quality, playwright, merge-reports] runs-on: ubuntu-latest timeout-minutes: 2 steps: - name: Confirm required jobs passed env: + SCOPE_RESULT: ${{ needs.scope.result }} + PLAYWRIGHT_MODE: ${{ needs.scope.outputs.mode }} QUALITY_RESULT: ${{ needs.quality.result }} PLAYWRIGHT_RESULT: ${{ needs.playwright.result }} REPORT_RESULT: ${{ needs.merge-reports.result }} run: | - if [ "$QUALITY_RESULT" != "success" ] || [ "$PLAYWRIGHT_RESULT" != "success" ] || [ "$REPORT_RESULT" != "success" ]; then - echo "Required jobs did not all pass: quality=$QUALITY_RESULT playwright=$PLAYWRIGHT_RESULT report=$REPORT_RESULT" + if [ "$SCOPE_RESULT" != "success" ] || [ "$QUALITY_RESULT" != "success" ]; then + echo "Required setup failed: scope=$SCOPE_RESULT quality=$QUALITY_RESULT" + exit 1 + fi + if [ "$PLAYWRIGHT_MODE" = "none" ]; then + if [ "$PLAYWRIGHT_RESULT" != "skipped" ] || [ "$REPORT_RESULT" != "skipped" ]; then + echo "Expected skipped browser jobs: playwright=$PLAYWRIGHT_RESULT report=$REPORT_RESULT" + exit 1 + fi + elif [ "$PLAYWRIGHT_MODE" = "scoped" ] || [ "$PLAYWRIGHT_MODE" = "full" ]; then + if [ "$PLAYWRIGHT_RESULT" != "success" ] || [ "$REPORT_RESULT" != "success" ]; then + echo "Required browser jobs failed: mode=$PLAYWRIGHT_MODE playwright=$PLAYWRIGHT_RESULT report=$REPORT_RESULT" + exit 1 + fi + else + echo "Invalid Playwright selection mode: $PLAYWRIGHT_MODE" exit 1 fi diff --git a/AGENTS.md b/AGENTS.md index 902b94f..a36e233 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,5 +48,5 @@ BrainRot instructions should give capable models the outcome, repository constra - This file applies repository-wide. Explicit user instructions take precedence; a more deeply nested `AGENTS.md` takes precedence for its subtree. - `games-metadata.json` is the source of truth for games, versions, model ownership, reviews, and detected features. - A game file may only be created or changed by the exact AI model that owns that version. Resolve ownership from `games-metadata.json`; if the exact model is unavailable, stop instead of substituting another model. -- Keep local verification scoped to the change and avoid rerunning unchanged evidence. GitHub Actions runs the complete lint, build, and Playwright gate for every pull request, and its required aggregate check is authoritative before merge. +- Keep local verification scoped to the change and avoid rerunning unchanged evidence. GitHub Actions runs complete lint, build, and asset checks plus impact-selected Playwright coverage for every pull request; CI/test infrastructure and unclassified executable changes fall back to the full browser suite. The required aggregate check is authoritative before merge. - Follow `GAME_DEVELOPMENT_GUIDE.md` for game work. Keep detailed workflows in the skills above, not in this file. diff --git a/README.md b/README.md index fca7f58..eb68376 100644 --- a/README.md +++ b/README.md @@ -74,13 +74,16 @@ npm test # Run one affected spec while developing npm test -- tests/smoke.spec.ts + +# Validate the CI impact selector and Playwright tags +npm run test:ci-selection ``` ## ✅ Pull Request Checks -Every pull request runs the complete validation gate in GitHub Actions: lint, the production build, and every Playwright test. The browser suite is split across two Chromium shards, then combined into one HTML report that is retained with the workflow run for 14 days. +Every pull request runs the complete lint, production build, and asset-tooling gate in GitHub Actions. Playwright is change-scoped: game files select that exact game/model's load and regression coverage, shared application files select the site and ratings suites, and Three.js runtime files select their actual consumers. The selected browser tests remain split across two Chromium shards and are combined into one HTML report retained with the workflow run for 14 days. Documentation-only changes skip the browser jobs. -Local development should stay focused. Run the smallest Playwright spec or filtered load probe that covers the change, run lint for JavaScript, TypeScript, test, or configuration changes, and run the production build for application, routing, dependency, Next.js, or shared-runtime changes. The full `npm test` suite is reserved for test or CI infrastructure changes, changes whose impact cannot be bounded, and explicit requests; otherwise the required `Required PR checks` status is the authoritative full gate before merge. +Local development should stay focused. Run the smallest Playwright spec or filtered load probe that covers the change, run lint for JavaScript, TypeScript, test, or configuration changes, and run the production build for application, routing, dependency, Next.js, or shared-runtime changes. CI and test-infrastructure changes, unclassified executable paths, and manual `workflow_dispatch` runs use the complete Playwright suite as a fail-safe. The required `Required PR checks` status remains authoritative before merge. ## ⭐ Ratings Setup @@ -107,7 +110,7 @@ If you only provide `KV_REST_API_READ_ONLY_TOKEN`, the site can show rating summ ``` .github/ -└── workflows/pr-checks.yml # Required lint, build, and sharded Playwright PR gate +└── workflows/pr-checks.yml # Required quality and change-scoped Playwright PR gate .agents/ └── skills/ # Portable workflows for all agent harnesses AGENTS.md # Cross-harness instructions and skill catalog diff --git a/package.json b/package.json index 8c19a4f..46b3bdf 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "start": "next start", "lint": "eslint", "test": "playwright test", + "test:ci-selection": "node --test tests-node/select-playwright-tests.test.js", "update-metadata": "node scripts/update-metadata.js", "sync:three-runtime": "node scripts/sync-three-runtime.js", "validate:game-assets": "node scripts/validate-game-assets.js", diff --git a/scripts/select-playwright-tests.js b/scripts/select-playwright-tests.js new file mode 100644 index 0000000..32d0d67 --- /dev/null +++ b/scripts/select-playwright-tests.js @@ -0,0 +1,345 @@ +const { execFileSync } = require("node:child_process"); +const fs = require("node:fs"); +const path = require("node:path"); + +const AREA_SITE = "@area:site"; +const AREA_RATINGS = "@area:ratings"; +const AREA_THREEJS = "@area:threejs"; +const SPEC_GAMES_LOAD = "@spec:games-load"; + +const FULL_SUITE_PATTERNS = [ + /^\.github\/workflows\//, + /^eslint\.config\./, + /^next\.config\./, + /^package(?:-lock)?\.json$/, + /^playwright\.config\./, + /^postcss\.config\./, + /^scripts\/select-playwright-tests\.js$/, + /^tests\/(?![^/]+\.spec\.[cm]?[jt]sx?$)/, + /^tests-node\/select-playwright-tests\.test\.js$/, + /^tsconfig(?:\.[^/]+)?\.json$/, +]; + +const NO_PLAYWRIGHT_PATTERNS = [ + /^\.agents\//, + /^\.gitignore$/, + /^AGENTS\.md$/, + /^CLAUDE\.md$/, + /^GAME_DEVELOPMENT_GUIDE\.md$/, + /^README\.md$/, + /^public\/games\/README\.md$/, + /^public\/games\/TEMPLATE\//, + /^reviews\//, + /^tests-node\/(?!select-playwright-tests\.test\.js$)/, + /\.md$/, +]; + +const THREE_TOOLING_PATTERNS = [ + /^public\/test-fixtures\/three-runtime\//, + /^scripts\/(?:game-assets|inspect-threejs|sync-three-runtime|validate-game-assets)\.js$/, +]; + +function normalizePath(filePath) { + return filePath.replaceAll("\\", "/").replace(/^\.\//, ""); +} + +function gameTag(gameId, modelId) { + return `@game:${gameId}/${modelId}`; +} + +function specTag(filePath) { + const match = normalizePath(filePath).match(/^tests\/([^/]+)\.spec\.[cm]?[jt]sx?$/); + return match ? `@spec:${match[1]}` : null; +} + +function metadataVersionKeys(metadata) { + return new Set(metadataVersionsByKey(metadata).keys()); +} + +function metadataVersionsByKey(metadata) { + const versions = new Map(); + for (const game of metadata?.games ?? []) { + if (typeof game?.id !== "string") continue; + for (const version of game.versions ?? []) { + if (typeof version?.modelId === "string") { + versions.set(`${game.id}/${version.modelId}`, version); + } + } + } + return versions; +} + +function changedMetadataVersions(baseMetadata, headMetadata) { + const before = metadataVersionsByKey(baseMetadata); + const after = metadataVersionsByKey(headMetadata); + return new Set( + [...new Set([...before.keys(), ...after.keys()])].filter((key) => { + if (!before.has(key) || !after.has(key)) return true; + return before.get(key)?.path !== after.get(key)?.path; + }), + ); +} + +function findThreeRuntimeConsumers(rootDir) { + const gamesRoot = path.join(rootDir, "public", "games"); + const consumers = new Set(); + if (!fs.existsSync(gamesRoot)) return consumers; + + for (const gameEntry of fs.readdirSync(gamesRoot, { withFileTypes: true })) { + if (!gameEntry.isDirectory() || gameEntry.name === "TEMPLATE") continue; + const gameRoot = path.join(gamesRoot, gameEntry.name); + for (const modelEntry of fs.readdirSync(gameRoot, { withFileTypes: true })) { + if (!modelEntry.isDirectory()) continue; + const gameFile = path.join(gameRoot, modelEntry.name, "index.html"); + if (!fs.existsSync(gameFile)) continue; + const source = fs.readFileSync(gameFile, "utf8"); + if (/\/vendor\/three\/|three\.(?:module|core)\.min\.js/.test(source)) { + consumers.add(`${gameEntry.name}/${modelEntry.name}`); + } + } + } + return consumers; +} + +function isMatch(filePath, patterns) { + return patterns.some((pattern) => pattern.test(filePath)); +} + +function escapeRegex(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function selectPlaywrightImpact({ + changes, + baseMetadata = { games: [] }, + headMetadata = { games: [] }, + rootDir = process.cwd(), + forceFull = false, +}) { + if (forceFull) { + return { + mode: "full", + tags: [], + grep: "", + summary: "Full Playwright suite requested explicitly.", + }; + } + + const tags = new Set(); + const reasons = new Set(); + let metadataChanged = false; + + for (const change of changes) { + for (const rawPath of change.paths) { + const filePath = normalizePath(rawPath); + + if (isMatch(filePath, FULL_SUITE_PATTERNS)) { + return { + mode: "full", + tags: [], + grep: "", + summary: `Full Playwright suite required by ${filePath}.`, + }; + } + + const changedSpecTag = specTag(filePath); + if (changedSpecTag) { + tags.add(changedSpecTag); + reasons.add(`changed spec ${filePath}`); + continue; + } + + const gameMatch = filePath.match(/^public\/games\/([^/]+)\/([^/]+)\//); + if (gameMatch && gameMatch[1] !== "TEMPLATE") { + tags.add(gameTag(gameMatch[1], gameMatch[2])); + reasons.add(`game ${gameMatch[1]}/${gameMatch[2]}`); + continue; + } + + if (filePath === "games-metadata.json") { + metadataChanged = true; + tags.add(AREA_SITE); + tags.add(AREA_RATINGS); + reasons.add("game metadata"); + continue; + } + + if (filePath.startsWith("public/vendor/three/")) { + tags.add(AREA_THREEJS); + for (const consumer of findThreeRuntimeConsumers(rootDir)) { + const [gameId, modelId] = consumer.split("/"); + tags.add(gameTag(gameId, modelId)); + } + reasons.add("pinned Three.js runtime"); + continue; + } + + if (isMatch(filePath, THREE_TOOLING_PATTERNS)) { + tags.add(AREA_THREEJS); + reasons.add("Three.js tooling"); + continue; + } + + if (filePath.startsWith("src/")) { + tags.add(AREA_SITE); + tags.add(AREA_RATINGS); + tags.add(SPEC_GAMES_LOAD); + reasons.add("shared application source"); + continue; + } + + if (/^public\/[^/]+$/.test(filePath)) { + tags.add(AREA_SITE); + reasons.add("shared public asset"); + continue; + } + + if (isMatch(filePath, NO_PLAYWRIGHT_PATTERNS)) continue; + + return { + mode: "full", + tags: [], + grep: "", + summary: `Full Playwright suite required by unclassified path ${filePath}.`, + }; + } + } + + if (metadataChanged) { + for (const key of changedMetadataVersions(baseMetadata, headMetadata)) { + const [gameId, modelId] = key.split("/"); + tags.add(gameTag(gameId, modelId)); + reasons.add(`metadata version ${key}`); + } + } + + const selectedTags = [...tags].sort(); + if (selectedTags.length === 0) { + return { + mode: "none", + tags: [], + grep: "", + summary: "No Playwright-impacting files changed.", + }; + } + + return { + mode: "scoped", + tags: selectedTags, + grep: selectedTags.map(escapeRegex).join("|"), + summary: `Selected ${selectedTags.join(", ")} (${[...reasons].join("; ")}).`, + }; +} + +function parseNameStatus(output) { + const tokens = output.split("\0"); + if (tokens.at(-1) === "") tokens.pop(); + const changes = []; + for (let index = 0; index < tokens.length;) { + const status = tokens[index++]; + const pathCount = /^[RC]/.test(status) ? 2 : 1; + const paths = tokens.slice(index, index + pathCount).map(normalizePath); + index += pathCount; + changes.push({ status, paths }); + } + return changes; +} + +function readMetadataAtRef(rootDir, ref) { + try { + const output = execFileSync("git", ["show", `${ref}:games-metadata.json`], { + cwd: rootDir, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + return JSON.parse(output); + } catch { + return { games: [] }; + } +} + +function readChanges(rootDir, base, head) { + const output = execFileSync( + "git", + ["diff", "--name-status", "-z", "--find-renames", `${base}...${head}`], + { cwd: rootDir, encoding: "utf8" }, + ); + return parseNameStatus(output); +} + +function parseArgs(argv) { + const args = { forceFull: false, json: false }; + for (let index = 0; index < argv.length; index++) { + const value = argv[index]; + if (value === "--force-full") args.forceFull = true; + else if (value === "--json") args.json = true; + else if (["--base", "--head", "--github-output"].includes(value)) { + args[value.slice(2).replace("-", "_")] = argv[++index]; + } else { + throw new Error(`Unknown argument: ${value}`); + } + } + return args; +} + +function writeGitHubOutput(filePath, result) { + const lines = [ + `mode=${result.mode}`, + `grep=${result.grep}`, + `summary=${result.summary.replaceAll("\n", " ")}`, + ]; + fs.appendFileSync(filePath, `${lines.join("\n")}\n`); +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + const rootDir = process.cwd(); + let changes = []; + let baseMetadata = { games: [] }; + let headMetadata = { games: [] }; + + if (!args.forceFull) { + if (!args.base || !args.head) { + throw new Error("--base and --head are required unless --force-full is used"); + } + changes = readChanges(rootDir, args.base, args.head); + baseMetadata = readMetadataAtRef(rootDir, args.base); + headMetadata = readMetadataAtRef(rootDir, args.head); + } + + const result = selectPlaywrightImpact({ + changes, + baseMetadata, + headMetadata, + rootDir, + forceFull: args.forceFull, + }); + + if (args.github_output) writeGitHubOutput(args.github_output, result); + if (args.json) console.log(JSON.stringify({ ...result, changes }, null, 2)); + else console.log(`${result.mode}: ${result.summary}`); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error.message); + process.exitCode = 1; + } +} + +module.exports = { + AREA_RATINGS, + AREA_SITE, + AREA_THREEJS, + SPEC_GAMES_LOAD, + changedMetadataVersions, + findThreeRuntimeConsumers, + gameTag, + metadataVersionKeys, + normalizePath, + parseNameStatus, + selectPlaywrightImpact, + specTag, +}; diff --git a/tests-node/select-playwright-tests.test.js b/tests-node/select-playwright-tests.test.js new file mode 100644 index 0000000..f9cfc3c --- /dev/null +++ b/tests-node/select-playwright-tests.test.js @@ -0,0 +1,242 @@ +const assert = require("node:assert/strict"); +const { spawnSync } = require("node:child_process"); +const path = require("node:path"); +const test = require("node:test"); +const { + AREA_RATINGS, + AREA_SITE, + AREA_THREEJS, + SPEC_GAMES_LOAD, + changedMetadataVersions, + parseNameStatus, + selectPlaywrightImpact, +} = require("../scripts/select-playwright-tests"); + +const rootDir = path.resolve(__dirname, ".."); + +function change(...paths) { + return [{ status: "M", paths }]; +} + +function metadata(entries) { + const games = new Map(); + for (const key of entries) { + const [gameId, modelId] = key.split("/"); + const game = games.get(gameId) ?? { id: gameId, versions: [] }; + game.versions.push({ modelId }); + games.set(gameId, game); + } + return { games: [...games.values()] }; +} + +test("selects only the exact changed game implementation", () => { + const result = selectPlaywrightImpact({ + changes: change("public/games/maze-3d/gpt-5-6-sol/index.html"), + rootDir, + }); + assert.equal(result.mode, "scoped"); + assert.deepEqual(result.tags, ["@game:maze-3d/gpt-5-6-sol"]); +}); + +test("combines multiple changed game implementations", () => { + const result = selectPlaywrightImpact({ + changes: change( + "public/games/tetris/gpt-5-6-luna/index.html", + "public/games/tile-matching/opus-5/index.html", + ), + rootDir, + }); + assert.deepEqual(result.tags, [ + "@game:tetris/gpt-5-6-luna", + "@game:tile-matching/opus-5", + ]); +}); + +test("maps shared application changes to site, ratings, and game-load tests", () => { + const result = selectPlaywrightImpact({ + changes: change("src/components/Navbar.tsx"), + rootDir, + }); + assert.deepEqual(result.tags, [AREA_RATINGS, AREA_SITE, SPEC_GAMES_LOAD]); +}); + +test("maps Three.js tooling to pipeline tests", () => { + const result = selectPlaywrightImpact({ + changes: change("scripts/inspect-threejs.js"), + rootDir, + }); + assert.deepEqual(result.tags, [AREA_THREEJS]); +}); + +test("maps pinned Three.js runtime changes to the pipeline and its consumers", () => { + const result = selectPlaywrightImpact({ + changes: change("public/vendor/three/0.185.1/three.module.min.js"), + rootDir, + }); + assert.equal(result.mode, "scoped"); + assert.ok(result.tags.includes(AREA_THREEJS)); + assert.ok(result.tags.includes("@game:mini-golf/gpt-5-6-sol")); + assert.ok(result.tags.includes("@game:outrun-racer/opus-5")); + assert.ok(!result.tags.includes("@game:maze-3d/gpt-5-6-sol")); +}); + +test("selects a changed spec by its unique spec tag", () => { + const result = selectPlaywrightImpact({ + changes: change("tests/ratings-feedback.spec.ts"), + rootDir, + }); + assert.deepEqual(result.tags, ["@spec:ratings-feedback"]); +}); + +test("metadata review changes stay scoped to shared application tests", () => { + const before = metadata(["snake/opus-4-6"]); + const after = metadata(["snake/opus-4-6"]); + before.games[0].versions[0].path = "/games/snake/opus-4-6/index.html"; + after.games[0].versions[0].path = "/games/snake/opus-4-6/index.html"; + after.games[0].versions[0].aiReviews = [{ + from: "reviewer", + comments: ["presentation-only feedback"], + }]; + const result = selectPlaywrightImpact({ + changes: change("games-metadata.json"), + baseMetadata: before, + headMetadata: after, + rootDir, + }); + assert.deepEqual(result.tags, [AREA_RATINGS, AREA_SITE]); +}); + +test("metadata version additions and removals select exact game tags", () => { + const before = metadata(["snake/opus-4-6", "tetris/old-model"]); + const after = metadata(["snake/opus-4-6", "tetris/new-model"]); + assert.deepEqual( + [...changedMetadataVersions(before, after)].sort(), + ["tetris/new-model", "tetris/old-model"], + ); + const result = selectPlaywrightImpact({ + changes: change("games-metadata.json"), + baseMetadata: before, + headMetadata: after, + rootDir, + }); + assert.deepEqual(result.tags, [ + AREA_RATINGS, + AREA_SITE, + "@game:tetris/new-model", + "@game:tetris/old-model", + ]); +}); + +test("metadata path changes select the affected game version", () => { + const before = metadata(["snake/opus-4-6"]); + const after = metadata(["snake/opus-4-6"]); + before.games[0].versions[0].path = "/games/snake/opus-4-6/index.html"; + after.games[0].versions[0].path = "/games/snake/opus-4-6/moved.html"; + + assert.deepEqual( + [...changedMetadataVersions(before, after)], + ["snake/opus-4-6"], + ); + + const result = selectPlaywrightImpact({ + changes: change("games-metadata.json"), + baseMetadata: before, + headMetadata: after, + rootDir, + }); + assert.deepEqual(result.tags, [ + AREA_RATINGS, + AREA_SITE, + "@game:snake/opus-4-6", + ]); +}); + +test("rename records inspect both old and new paths", () => { + const parsed = parseNameStatus( + "R100\0public/games/tetris/old/index.html\0public/games/tetris/new/index.html\0", + ); + assert.deepEqual(parsed, [{ + status: "R100", + paths: [ + "public/games/tetris/old/index.html", + "public/games/tetris/new/index.html", + ], + }]); + const result = selectPlaywrightImpact({ changes: parsed, rootDir }); + assert.deepEqual(result.tags, ["@game:tetris/new", "@game:tetris/old"]); +}); + +test("deleted game files still select their former implementation", () => { + const result = selectPlaywrightImpact({ + changes: [{ + status: "D", + paths: ["public/games/sudoku/sonnet-4-6/index.html"], + }], + rootDir, + }); + assert.deepEqual(result.tags, ["@game:sudoku/sonnet-4-6"]); +}); + +test("documentation-only changes skip Playwright", () => { + const result = selectPlaywrightImpact({ + changes: change("README.md", ".agents/skills/verify-changes/SKILL.md"), + rootDir, + }); + assert.equal(result.mode, "none"); +}); + +test("unknown executable paths fail safe to the full suite", () => { + const result = selectPlaywrightImpact({ + changes: change("scripts/new-runtime-tool.js"), + rootDir, + }); + assert.equal(result.mode, "full"); +}); + +test("test and CI infrastructure changes require the full suite", () => { + for (const filePath of [ + ".github/workflows/pr-checks.yml", + "package.json", + "playwright.config.ts", + "scripts/select-playwright-tests.js", + ]) { + const result = selectPlaywrightImpact({ changes: change(filePath), rootDir }); + assert.equal(result.mode, "full", filePath); + } +}); + +test("forced full mode ignores the changed paths", () => { + const result = selectPlaywrightImpact({ + changes: change("README.md"), + rootDir, + forceFull: true, + }); + assert.equal(result.mode, "full"); +}); + +function collectSpecs(suite, output = []) { + output.push(...(suite.specs ?? [])); + for (const child of suite.suites ?? []) collectSpecs(child, output); + return output; +} + +test("every collected Playwright test has one spec tag and an impact tag", () => { + const cliPath = path.join(rootDir, "node_modules", "playwright", "cli.js"); + const listed = spawnSync( + process.execPath, + [cliPath, "test", "--list", "--reporter=json"], + { cwd: rootDir, encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }, + ); + assert.equal(listed.status, 0, listed.stderr || listed.stdout); + const report = JSON.parse(listed.stdout); + const specs = report.suites.flatMap((suite) => collectSpecs(suite)); + assert.ok(specs.length > 0); + for (const spec of specs) { + const specTags = spec.tags.filter((tag) => tag.startsWith("spec:")); + const impactTags = spec.tags.filter( + (tag) => tag.startsWith("game:") || tag.startsWith("area:"), + ); + assert.equal(specTags.length, 1, `${spec.file}:${spec.line} ${spec.title}`); + assert.ok(impactTags.length > 0, `${spec.file}:${spec.line} ${spec.title}`); + } +}); diff --git a/tests/clockwork-caper.spec.ts b/tests/clockwork-caper.spec.ts index 538d99e..b59029b 100644 --- a/tests/clockwork-caper.spec.ts +++ b/tests/clockwork-caper.spec.ts @@ -23,9 +23,13 @@ const IMPLEMENTATIONS: Array<{ }, ]; -test.describe("Clockwork Caper", () => { +test.describe("Clockwork Caper", { + tag: "@spec:clockwork-caper", +}, () => { for (const implementation of IMPLEMENTATIONS) { - test(`${implementation.model} exposes the playable loop contract`, async ({ page }) => { + test(`${implementation.model} exposes the playable loop contract`, { + tag: `@game:clockwork-caper/${implementation.model}`, + }, async ({ page }) => { await page.setViewportSize({ width: 1280, height: 720 }); const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); diff --git a/tests/community-failure-verdicts.spec.ts b/tests/community-failure-verdicts.spec.ts index 3642fab..1565159 100644 --- a/tests/community-failure-verdicts.spec.ts +++ b/tests/community-failure-verdicts.spec.ts @@ -48,7 +48,9 @@ async function mockFeedbackApi( }); } -test.describe("community failed version surfaces", () => { +test.describe("community failed version surfaces", { + tag: ["@spec:community-failure-verdicts", "@area:site", "@area:ratings"], +}, () => { const game = getGames().find((candidate) => candidate.versions.length >= 2); test("has a game fixture with two implementations", () => { diff --git a/tests/games-load.spec.ts b/tests/games-load.spec.ts index 140b5f1..c66da47 100644 --- a/tests/games-load.spec.ts +++ b/tests/games-load.spec.ts @@ -1,31 +1,33 @@ import { test, expect } from "@playwright/test"; +import { getGames } from "../src/lib/games"; -const ALL_GAMES = [ - { id: "snake", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro", "gemma-4-12b", "mai-code-1-flash", "hy3"] }, - { id: "minesweeper", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro"] }, - { id: "tetris", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro", "gpt-5-6-luna"] }, - { id: "reversi", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro", "gpt-5-6-sol"] }, - { id: "breakout", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro"] }, - { id: "2048", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-5", "gpt-5-4-mini", "gemini-3-1-pro", "qwen-3-6-27b"] }, - { id: "endless-runner", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-5", "gpt-5-4-mini", "gemini-3-1-pro"] }, - { id: "marble-madness", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro", "gpt-5-6-sol"] }, - { id: "maze-3d", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gemini-3-1-pro", "hy3", "gpt-5-6-terra", "gpt-5-6-sol"] }, - { id: "mini-golf", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gpt-5-6-sol"] }, - { id: "tile-matching", models: ["opus-4-6", "opus-5", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gpt-5-5", "gpt-5-6-sol"] }, - { id: "space-invaders", models: ["opus-4-6", "sonnet-4-6", "gpt-5-4", "gpt-5-4-mini", "gpt-5-5"] }, - { id: "pac-man", models: ["sonnet-4-6", "gpt-5-4", "gpt-5-5"] }, - { id: "sudoku", models: ["sonnet-4-6", "gpt-5-4"] }, - { id: "outrun-racer", models: ["gemma-4-12b", "gpt-5-6-sol", "opus-5"] }, - { id: "coastal-rush-86", models: ["gpt-5-5", "gpt-5-4", "gpt-5-4-mini", "gpt-5-6-sol", "fable-5"] }, - { id: "clockwork-caper", models: ["gpt-5-6-sol", "gpt-5-6-terra", "gpt-5-6-luna"] }, - { id: "kart-racing", models: ["opus-5", "gpt-5-6-sol"] }, - { id: "perihelion", models: ["opus-5"] }, -]; +const EXPECTED_PAGE_ERRORS = new Map([ + [ + "outrun-racer/gpt-5-4-mini", + ["Identifier 'buildInitialGates' has already been declared"], + ], +]); -test.describe("Game HTML Files — Load Tests", () => { - for (const game of ALL_GAMES) { - for (const model of game.models) { - test(`${game.id}/${model} loads without errors`, async ({ page }) => { +test.describe("Game HTML Files — Load Tests", { + tag: "@spec:games-load", +}, () => { + for (const game of getGames()) { + for (const version of game.versions) { + const model = version.modelId; + const gameKey = `${game.id}/${model}`; + const expectedErrors = EXPECTED_PAGE_ERRORS.get(gameKey) ?? []; + const expectation = expectedErrors.length > 0 + ? "reports only its tracked runtime error" + : "loads without errors"; + test(`${gameKey} ${expectation}`, { + tag: `@game:${game.id}/${model}`, + }, async ({ page }) => { + if (expectedErrors.length > 0) { + test.info().annotations.push({ + type: "known-runtime-error", + description: expectedErrors.join("; "), + }); + } test.setTimeout(60_000); const errors: string[] = []; page.on("pageerror", (err) => errors.push(err.message)); @@ -42,8 +44,8 @@ test.describe("Game HTML Files — Load Tests", () => { await page.waitForTimeout(500); - // Check no page-level errors occurred - expect(errors).toEqual([]); + // Keep the runtime-error baseline exact so fixes and regressions both surface. + expect(errors).toEqual(expectedErrors); }); } } diff --git a/tests/kart-racing-opus-5.spec.ts b/tests/kart-racing-opus-5.spec.ts index 92f5736..4aad772 100644 --- a/tests/kart-racing-opus-5.spec.ts +++ b/tests/kart-racing-opus-5.spec.ts @@ -128,7 +128,9 @@ async function driveLap(page: Page, rivals: number) { }, rivals); } -test.describe("Claude Opus 5 Sunbeam Kart Rally", () => { +test.describe("Claude Opus 5 Sunbeam Kart Rally", { + tag: ["@spec:kart-racing-opus-5", "@game:kart-racing/opus-5"], +}, () => { test("uses the pinned local Three.js runtime with no external requests or errors", async ({ page }) => { const external: string[] = []; const runtime: string[] = []; diff --git a/tests/kart-racing-sol.spec.ts b/tests/kart-racing-sol.spec.ts index a49f199..bbd70a9 100644 --- a/tests/kart-racing-sol.spec.ts +++ b/tests/kart-racing-sol.spec.ts @@ -77,7 +77,9 @@ async function openGame(page: Page, viewport = { width: 1280, height: 720 }) { await expect.poll(() => page.evaluate(() => Boolean(window.__SUNBEAM_TEST__))).toBe(true); } -test.describe("GPT 5.6 Sol Sunbeam Kart Rally rebuild", () => { +test.describe("GPT 5.6 Sol Sunbeam Kart Rally rebuild", { + tag: ["@spec:kart-racing-sol", "@game:kart-racing/gpt-5-6-sol"], +}, () => { test("uses the pinned local Three.js runtime without external requests or errors", async ({ page }) => { const external: string[] = [], runtime: string[] = [], errors: string[] = []; page.on("request", request => { diff --git a/tests/marble-madness-sol.spec.ts b/tests/marble-madness-sol.spec.ts index 45eec38..4c3a86d 100644 --- a/tests/marble-madness-sol.spec.ts +++ b/tests/marble-madness-sol.spec.ts @@ -76,7 +76,9 @@ async function openGame(page: Page, testMode = true) { } } -test.describe("GPT 5.6 Sol Marble Madness", () => { +test.describe("GPT 5.6 Sol Marble Madness", { + tag: ["@spec:marble-madness-sol", "@game:marble-madness/gpt-5-6-sol"], +}, () => { test("uses the pinned local Three.js runtime without external requests or errors", async ({ page }) => { const external: string[] = [], runtime: string[] = [], errors: string[] = []; page.on("request", request => { diff --git a/tests/maze-3d-sol.spec.ts b/tests/maze-3d-sol.spec.ts index b2ded5d..eae6149 100644 --- a/tests/maze-3d-sol.spec.ts +++ b/tests/maze-3d-sol.spec.ts @@ -68,7 +68,9 @@ async function startGame(page: Page, seed = 56_056) { await expect(page.locator("#hud")).toHaveClass(/visible/); } -test.describe("GPT 5.6 Sol HELIOVAULT maze", () => { +test.describe("GPT 5.6 Sol HELIOVAULT maze", { + tag: ["@spec:maze-3d-sol", "@game:maze-3d/gpt-5-6-sol"], +}, () => { test("loads as a self-contained Three.js experience without external requests", async ({ page }) => { const externalRequests: string[] = []; const errors: string[] = []; diff --git a/tests/mini-golf-sol.spec.ts b/tests/mini-golf-sol.spec.ts index 0820a4e..115d7fd 100644 --- a/tests/mini-golf-sol.spec.ts +++ b/tests/mini-golf-sol.spec.ts @@ -43,7 +43,9 @@ async function startGame(page: Page) { await expect(page.locator("#hud")).toBeVisible(); } -test.describe("GPT 5.6 Sol TOTALITY mini golf", () => { +test.describe("GPT 5.6 Sol TOTALITY mini golf", { + tag: ["@spec:mini-golf-sol", "@game:mini-golf/gpt-5-6-sol"], +}, () => { test("loads as a self-contained Three.js game with no external requests", async ({ page }) => { const externalRequests: string[] = []; page.on("request", (request) => { diff --git a/tests/mobile-layout.spec.ts b/tests/mobile-layout.spec.ts index b84f1f3..937bae9 100644 --- a/tests/mobile-layout.spec.ts +++ b/tests/mobile-layout.spec.ts @@ -61,9 +61,13 @@ async function expectFullyInViewport(page: Page, locator: Locator) { expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 1); } -test.describe("Gameplay-first layout regressions", () => { +test.describe("Gameplay-first layout regressions", { + tag: "@spec:mobile-layout", +}, () => { for (const model of ["gpt-5-5", "gpt-5-4", "gpt-5-4-mini", "gpt-5-6-sol", "opus-4-8", "fable-5"]) { - test(`Coastal Rush '86 ${model} keeps the road and touch controls reachable`, async ({ + test(`Coastal Rush '86 ${model} keeps the road and touch controls reachable`, { + tag: `@game:coastal-rush-86/${model}`, + }, async ({ page, }) => { await openStandaloneGame(page, `/games/coastal-rush-86/${model}/index.html`); @@ -94,7 +98,9 @@ test.describe("Gameplay-first layout regressions", () => { } for (const model of ["gpt-5-4", "gpt-5-5"]) { - test(`Pac-Man ${model} keeps swipe-first controls and the maze in view`, async ({ page }) => { + test(`Pac-Man ${model} keeps swipe-first controls and the maze in view`, { + tag: `@game:pac-man/${model}`, + }, async ({ page }) => { await instrumentAudioStarts(page); await openStandaloneGame(page, `/games/pac-man/${model}/index.html`); @@ -119,7 +125,9 @@ test.describe("Gameplay-first layout regressions", () => { }); } - test("GPT 5.5 Pac-Man and ghosts move after the ready countdown", async ({ page }) => { + test("GPT 5.5 Pac-Man and ghosts move after the ready countdown", { + tag: "@game:pac-man/gpt-5-5", + }, async ({ page }) => { await openStandaloneGame(page, "/games/pac-man/gpt-5-5/index.html"); await page.locator("#primaryButton").click(); await page.waitForTimeout(1900); @@ -141,7 +149,9 @@ test.describe("Gameplay-first layout regressions", () => { expect(Math.hypot(after.ghost.x - before.ghost.x, after.ghost.y - before.ghost.y)).toBeGreaterThan(10); }); - test("GPT 5.5 Pac-Man accepts another direction after stopping at a wall", async ({ page }) => { + test("GPT 5.5 Pac-Man accepts another direction after stopping at a wall", { + tag: "@game:pac-man/gpt-5-5", + }, async ({ page }) => { await openStandaloneGame(page, "/games/pac-man/gpt-5-5/index.html"); await page.locator("#primaryButton").click(); await page.waitForTimeout(1900); @@ -164,7 +174,9 @@ test.describe("Gameplay-first layout regressions", () => { expect(Math.hypot(turned.x - stopped.x, turned.y - stopped.y)).toBeGreaterThan(10); }); - test("Sudoku keeps the board and number pad playable together without scrolling", async ({ + test("Sudoku keeps the board and number pad playable together without scrolling", { + tag: "@game:sudoku/gpt-5-4", + }, async ({ page, }) => { await instrumentAudioStarts(page); diff --git a/tests/model-identity.spec.ts b/tests/model-identity.spec.ts index fd33ac0..991e325 100644 --- a/tests/model-identity.spec.ts +++ b/tests/model-identity.spec.ts @@ -6,7 +6,9 @@ import { isKnownModelId, } from "../src/lib/modelCatalog"; -test.describe("Model identity system", () => { +test.describe("Model identity system", { + tag: ["@spec:model-identity", "@area:site"], +}, () => { test("catalog covers every model and uses unique public names", () => { const entries = Object.values(MODEL_CATALOG); expect(new Set(entries.map((entry) => entry.displayName)).size).toBe(entries.length); diff --git a/tests/models.spec.ts b/tests/models.spec.ts index e3cece3..b6e9a63 100644 --- a/tests/models.spec.ts +++ b/tests/models.spec.ts @@ -1,6 +1,8 @@ import { expect, test } from "@playwright/test"; -test.describe("Model explorer", () => { +test.describe("Model explorer", { + tag: ["@spec:models", "@area:site"], +}, () => { test("groups models by company with frontier labs first", async ({ page }) => { await page.goto("/models"); await expect(page.getByRole("heading", { name: "Meet the minds behind the games." })).toBeVisible(); diff --git a/tests/outrun-racer-opus-5.spec.ts b/tests/outrun-racer-opus-5.spec.ts index 688a303..cd92b9f 100644 --- a/tests/outrun-racer-opus-5.spec.ts +++ b/tests/outrun-racer-opus-5.spec.ts @@ -116,7 +116,9 @@ async function primed(page: Page, seed = 7) { }, seed); } -test.describe("Claude Opus 5 Neon Horizon Racer: Skyweave", () => { +test.describe("Claude Opus 5 Neon Horizon Racer: Skyweave", { + tag: ["@spec:outrun-racer-opus-5", "@game:outrun-racer/opus-5"], +}, () => { test("loads the pinned runtime and Blender-authored assets without external requests", async ({ page }) => { const external: string[] = []; const errors: string[] = []; diff --git a/tests/outrun-racer-sol.spec.ts b/tests/outrun-racer-sol.spec.ts index a2fd87b..b26c19a 100644 --- a/tests/outrun-racer-sol.spec.ts +++ b/tests/outrun-racer-sol.spec.ts @@ -50,7 +50,9 @@ async function openGame(page: Page) { await expect.poll(() => page.evaluate(() => Boolean(window.__THREE_GAME_TEST_HOOKS__))).toBe(true); } -test.describe("GPT 5.6 Sol Neon Horizon Racer", () => { +test.describe("GPT 5.6 Sol Neon Horizon Racer", { + tag: ["@spec:outrun-racer-sol", "@game:outrun-racer/gpt-5-6-sol"], +}, () => { test("loads the pinned runtime and Blender-authored assets without external requests", async ({ page }) => { const external: string[] = [], errors: string[] = [], assets: string[] = []; page.on("request", request => { diff --git a/tests/perihelion-opus-5.spec.ts b/tests/perihelion-opus-5.spec.ts index a163d38..20deccb 100644 --- a/tests/perihelion-opus-5.spec.ts +++ b/tests/perihelion-opus-5.spec.ts @@ -122,7 +122,9 @@ async function openGame(page: Page, viewport = { width: 1280, height: 720 }) { .toBe(true); } -test.describe("Claude Opus 5 Perihelion Post", () => { +test.describe("Claude Opus 5 Perihelion Post", { + tag: ["@spec:perihelion-opus-5", "@game:perihelion/opus-5"], +}, () => { test("runs standalone on the pinned runtime with no external requests or errors", async ({ page }) => { const external: string[] = []; const runtime: string[] = []; diff --git a/tests/ratings-feedback.spec.ts b/tests/ratings-feedback.spec.ts index 5470293..2261272 100644 --- a/tests/ratings-feedback.spec.ts +++ b/tests/ratings-feedback.spec.ts @@ -16,7 +16,9 @@ function feedback(voteCount: number, failCount: number) { }); } -test.describe("community failure verdict logic", () => { +test.describe("community failure verdict logic", { + tag: ["@spec:ratings-feedback", "@area:ratings"], +}, () => { test("uses the literal half-or-more threshold without a quorum", () => { expect(buildVersionFeedback({})).toBeNull(); expect(feedback(0, 1)?.failed).toBe(true); diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index a61b743..f0c41df 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -21,7 +21,9 @@ const games = [ "kart-racing", ]; -test.describe("BrainRot Games — Smoke Tests", () => { +test.describe("BrainRot Games — Smoke Tests", { + tag: ["@spec:smoke", "@area:site"], +}, () => { test("landing page loads with hero and game cards", async ({ page }) => { await page.goto("/"); diff --git a/tests/sudoku-regressions.spec.ts b/tests/sudoku-regressions.spec.ts index 2ec0653..5f032b3 100644 --- a/tests/sudoku-regressions.spec.ts +++ b/tests/sudoku-regressions.spec.ts @@ -56,8 +56,12 @@ function isValidSolvedCell(grid: number[], row: number, col: number) { return true; } -test.describe("Sudoku regression guards", () => { - test("GPT Sudoku does not reject correct digits just because candidates are poisoned", () => { +test.describe("Sudoku regression guards", { + tag: "@spec:sudoku-regressions", +}, () => { + test("GPT Sudoku does not reject correct digits just because candidates are poisoned", { + tag: "@game:sudoku/gpt-5-4", + }, () => { const source = readGameSource("public", "games", "sudoku", "gpt-5-4", "index.html"); expect(source).toMatch( @@ -72,7 +76,9 @@ test.describe("Sudoku regression guards", () => { ); }); - test("GPT Sudoku puzzle bank only ships solvable boards with matching solutions", () => { + test("GPT Sudoku puzzle bank only ships solvable boards with matching solutions", { + tag: "@game:sudoku/gpt-5-4", + }, () => { const source = readGameSource("public", "games", "sudoku", "gpt-5-4", "index.html"); const entries = parsePuzzleBank(source); @@ -103,7 +109,9 @@ test.describe("Sudoku regression guards", () => { }); }); - test("Sonnet Sudoku numpad maps digits 1-9 without an off-by-one shift", () => { + test("Sonnet Sudoku numpad maps digits 1-9 without an off-by-one shift", { + tag: "@game:sudoku/sonnet-4-6", + }, () => { const source = readGameSource("public", "games", "sudoku", "sonnet-4-6", "index.html"); expect(source).toMatch(/if\(col>=0&&col<=8\)return col\+1;\s*\/\/ digits 1-9/); diff --git a/tests/tetris-luna.spec.ts b/tests/tetris-luna.spec.ts index dee06d4..0310dbb 100644 --- a/tests/tetris-luna.spec.ts +++ b/tests/tetris-luna.spec.ts @@ -28,7 +28,9 @@ async function expectInViewport(page: Page, selector: string) { expect(box.y + box.height).toBeLessThanOrEqual(viewport.height + 1); } -test.describe("GPT 5.6 Luna Tetris", () => { +test.describe("GPT 5.6 Luna Tetris", { + tag: ["@spec:tetris-luna", "@game:tetris/gpt-5-6-luna"], +}, () => { test("loads standalone without page errors and exposes the lunar start state", async ({ page }) => { const errors: string[] = []; page.on("pageerror", (error) => errors.push(error.message)); @@ -82,4 +84,4 @@ test.describe("GPT 5.6 Luna Tetris", () => { expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(1280); expect(await page.evaluate(() => document.documentElement.scrollHeight)).toBeLessThanOrEqual(800); }); -}); \ No newline at end of file +}); diff --git a/tests/threejs-pipeline.spec.ts b/tests/threejs-pipeline.spec.ts index a4e1e57..ef8c817 100644 --- a/tests/threejs-pipeline.spec.ts +++ b/tests/threejs-pipeline.spec.ts @@ -6,7 +6,9 @@ import { expect, test } from "@playwright/test"; const execFileAsync = promisify(execFile); -test("pinned Three.js and GLTFLoader import in an opaque sandbox iframe", async ({ +test("pinned Three.js and GLTFLoader import in an opaque sandbox iframe", { + tag: ["@spec:threejs-pipeline", "@area:threejs"], +}, async ({ page, }) => { const browserErrors: string[] = []; @@ -78,7 +80,9 @@ test("pinned Three.js and GLTFLoader import in an opaque sandbox iframe", async } }); -test("play page forwards test mode without weakening the iframe sandbox", async ({ +test("play page forwards test mode without weakening the iframe sandbox", { + tag: ["@spec:threejs-pipeline", "@area:site", "@game:maze-3d/gpt-5-6-sol"], +}, async ({ page, }) => { await page.goto("/games/maze-3d/gpt-5-6-sol?test=1"); @@ -94,7 +98,9 @@ test("play page forwards test mode without weakening the iframe sandbox", async await expect(page.locator("[data-asset-summary]")).toHaveCount(0); }); -test("Three.js inspector creates a report and iframe screenshot", async ({ +test("Three.js inspector creates a report and iframe screenshot", { + tag: ["@spec:threejs-pipeline", "@area:threejs", "@game:maze-3d/gpt-5-6-sol"], +}, async ({ baseURL, }, testInfo) => { const outputDir = testInfo.outputPath("threejs-inspection"); diff --git a/tests/tile-matching-opus-5.spec.ts b/tests/tile-matching-opus-5.spec.ts index 7b24f9e..990e55b 100644 --- a/tests/tile-matching-opus-5.spec.ts +++ b/tests/tile-matching-opus-5.spec.ts @@ -217,7 +217,9 @@ const totalCleared = (state: Snapshot) => state.objective.collected.reduce((sum, const boardIsFull = (state: Snapshot) => state.board.every((row, r) => row.every((tile, c) => (state.mask[r][c] === 1 ? Boolean(tile) : tile === null))); -test.describe("Claude Opus 5 Tile Matching — Northlight", () => { +test.describe("Claude Opus 5 Tile Matching — Northlight", { + tag: ["@spec:tile-matching-opus-5", "@game:tile-matching/opus-5"], +}, () => { test("boots night one deterministically with audio and a legal board", async ({ page }) => { await page.addInitScript(() => { window.__northlightAudioStarts = 0; diff --git a/tests/tile-matching-sol.spec.ts b/tests/tile-matching-sol.spec.ts index 453922a..e69038e 100644 --- a/tests/tile-matching-sol.spec.ts +++ b/tests/tile-matching-sol.spec.ts @@ -98,7 +98,9 @@ async function snapshot(page: Page) { return page.evaluate(() => window.__solFlareTest.snapshot()); } -test.describe("GPT 5.6 Sol Tile Matching", () => { +test.describe("GPT 5.6 Sol Tile Matching", { + tag: ["@spec:tile-matching-sol", "@game:tile-matching/gpt-5-6-sol"], +}, () => { test("starts with 36 moves, clear rewards, audio, and a deterministic legal board", async ({ page }) => { await page.addInitScript(() => { window.__solAudioStarts = 0;