Docs /features/scoped-test-selection

Scoped test selection

When the GitHub App's PR-delta orchestrator triggers a test run, the runner reads .izri/test-scope.json at the cloned head SHA and uses it to decide which Playwright specs to execute. Without scoping a 3-file PR pays the same ~10-minute ceiling as a 300-file PR; with scoping the runner can skip everything that the diff can't possibly affect.

This is a per-repo, declarative file. The izri repo ships one as a worked example — .izri/test-scope.json at the repo root.

File location

<repo-root>/.izri/test-scope.json

The runner reads it after cloning, before invoking npx playwright test. If the file is missing, malformed, or the diff doesn't match any mapping, the runner falls back to the full suite — running too many specs is a strictly safer failure mode than running too few.

Schema

{
  "version": 1,

  // Diff paths that should escalate to the full suite. Use for shared
  // infrastructure where almost anything could break (lockfiles, root
  // configs, the database schema, your test runner config).
  "fullSuiteOn": [
    "packages/database/**",
    "pnpm-lock.yaml",
    "package.json",
    "playwright.config.*"
  ],

  // Specs that always run regardless of which paths changed. Cheap
  // safety nets — error pages, regressions for previously-shipped
  // bugs, smoke tests. Keep this list small; specs here run on every
  // PR.
  "alwaysRun": [
    "errors.spec.ts",
    "meta.spec.ts"
  ],

  // The body of the mapping. Each entry says: when a changed file
  // matches `changed` (glob), include every spec in `specs`. Mappings
  // are unioned — a PR touching auth AND projects will pick up specs
  // from both.
  "mappings": [
    { "changed": "packages/auth/**", "specs": ["auth.spec.ts"] },
    {
      "changed": "packages/trpc/src/routers/projects.ts",
      "specs": ["projects.spec.ts", "settings.spec.ts"]
    }
  ]
}

Glob syntax

The runner ships a tiny in-house matcher (see apps/runner/src/specSelector.ts):

Pattern Meaning
* matches anything except /
** matches anything including /; foo/** also matches bare foo
? matches a single character except /
any other char literal (regex metacharacters auto-escaped)

It deliberately does not pull in picomatch or minimatch — keeping the runner image lean. If you find yourself needing more expressive patterns, file an issue and we'll swap in picomatch.

Resolution order

When a job runs the selector reaches exactly one of these discrete decisions, exposed as the decision field on the log line and on test_runs.results.selection:

decision When it fires Outcome
no_config .izri/test-scope.json missing or unreadable Full suite
empty_diff Orchestrator didn't pass any changedFiles Full suite
full_match A changed file matched a fullSuiteOn glob Full suite
no_mappings_matched No mappings matched AND no alwaysRun specs are configured Full suite
mapped alwaysRun ∪ specs from each matching mappings entry, deduped (non-empty) Scoped subset

The decision is logged at info from apps/runner so Railway log search can answer "did this run get scoped, and if not why?" in one query:

{
  "msg": "job complete",
  "testRunId": "01KS…",
  "status": "PASSED",
  "selection": {
    "runFull": false,
    "decision": "mapped",
    "reason": "scoped to 3 spec(s) via .izri/test-scope.json",
    "specCount": 3
  }
}

Full-suite escalations carry the same decision discriminator:

{
  "msg": "job complete",
  "selection": {
    "runFull": true,
    "decision": "full_match",
    "reason": "changed file pnpm-lock.yaml matches fullSuiteOn pattern pnpm-lock.yaml"
  }
}

The same decision (with full spec list) is also persisted to test_runs.results.selection and surfaced as the first line of test_runs.logs for failure post-mortem.

One mapping table, two consumers

.github/workflows/e2e.yaml consumes the same .izri/test-scope.json through .github/scripts/select-e2e-specs.mjs (see #262). The JSON file is the single source of truth — there is no parallel mapping table to keep in sync.

Authoring tips

  • Map by import surface, not by feature. When module X is changed, which specs exercise code that imports X (transitively)? Those are the specs that should run. Web routes are the easy case; shared packages (packages/trpc/**, packages/shared/**) tend to escalate to full because almost anything imports them.
  • Lean on alwaysRun. A small set of cross-cutting specs in alwaysRun is cheaper than mapping every conceivable path → spec. Anything that catches "the build is broken" without needing feature-specific setup (404 pages, basic auth flow, key landing page) belongs there.
  • Prefer over-running to under-running. Selector failures default to full suite for a reason. A green CI on a half-tested PR is worse than a 12-minute CI on a 3-file PR.

See also

  • apps/runner/src/specSelector.ts — implementation + tests
  • .izri/test-scope.json — worked example in this repo
  • .github/scripts/select-e2e-specs.mjs — CI-side consumer used by .github/workflows/e2e.yaml

Reading this with an agent? /docs/features/scoped-test-selection.md serves the raw markdown.

All docs