PlanWright
verify.yml Starter Kits

Platform

verify.yml Starter Kits

.planwright/verify.yml tells Planwright how to run the tests an objective needs and where the reports land. These kits are working files for the three most common stacks, each with a minimal example repo, the marker convention for its test runner, coverage, and a CI workflow.

Copy the kit for your stack and change the commands to fit your repo. Other languages use the same protocol: any runner that writes JUnit XML works.

How the protocol works

  1. When an agent claims an objective, each acceptance criterion gets a marker such as [AC:3f2a91c0:ac_3]: the objective's id prefix and the criterion's id. The claim response lists them.
  2. The agent writes checked-in tests whose names carry those markers. Tests that guard an invariant carry [INV:<key>].
  3. Planwright fills the markers a run needs into verify.yml's run command and hands the agent the exact commands.
  4. The agent runs them on a clean checkout of its commit and submits the raw reports. Planwright checks that every selected marker is there.
  5. Your CI runs the full suite and uploads the same reports. Planwright reads them from the workflow's artifacts and matches them to the agent's commit, as independent evidence.

Agents run only the tests their change needs. Whether and where the full suite runs is your choice, in your CI.

The keys

version
Optional. Always 1.
setup
Optional. Runs once before the tests, e.g. installing dependencies.
run
Required. Runs the selected tests. Must contain {grep}, a regular expression matching the markers, or {markers}, a space-separated list of quoted markers.
files
Optional. Runs whole test files, given as {files}: the test files the test-impact index selects because their coverage includes a changed file. Writes its own JUnit report.
reports
Required. Where the commands write their reports, each with a format: junit, playwright, lcov, cobertura or jacoco. At least one must be junit or playwright.
coverage
Optional. Measures one test file, given as {file}, and writes a coverage report listed under reports. For the test-impact index.
coverage_noise
Optional, default 0.5. Source files covered by more than this share of measured test files are left out of the index as noise.
full
Optional. Runs the whole suite and writes a report. Only used to generate the CI workflow; never handed to an agent.

Commands run in a shell from the repository root. A commit that changes the file is approved when it lands on your default branch; an agent's change is approved when you accept its objective.

Markers

A test proves a criterion when its name carries [AC:<objective prefix>:<criterion id>], and guards an invariant when it carries [INV:<key>]. Use the exact marker from the claim response: every objective has an ac_1, so the prefix is what ties a result to one objective. Where the marker goes depends on the runner, since some don't allow brackets in a test function's name; each kit below shows how. A test without a marker still runs with the full suite, but doesn't count as evidence.

Report formats

JUnit XML

Test results from any runner: vitest, pytest --junitxml, gotestsum, Maven Surefire, dotnet test, rspec. The testcase name is the test's title, so the marker must be in it.

Playwright JSON

Test results from Playwright's json reporter. The test title carries the marker.

lcov

Coverage: which source files a test file ran. The coverage command's report builds the test-impact index; the run's own lcov is recorded with its evidence.

Cobertura XML

Coverage from coverage.py (coverage xml), coverlet for .NET, and other tools. Read for the test-impact index like lcov.

JaCoCo XML

Coverage from JaCoCo on the JVM. Files are named by package path (com/acme/Foo.java) and matched to your repo's source paths by suffix.

What falls back to hand-written scopes. An invariant's scope, the files whose changes require its tests, is the set of path globs a person writes on the Verification page's Invariants tab. The coverage command feeds a test-impact index that will work out which tests a change can affect, tagged or not. That index isn't available yet, so today every scope is hand-written, and a run is checked against the invariants whose globs its diff touches.

JavaScript and TypeScript

Starter kit

vitest for unit tests, Playwright for end-to-end tests, pnpm

Layout

.planwright/verify.yml
e2e/notes.spec.ts
src/notes.test.ts
src/notes.ts
package.json
playwright.config.ts
vitest.config.ts

.planwright/verify.yml

The test commands agents are handed. Commit it; it applies once it lands on your default branch.

version: 1
setup: pnpm install --frozen-lockfile && pnpm exec playwright install chromium
run: >-
  status=0;
  pnpm vitest run -t "{grep}" --passWithNoTests
  --reporter=junit --outputFile=reports/junit.xml
  --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage
  --coverage.reportOnFailure
  || status=1;
  PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json
  pnpm playwright test --grep "{grep}" --pass-with-no-tests --reporter=json
  || status=1;
  exit $status
