A GitHub Action that runs only the Playwright specs a pull request can actually affect — and escalates to the full suite whenever it can't justify narrowing.
- uses: sjzavala/playwright-test-selector@v1
id: select
with:
test-dir: tests
- run: npx playwright test ${{ steps.select.outputs.specs }}That's it. Two steps, no config file, no maintained mapping table.
Most diff-to-test tooling infers the mapping — from filename similarity, from import graphs, from coverage traces that go stale the moment someone refactors. The inference is invisible, so when it's wrong you find out by shipping a regression the suite could have caught.
This action doesn't infer. It reads a declaration the test itself carries:
/**
* @qase-id BOR-12
* @covers server/routes.js client/src/App.jsx
* @guards BUG-3 — lowercase query returned zero results (server/routes.js:37-39)
*/
test('BOR-12 — search is case-insensitive', { tag: ['@regression'] }, async ({ page }) => {@covers is written by whoever wrote the test, at the moment they knew exactly what it
exercised. The action parses it, maps the diff, and shows its work.
Narrowing never happens on a guess. Every rule either explains itself or escalates:
| Rule | Effect |
|---|---|
spec-changed |
a changed spec always runs itself |
covers-glob |
changed file matches a spec's @covers |
guards-path |
changed file is cited in a spec's @guards prose |
smoke |
anything tagged @smoke runs on every PR |
| infra changed | lockfiles, playwright.config.*, workflows → full suite |
| unmapped file | no spec claims the changed file → full suite |
Those last two are the point. A repo with zero @covers annotations gets correct results
on day one — it simply always runs everything — and gets faster as annotations accumulate.
There is no adoption cliff, and no state where the action quietly skips the one test that
mattered.
Every decision lands on the PR:
Selected 1 of 2 specs.
Spec Case Why it was selected tests/smoke.spec.jsBOR-1 tagged @smoke
declared in@covers→server/index.jsSkipped 1 spec
tests/search-case-insensitive.spec.js(BOR-5)
name: PR tests
on:
pull_request:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: write
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # required — the selector diffs against the merge base
- uses: actions/setup-node@v7
with:
node-version: '22'
- run: npm ci
- run: npx playwright install chromium
- uses: sjzavala/playwright-test-selector@v1
id: select
with:
test-dir: tests
label: use-intelligent-runner # optional: only narrow on labelled PRs
comment: 'true'
# Unquoted on purpose: an empty value correctly runs the whole suite.
- run: npx playwright test ${{ steps.select.outputs.specs }}If you don't already have a Playwright job:
jobs:
tests:
uses: sjzavala/playwright-test-selector/.github/workflows/intelligent-runner.yml@v1
permissions:
contents: read
pull-requests: write
with:
test-dir: testsThe engine is a plain Node script with no dependencies:
node scripts/select-tests.mjs --changed "server/routes.js" --test-dir tests| Input | Default | Description |
|---|---|---|
test-dir |
tests |
Directory containing Playwright specs. |
changed-files |
(derived) | Newline/comma-separated list. Omit to diff against the PR's merge base. |
label |
'' |
Only narrow when the PR carries this label. Empty means always narrow. |
config |
.qa-tms/selection.json |
Optional escalation-glob overrides. |
comment |
false |
Post the selection table as a PR comment. Needs pull-requests: write. |
github-token |
${{ github.token }} |
Token used for the comment. |
| Output | Description |
|---|---|
mode |
subset when narrowing was safe, full when it escalated. |
specs |
Space-separated spec paths. Empty in full mode — pass it unquoted and an empty value naturally runs everything. |
selected-count / total-count |
For badges, summaries, or your own gating. |
summary |
Path to the markdown explaining the selection. |
Name what the test actually exercised — the endpoints the run hit, the components that rendered. Prefer a file over a directory, and a directory over a wildcard:
* @covers server/routes.js client/src/**/*.jsxOver-broad globs are not a safe hedge. server/** on every spec makes the map useless and
quietly returns you to running everything.
Omitting it is safe but costly: an unclaimed file escalates that PR to the full suite. That's the intended fallback, not a failure.
Per-repo, in .qa-tms/selection.json:
{
"infraGlobs": ["playwright.config.*", "package.json", "src/testing/**"],
"ignoreGlobs": ["**/*.md", "docs/**"]
}The logic that decides what not to run is the last place to accept "looks right":
- 32 unit tests over glob matching, header parsing, and every selection rule — including the escalation paths, which are the ones that keep it safe.
- A self-test job where the action runs itself against fixture specs in
examples/and asserts on its own outputs: that it narrows, that it escalates on an unclaimed file, that a path cited only in@guardsprose still maps, and that the label gate holds.
The @covers / @guards header is the traceability format emitted by
qa-tms, which generates Playwright specs from
managed test cases. This action works with any spec carrying the header — the plugin is not
required.
See it running on a real PR in borrower-search.
MIT