With --changed-since <ref>, a test filter leaves out the tests the change
since that ref can’t affect. The decision filter asks a
decision model, such as Jev, about each test. Without
the flag, as on main and locally, every test runs.
Add the decision filter
In CI, pass --changed-since on pull requests, and give the e2e step the
AI Gateway key:
The flag goes only on pull_request runs, so a push, merge queue, or
scheduled run passes none and runs every test. Never pass it on
pull_request_target: that run holds your secrets, and a fork’s diff
could talk the model into leaving tests out. fetch-depth: 0 puts the
base branch and the merge base in the checkout. Without them, the run fails
with INVALID_CHANGED_SINCE.
The change is every file that differs from the merge base of the ref and
HEAD, uncommitted and untracked files included. The model reads the diff
of what the commits change, the test files, and the setup files that save
their sessions, with secrets redacted, except a value a secret provider
returns. It gets a file only the working tree changes, uncommitted or
untracked, by name alone, never its contents, so a file a CI step writes
doesn’t reach it.
The filter leaves out a test when the model rates both the test and its
setup under 20% likely affected. Tests the model can’t judge run, and the
filter’s note says why: a diff over 60 kB, a test or setup file too large
to send with the diff, or some calls failing. When every call fails, as
without a key on a pull request from a fork, the filter fails: the run
warns and runs every test. When git can’t measure the change, because it
is missing, the project is not in a repository, or the ref shares no
history with HEAD, the run fails with INVALID_CHANGED_SINCE. The
filter needs ai 7.0.134 or later.
Any decision model that answers boolean questions
works, such as Jev through TypeSafe directly:
typeSafeAi.decisionModel('jev-latest') from @ai-sdk/typesafe-ai, with
TYPESAFE_AI_API_KEY set. providerOptions goes with every call, such as
{ gateway: { zeroDataRetention: true } }.
Preview locally
e2e list prints each test a filter leaves out, with the reason. It starts
no app, and makes the same model calls as a run. Commit first: the decision
filter reads only what the commits change.
What always runs
- Every test, when the run has no
--changed-since, or when no file changed since the merge base. - A
--last-failedrerun, soe2e run || e2e run --last-failedis safe, and every--watchrerun after the first run.
The decision filter also runs:
- The tests in a test file that changed or whose stored screenshots changed, and the tests that use the sessions of such a setup file.
- Every test, when the config the run loaded changes, whatever its name,
or when a file named
e2e.config.ts(.mts,.js,.mjs), Gradle’slibs.versions.toml, or a lockfile changes in the project’s directory, inside it, or in a directory above it, such as the workspace root. Lockfiles are known by name, such aspnpm-lock.yaml,Podfile.lock,gradle.lockfile,Gemfile.lock,go.sum, orCargo.lock. The model judges any other file, and those of a sibling project.
A filter that throws, returns something other than { exclude, note? },
or runs past 5 minutes leaves nothing out, and a warning says so. The next
filter still judges every test it was given.
On another CI service, pass --changed-since origin/main on pull request
builds only, and fetch enough history for the merge base. On GitLab, pass
--changed-since ${CI_MERGE_REQUEST_TARGET_BRANCH_SHA:-$CI_MERGE_REQUEST_DIFF_BASE_SHA}
in merge request pipelines, not merge trains, with GIT_DEPTH: 0. With --shard, the filters on each machine judge
only its slice.
What you see
- Terminal: one line per filter, such as
decision kept 7 of 77 tests, left out 70 (1 call, 17.6k tokens, $0.0007). The decision filter’s note ends with its spend: model calls, tokens, and the cost when the provider reports it, as the AI Gateway does. ALeft outrow follows in the summary. When the filters leave nothing to run, the run passes and starts no app. - Pull request comment and
summary.md: the headline7 passed, 70 left out by filters, and a folded table of the tests with their reasons. junit.xmlandreport.json: each left-out test is a skipped case, withskip.filterin the report, besiderun.filtersandrun.changes. See Reporters.
Adjust the decision filter
Wrap the decision filter to change what it judges. This one runs every
test when infra/ changes, leaves apps/mobile/, which these tests don’t
reach, out of the change, and always runs the smoke tests:
Put tuned in filters in place of decisionFilter(...). To run every
test on one pull request, leave out --changed-since when it has the
all-tests label. Add labeled to the events so adding the label starts a
run:
Write your own
A filter is { name, filter }: filter gets the tests and the change, and
returns the tests to leave out with a reason for each. This one maps
changed paths to test tags:
Put it before the decision filter, so the model judges only the tests
areas kept:
- Every test you don’t name runs, and so does a test you name in a serial group another of its tests runs in.
- Each filter judges only the tests the one before it kept, and is not called when none are left.
- Pass
signalto every call, andredactwhat you send to a model. It replaces each fixed-value secret and credential password, and any run of 8 or more of its characters, so text that shares 8 characters with one, such asPasswordwhen a password isPassword123!, is masked too. A value a secret provider returns passes through. - Do the work inside
filter: workers load the config too, and only the runner callsfilter.
See TestFilter for the types.