files: pnpm vitest run {files} --passWithNoTests --reporter=junit --outputFile=reports/files.xml
reports:
  - path: reports/junit.xml
    format: junit
  - path: reports/playwright.json
    format: playwright
  - path: reports/files.xml
    format: junit
  - path: coverage/lcov.info
    format: lcov
coverage: pnpm vitest run {file} --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage
full: >-
  status=0;
  pnpm vitest run --reporter=junit --outputFile=reports/junit.xml || status=1;
  PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json
  pnpm playwright test --reporter=json || status=1;
  exit $status

src/notes.test.ts

Unit tests. The marker ends the title; the last test has none, so it runs only with the full suite.

import { describe, expect, it } from "vitest";
import { addNote } from "./notes";

describe("addNote", () => {
  it("refuses a blank note [AC:3f2a91c0:ac_3]", () => {
    expect(() => addNote([], "   ")).toThrow("blank");
  });

  it("never stores surrounding whitespace [INV:notes-trimmed]", () => {
    expect(addNote([], "  buy milk ")).toEqual(["buy milk"]);
  });

  it("keeps earlier notes", () => {
    expect(addNote(["a"], "b")).toEqual(["a", "b"]);
  });
});

e2e/notes.spec.ts

An end-to-end test with its own marker. A real one would open your app with page.goto.

import { expect, test } from "@playwright/test";

test("shows a saved note [AC:3f2a91c0:ac_1]", async ({ page }) => {
  await page.setContent("<ul><li>buy milk</li></ul>");
  await expect(page.getByRole("listitem")).toHaveText("buy milk");
});
The rest of the example repo (src/notes.ts, package.json, vitest.config.ts, playwright.config.ts)

src/notes.ts

export function addNote(notes: string[], text: string): string[] {
  const note = text.trim();
  if (!note) throw new Error("A note can't be blank.");
  return [...notes, note];
}

package.json

{
  "name": "notes",
  "private": true,
  "type": "module",
  "packageManager": "pnpm@9.12.0",
  "scripts": {
    "test": "vitest run"
  },
  "devDependencies": {
    "@playwright/test": "^1.48.0",
    "@vitest/coverage-v8": "^2.1.0",
    "vitest": "^2.1.0"
  }
}

vitest.config.ts

import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { include: ["src/**/*.test.ts"], coverage: { include: ["src/**"] } },
});

playwright.config.ts

import { defineConfig } from "@playwright/test";

export default defineConfig({ testDir: "e2e" });

Markers

Put the marker at the end of the test title: it("refuses a blank note [AC:3f2a91c0:ac_3]"). vitest selects by title with -t and Playwright with --grep; both take the regular expression Planwright puts in {grep}.

Run one test

What an agent runs when its selection is one criterion, then one invariant. Planwright fills in the markers; you never type these yourself.

[AC:3f2a91c0:ac_3]

status=0; pnpm vitest run -t "\[AC:3f2a91c0:ac_3\]" --passWithNoTests --reporter=junit --outputFile=reports/junit.xml --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage --coverage.reportOnFailure || status=1; PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json pnpm playwright test --grep "\[AC:3f2a91c0:ac_3\]" --pass-with-no-tests --reporter=json || status=1; exit $status

[INV:notes-trimmed]

status=0; pnpm vitest run -t "\[INV:notes-trimmed\]" --passWithNoTests --reporter=junit --outputFile=reports/junit.xml --coverage --coverage.reporter=lcov --coverage.reportsDirectory=coverage --coverage.reportOnFailure || status=1; PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json pnpm playwright test --grep "\[INV:notes-trimmed\]" --pass-with-no-tests --reporter=json || status=1; exit $status

Coverage

The run command adds --coverage --coverage.reporter=lcov, so each verification run writes coverage/lcov.info for the selected tests (needs @vitest/coverage-v8). The coverage command measures one test file at a time, for the test-impact index, and files runs the whole test files that index selects for a change.

CI workflow

Commit this as .github/workflows/planwright-tests.yml, with your default branch in place of main. It runs the full suite on every push and pull request and uploads the test reports as an artifact, even when tests fail. Planwright reads that artifact with the GitHub App's Actions: read permission and credits each result to the criterion or invariant its marker names. Skip it if your CI already runs the suite and uploads these reports. A project with no CI configuration is offered this same file on its Verification page.

# Generated by Planwright from .planwright/verify.yml. Review it, then commit it yourself;
# Planwright never commits to your repository. Planwright reads the uploaded test reports
# as independent evidence for the objectives whose commits this runs on.
name: Tests
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4   # pnpm version from packageManager in package.json
      - uses: actions/setup-node@v4
        with:
          node-version: lts/*   # no .nvmrc, .node-version or engines.node found; pin your version
          cache: pnpm
      - name: Set up
        run: |
          pnpm install --frozen-lockfile && pnpm exec playwright install chromium
      - name: Run the full test suite
        run: |
          status=0; pnpm vitest run --reporter=junit --outputFile=reports/junit.xml || status=1; PLAYWRIGHT_JSON_OUTPUT_NAME=reports/playwright.json pnpm playwright test --reporter=json || status=1; exit $status
      - name: Upload test reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: |
            reports/junit.xml
            reports/playwright.json
            reports/files.xml
          if-no-files-found: warn

Worth knowing

  • Playwright doesn't write lcov, so coverage comes from vitest only.
  • setup installs Chromium without --with-deps, which needs root: agents run it on their own machines. If CI reports missing system libraries for the browser, add --with-deps to that step in the workflow.
  • The run command keeps going when the unit tests fail, so the Playwright report and lcov are still written (coverage.reportOnFailure), and exits non-zero if either failed.

Python

Starter kit

pytest, with pytest-cov (coverage.py) for coverage

Layout

.planwright/verify.yml
src/notes/__init__.py
tests/test_notes.py
.python-version
conftest.py
pyproject.toml
requirements-dev.txt

.planwright/verify.yml

The test commands agents are handed. Commit it; it applies once it lands on your default branch.

version: 1
setup: python -m pip install -r requirements-dev.txt
run: >-
  python -m pytest --planwright {markers}
  --junitxml=reports/junit.xml
  --cov --cov-report=lcov:coverage/lcov.info
files: python -m pytest {files} --junitxml=reports/files.xml
reports:
  - path: reports/junit.xml
    format: junit
  - path: reports/files.xml
    format: junit
  - path: coverage/lcov.info
    format: lcov
coverage: python -m pytest {file} --cov --cov-report=lcov:coverage/lcov.info
full: python -m pytest --junitxml=reports/junit.xml

tests/test_notes.py

The marker is a parametrize id, so it lands in the test's name. The last test has none, so it runs only with the full suite.

import pytest

from notes import add_note


@pytest.mark.parametrize("marker", ["AC:3f2a91c0:ac_3"])
def test_refuses_a_blank_note(marker):
    with pytest.raises(ValueError, match="blank"):
        add_note([], "   ")


@pytest.mark.parametrize("marker", ["INV:notes-trimmed"])
def test_never_stores_surrounding_whitespace(marker):
    assert add_note([], "  buy milk ") == ["buy milk"]


def test_keeps_earlier_notes():
    assert add_note(["a"], "b") == ["a", "b"]

conftest.py

Adds --planwright: run only the tests whose names carry one of the given markers. Public pytest hooks only.

def pytest_addoption(parser):
    parser.addoption(
        "--planwright",
        nargs="*",
        metavar="MARKER",
        help="Run only tests whose names contain one of these [AC:...] / [INV:...] markers.",
    )


def pytest_collection_modifyitems(config, items):
    wanted = config.getoption("--planwright")
    if wanted is None:
        return
    selected, deselected = [], []
    for item in items:
        (selected if any(m in item.name for m in wanted) else deselected).append(item)
    if deselected:
        config.hook.pytest_deselected(items=deselected)
        items[:] = selected
The rest of the example repo (src/notes/__init__.py, pyproject.toml, requirements-dev.txt, .python-version)

src/notes/__init__.py

def add_note(notes: list[str], text: str) -> list[str]:
    note = text.strip()
    if not note:
        raise ValueError("A note can't be blank.")
    return [*notes, note]

pyproject.toml

[project]
name = "notes"
version = "0.1.0"
requires-python = ">=3.10"

[tool.pytest.ini_options]
pythonpath = ["src"]
testpaths = ["tests"]

[tool.coverage.run]
source = ["src"]

requirements-dev.txt

pytest>=8
pytest-cov>=5

.python-version

3.12

Markers

A pytest test's name can't contain brackets, but a parametrize id can: @pytest.mark.parametrize("marker", ["AC:3f2a91c0:ac_3"]) makes the name test_refuses_a_blank_note[AC:3f2a91c0:ac_3], and that name is what the JUnit report carries. The kit's conftest.py adds a --planwright option that keeps only tests whose names contain one of the markers Planwright puts in {markers}.

Run one test

What an agent runs when its selection is one criterion, then one invariant. Planwright fills in the markers; you never type these yourself.

[AC:3f2a91c0:ac_3]

python -m pytest --planwright '[AC:3f2a91c0:ac_3]' --junitxml=reports/junit.xml --cov --cov-report=lcov:coverage/lcov.info

[INV:notes-trimmed]

python -m pytest --planwright '[INV:notes-trimmed]' --junitxml=reports/junit.xml --cov --cov-report=lcov:coverage/lcov.info

Coverage

The run command adds --cov --cov-report=lcov:coverage/lcov.info, so each verification run writes lcov for the selected tests (needs pytest-cov 4 or later, on coverage.py 6.3 or later). The coverage command measures one test file at a time, for the test-impact index, and files runs the whole test files that index selects for a change.

CI workflow

Commit this as .github/workflows/planwright-tests.yml, with your default branch in place of main. It runs the full suite on every push and pull request and uploads the test reports as an artifact, even when tests fail. Planwright reads that artifact with the GitHub App's Actions: read permission and credits each result to the criterion or invariant its marker names. Skip it if your CI already runs the suite and uploads these reports. A project with no CI configuration is offered this same file on its Verification page.

# Generated by Planwright from .planwright/verify.yml. Review it, then commit it yourself;
# Planwright never commits to your repository. Planwright reads the uploaded test reports
# as independent evidence for the objectives whose commits this runs on.
name: Tests
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version-file: .python-version
      - name: Set up
        run: |
          python -m pip install -r requirements-dev.txt
      - name: Run the full test suite
        run: |
          python -m pytest --junitxml=reports/junit.xml
      - name: Upload test reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: |
            reports/junit.xml
            reports/files.xml
          if-no-files-found: warn

Worth knowing

  • pytest exits with code 5 when no test matches. That happens when a run's selection is empty, before the agent has written its tests.
  • If you use uv, put uv run in front of each command and set setup to uv sync.

Go

Starter kit

go test through gotestsum for JUnit XML, -coverprofile converted to lcov with gcov2lcov

Layout

.planwright/verify.yml
notes/notes_test.go
notes/notes.go
go.mod

.planwright/verify.yml

The test commands agents are handed. Commit it; it applies once it lands on your default branch.

version: 1
setup: go install gotest.tools/gotestsum@v1.12.0
run: >-
  status=0;
  gotestsum --junitfile reports/junit.xml --
  -run '/({grep})' -coverprofile=coverage.out ./...
  || status=1;
  mkdir -p coverage &&
  go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info &&
  sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info;
  exit $status
files: >-
  gotestsum --junitfile reports/files.xml --
  $(for f in {files}; do echo "./$(dirname "$f")"; done | sort -u)
reports:
  - path: reports/junit.xml
    format: junit
  - path: reports/files.xml
    format: junit
  - path: coverage/lcov.info
    format: lcov
coverage: >-
  go test -coverprofile=coverage.out ./$(dirname {file}) &&
  mkdir -p coverage &&
  go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info &&
  sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info
full: gotestsum --junitfile reports/junit.xml -- ./...

notes/notes_test.go

Each marked check is a subtest whose name ends with the marker. The last has none, so it runs only with the full suite.

package notes

import (
	"reflect"
	"testing"
)

func TestAddNote(t *testing.T) {
	t.Run("refuses a blank note [AC:3f2a91c0:ac_3]", func(t *testing.T) {
		if _, err := AddNote(nil, "   "); err == nil {
			t.Fatal("expected an error for a blank note")
		}
	})

	t.Run("never stores surrounding whitespace [INV:notes-trimmed]", func(t *testing.T) {
		got, _ := AddNote(nil, "  buy milk ")
		if !reflect.DeepEqual(got, []string{"buy milk"}) {
			t.Fatalf("got %q", got)
		}
	})

	t.Run("keeps earlier notes", func(t *testing.T) {
		got, _ := AddNote([]string{"a"}, "b")
		if !reflect.DeepEqual(got, []string{"a", "b"}) {
			t.Fatalf("got %q", got)
		}
	})
}
The rest of the example repo (notes/notes.go, go.mod)

notes/notes.go

package notes

import (
	"errors"
	"strings"
)

// AddNote returns notes with text appended, trimmed. A blank note is an error.
func AddNote(notes []string, text string) ([]string, error) {
	note := strings.TrimSpace(text)
	if note == "" {
		return nil, errors.New("a note can't be blank")
	}
	return append(append([]string{}, notes...), note), nil
}

go.mod

module example.com/notes

go 1.23

Markers

A Go test function's name can't contain brackets, so put the marker in a subtest name: t.Run("refuses a blank note [AC:3f2a91c0:ac_3]", …). Go reports it as TestAddNote/refuses_a_blank_note_[AC:3f2a91c0:ac_3], marker intact. -run '/({grep})' selects subtests by the regular expression Planwright puts in {grep}.

Run one test

What an agent runs when its selection is one criterion, then one invariant. Planwright fills in the markers; you never type these yourself.

[AC:3f2a91c0:ac_3]

status=0; gotestsum --junitfile reports/junit.xml -- -run '/(\[AC:3f2a91c0:ac_3\])' -coverprofile=coverage.out ./... || status=1; mkdir -p coverage && go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info && sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info; exit $status

[INV:notes-trimmed]

status=0; gotestsum --junitfile reports/junit.xml -- -run '/(\[INV:notes-trimmed\])' -coverprofile=coverage.out ./... || status=1; mkdir -p coverage && go run github.com/jandelgado/gcov2lcov@v1.1.1 -infile=coverage.out -outfile=coverage/absolute.info && sed "s|^SF:$PWD/|SF:|" coverage/absolute.info > coverage/lcov.info; exit $status

Coverage

The run command writes a Go cover profile, converts it with gcov2lcov and makes its file paths relative to the repository root (gcov2lcov writes absolute ones), so each verification run writes coverage/lcov.info for the selected tests. The coverage command measures the test file's package, for the test-impact index, and files runs the packages of the test files that index selects for a change.

CI workflow

Commit this as .github/workflows/planwright-tests.yml, with your default branch in place of main. It runs the full suite on every push and pull request and uploads the test reports as an artifact, even when tests fail. Planwright reads that artifact with the GitHub App's Actions: read permission and credits each result to the criterion or invariant its marker names. Skip it if your CI already runs the suite and uploads these reports. A project with no CI configuration is offered this same file on its Verification page.

# Generated by Planwright from .planwright/verify.yml. Review it, then commit it yourself;
# Planwright never commits to your repository. Planwright reads the uploaded test reports
# as independent evidence for the objectives whose commits this runs on.
name: Tests
on:
  push:
    branches: ["main"]
  pull_request:
    branches: ["main"]
permissions:
  contents: read
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-go@v5
        with:
          go-version-file: go.mod
      - name: Set up
        run: |
          go install gotest.tools/gotestsum@v1.12.0
      - name: Run the full test suite
        run: |
          gotestsum --junitfile reports/junit.xml -- ./...
      - name: Upload test reports
        if: always()
        uses: actions/upload-artifact@v4
        with:
          name: test-reports
          path: |
            reports/junit.xml
            reports/files.xml
          if-no-files-found: warn

Worth knowing

  • Go matches each level of a test's name separately, so -run '/…' also runs every top-level test that has no subtests. Planwright only counts the marked ones; the others cost time. Keep marked checks in subtests, and in a large module narrow ./... to the packages you change.
  • The run command keeps converting coverage when tests fail, and exits non-zero if they did.