PRACTICAL GUIDE / Playwright grepInvert regex

Stop grepInvert from excluding the wrong Playwright tests

Escape dynamic grepInvert values, inspect Playwright's full test-title match, and add CI checks that expose filters which remove too much or nothing.

By The Testing AcademyUpdated August 4, 202621 min read
All field guides
In this guide6 sections
  1. Understand what the regex actually sees
  2. Escape dynamic text before building the filter
  3. Test the filter like production code
  4. Diagnose an empty or suspiciously small run
  5. Roll the change into CI without hiding coverage
  6. Know when grepInvert is the wrong tool

What you will learn

  • Understand what the regex actually sees
  • Escape dynamic text before building the filter
  • Test the filter like production code
  • Diagnose an empty or suspiciously small run

Your pull request changes one quarantine label, and the next CI run executes no checkout tests. Nothing failed because the filter removed them before a browser opened. The tag contained parentheses, and the configuration treated those characters as regular-expression syntax instead of literal text.

This class of defect is dangerous because the report can stay green. A bad assertion produces a failure; an over-broad filter produces less evidence. The fix is not merely adding backslashes until one command works. Treat every dynamic filter as code: define whether its input is literal or regex syntax, test the compiled pattern, and review the selected test list before enforcing it.

Understand what the regex actually sees

Playwright does not apply grepInvert only to the string passed to test(). Its documented match string combines the project name, test file name, test.describe names, test name, and tags, separated by spaces. A pattern can therefore match a component nobody was looking at.

Suppose the intended exclusion is the tag @legacy. The same text might appear in legacy-checkout.spec.ts, a describe block called legacy migration, or a project named legacy-api. A loose /legacy/ expression removes every one of those. The configuration is behaving as written, but the human description "skip the tagged tests" is false.

The inverse semantics also matter. grepInvert keeps tests whose combined title does not match the pattern. When the option is an array, a match against any pattern is enough to exclude the test. An array is therefore an OR-shaped exclusion list. It is not a requirement that every expression match.

That distinction breaks a common attempt to express "exclude tests that are both slow and quarantined":

TypeScript
import { defineConfig } from '@playwright/test';

// This excludes every @slow test and every @quarantine test.
export default defineConfig({
  grepInvert: [/@slow/, /@quarantine/],
});

If the release policy truly requires both tags, use one expression that represents both conditions, or avoid regex composition and generate an explicit test list. Lookaheads can express the conjunction, but they make the policy harder to review. The complexity is a real cost, not a cleverness bonus.

Test tags begin with @ when declared through Playwright's tag metadata. Tagging is preferable to hiding operational control words inside prose titles because reporters expose tags as tags and reviewers can see intent. Filtering still uses the composite string, so exact token boundaries remain useful. A tag matcher should not turn @manual into a match for @manual-review unless that family behavior is deliberate.

Spaces separate the components in Playwright's documented composite. They also occur inside describe and test titles. A boundary of start-or-whitespace and whitespace-or-end works well for an exact tag token because tags themselves should not contain spaces. It is not a general exact-component parser. If you are matching a multiword test title, the same phrase can occur within a longer title and still satisfy whitespace boundaries.

Metacharacters change what text means. Parentheses create groups. Square brackets create character classes. A dot matches almost any single character. +, *, and ? quantify the previous atom. | creates alternatives. Anchors and backslashes add more behavior. When a release label, ticket name, project slug, or environment variable supplies those characters as data, passing the raw value to new RegExp() changes the selection policy.

For example, the literal tag @quarantine(payments) does not match itself when compiled as new RegExp('@quarantine(payments)'); the parentheses group the word and are not consumed as literal characters. A tag called @issue[42] compiles the bracketed portion as a character class and can match @issue4 or @issue2. A value @owner:qa.platform lets the dot stand for another character. These are deterministic regex rules, not Playwright flakiness.

There are two escaping layers when the pattern comes from a JavaScript string. JavaScript parses the string first, then the RegExp constructor parses its result. new RegExp('\\(') and /\(/ both represent a literal opening parenthesis, but the constructor form needs the extra string-literal backslash. A shell command adds another parser. Quoting that happens to work in Bash may not carry the same characters through PowerShell or a YAML interpolation.

Anchors are easy to aim at the wrong boundary. The start anchor ^ refers to the start of Playwright's entire composite string, where the project name appears in the documented example. It does not mean "start of the test title." A pattern such as /^checkout/ may match a project called checkout-chromium and fail to match a test named checkout accepts Visa because the file and suite components come first. The end anchor has the corresponding whole-string meaning. Inspect one fully qualified identity before using anchors to describe a smaller component.

Word boundaries are not exact tag boundaries either. JavaScript defines \b around transitions between word and nonword characters. The @ prefix is a nonword character, as are parentheses, hyphens, and much of the punctuation that motivated escaping. A pattern wrapped in \b can begin after the @, stop before a suffix, or behave differently around non-ASCII letters under case folding. Playwright already gives a simpler structural separator for its composite fields and tags: spaces. Start-or-whitespace and whitespace-or-end communicate the token rule directly.

Case sensitivity also belongs to the policy, and the two ways of supplying a filter do not agree about it. A RegExp you construct yourself is case-sensitive unless you add i. A bare string passed to --grep or --grep-invert on the command line is not: Playwright compiles it through a helper in playwright/lib/util.js that ends with return new RegExp(pattern, "gi"). Both flags are added for you. Compile @Quarantine that way and it matches the composite string chromium checkout.spec.ts buys @quarantine, while the same source compiled with only the u flag does not.

That asymmetry is easy to demonstrate and easy to miss, and it undercuts the canonical-case discipline this article otherwise recommends. A case-insensitive quarantine filter can conceal a mistyped tag that another tool treats as distinct, so the two paths shown below are not interchangeable even when their pattern text is identical. Normalize tags when authors declare them, not only when the release filter reads them, and if the CLI's folding is unwanted, pass a RegExp from configuration instead of a string from the shell.

An invalid raw pattern fails differently from an over-broad valid pattern. new RegExp('@team[') throws while the configuration module is evaluated, so collection never begins. That is noisy and usually easy to diagnose. new RegExp('@team[qa]') compiles and selects the wrong set, which is quieter and more dangerous. Validation must cover both syntax failure and semantic near misses. Catching the constructor exception alone does not prove literal behavior.

The safest first decision is contractual: is the input trusted regular-expression source, or literal text? Do not infer from punctuation. Give advanced regex input a separate, clearly named option and validate it. Treat values from environment variables, issue trackers, branch names, and UI fields as literal by default.

Escape dynamic text before building the filter

Modern JavaScript defines RegExp.escape() for turning a string into source that can be embedded literally in a pattern. MDN notes that it covers more than the familiar punctuation replacement, including leading alphanumeric characters, spaces, other punctuators, line breaks, and lone surrogates. That breadth matters when values are composed into a larger expression.

Availability depends on the Node runtime evaluating playwright.config.ts. Browser support on a developer's laptop does not make the method available in Node. Feature detection avoids a configuration crash. The fallback below encodes each Unicode code point with \u{...} and compiles the final expression with the Unicode flag. Since every input character becomes an escape, regex punctuation never enters as syntax.

TypeScript
// tests/support/literal-pattern.ts
type RegExpWithEscape = RegExpConstructor & {
  escape?: (value: string) => string;
};

export function escapeRegexLiteral(value: string): string {
  const nativeEscape = (RegExp as RegExpWithEscape).escape;
  if (nativeEscape) {
    return nativeEscape(value);
  }

  return Array.from(value, character => {
    const codePoint = character.codePointAt(0);
    if (codePoint === undefined) {
      throw new Error('Unable to read a Unicode code point');
    }
    return `\\u{${codePoint.toString(16)}}`;
  }).join('');
}

export function exactTagPattern(tag: string): RegExp {
  if (!tag.startsWith('@') || /\s/u.test(tag)) {
    throw new Error(`Expected one Playwright tag, received ${JSON.stringify(tag)}`);
  }

  const literal = escapeRegexLiteral(tag);
  return new RegExp(`(?:^|\\s)${literal}(?=\\s|$)`, 'u');
}

The validation is part of the fix. Without it, a value containing two tags separated by a space could look like one exact token while the boundaries match a phrase across components. Rejecting it forces the caller to provide a list and lets the configuration compile one pattern per tag.

This fallback is intentionally paired with the u flag because \u{...} code-point escapes require Unicode-aware regex parsing. Omitting the flag turns a correct-looking source string into different syntax. Keep pattern construction in one helper so no caller can forget that coupling.

Wire literal environment input into configuration only after validation. The following setup always excludes @manual. It optionally adds one exact quarantine tag supplied by CI. The variable name says "tag," not "regex," which tells the caller that punctuation has no special power.

TypeScript
// playwright.config.ts
import { defineConfig } from '@playwright/test';
import { exactTagPattern } from './tests/support/literal-pattern';

const excludedTags = ['@manual'];
if (process.env.PW_EXCLUDE_TAG) {
  excludedTags.push(process.env.PW_EXCLUDE_TAG);
}

export default defineConfig({
  testDir: './tests',
  grepInvert: excludedTags.map(exactTagPattern),
  reporter: [['list'], ['html', { open: 'never' }]],
});

This configuration still has a policy trade-off. A CI variable can remove tests from the run, which gives whoever controls the variable a coverage control. Restrict its allowed values if the pipeline should only select from reviewed quarantine groups. A literal escape prevents regex injection; it does not decide whether excluding a legitimate tag is authorized.

Static filters should stay static. Write /@manual/ directly when the syntax is deliberately a regex and exact-family matching is acceptable. Running a constant through a dynamic helper adds indirection without reducing risk. The helper earns its place when data crosses a trust or parsing boundary.

The command-line form is useful for local investigation. In Bash, a literal pair of parentheses needs backslashes in the regex, and single quotes preserve those backslashes for the CLI:

Shell
npx playwright test --grep-invert '(^|\s)@quarantine\(payments\)(?=\s|$)' --list

This command is a near equivalent of exactTagPattern('@quarantine(payments)'), not an exact one, and the difference is the flags. The helper compiles with u alone, so it is case-sensitive. Playwright compiles this string with gi, so it also excludes @QUARANTINE(Payments) and every other casing. For a correctly spelled tag both paths select the same tests, which is why the gap goes unnoticed. For a mistyped or inconsistently cased tag they diverge, and the CLI is the one that quietly forgives. Use the command line to investigate; keep the enforced release filter in configuration where the flags are visible in source.

Do not paste that command into every platform's CI script and assume identical parsing. Keeping the dynamic value in an environment variable and constructing the expression in TypeScript removes one shell-escaping layer. It also gives the team one validation path to test, and it removes the flag discrepancy along with it.

Test the filter like production code

A regex helper can be wrong while every application test remains green, because the wrong tests never run. Give the helper direct cases that prove literal matching and non-matching near misses. Each negative example should differ by one feature that caused a previous overreach: grouping characters, a character class, a longer tag with the same prefix, Unicode, or a value appearing inside a filename.

TypeScript
// tests/support/literal-pattern.spec.ts
import { test, expect } from '@playwright/test';
import { exactTagPattern } from './literal-pattern';

test('matches punctuation in a tag literally', () => {
  const pattern = exactTagPattern('@quarantine(payments)[P1]+');

  expect(pattern.test(
    'chromium checkout.spec.ts purchase retries @quarantine(payments)[P1]+',
  )).toBe(true);
  expect(pattern.test(
    'chromium checkout.spec.ts purchase retries @quarantinepaymentsP1',
  )).toBe(false);
  expect(pattern.test(
    'chromium checkout.spec.ts purchase retries @quarantine(payments)[P1]+-old',
  )).toBe(false);
});

test('matches a Unicode tag as one exact token', () => {
  const pattern = exactTagPattern('@team-品質');

  expect(pattern.test('webkit quality.spec.ts saves draft @team-品質'))
    .toBe(true);
  expect(pattern.test('webkit quality.spec.ts saves draft @team-品質-old'))
    .toBe(false);
});

test('rejects a value containing several tags', () => {
  expect(() => exactTagPattern('@slow @payments')).toThrow(
    'Expected one Playwright tag',
  );
});

These assertions can fail when the helper changes. They are not checking that a value exists in an array written directly above it. The punctuation case proves that regex syntax remains literal. The longer-suffix case proves the token boundary. The Unicode case exercises the fallback's code-point design even when the native escape path is unavailable in another runtime.

Also test the policy that consumes the helper. Create a tiny collection-only fixture with three tests: one tagged with the exact dynamic value, one with a similar tag, and one untagged release-critical test. Run --list through the real configuration and verify only the intended case disappears. This catches configuration mistakes that helper unit tests cannot, such as reading the wrong environment variable or replacing the filter later in a project block.

Project-level configuration deserves explicit coverage. A project can define its own grepInvert, and the full match string includes the project name. A pattern intended to exclude tests tagged @webkit can also match a project called webkit if it lacks exact tag boundaries. Listing every project makes that obvious. Never assume a filter observed under Chromium selects the same identities under all projects.

Consider a second worked failure built from a release branch name. CI sets PW_EXCLUDE_TAG to @release/2.4+hotfix, intending to remove tests explicitly tagged for an obsolete path. Raw regex compilation treats . as a wildcard and + as a quantifier. Depending on the surrounding text, it can match a similar tag without the literal plus or fail to match the real tag at all. The slash is harmless to the RegExp constructor but would need consideration inside a regex literal, which is another reason not to build source-code literals from runtime data.

Run the exact-token helper over that value, then list a fixture set containing @release/2.4+hotfix, @release/2x44hotfix, and @release/2.4+hotfix-old. Only the first should disappear. Next, rename a project to release/2.4+hotfix in a small configuration test. The exact tag pattern should leave that project's untagged test selected because the project component lacks the leading @. This example proves escaping, suffix boundaries, and component intent through genuinely competing identities.

File paths create another near miss. A team might use grepInvert: /payments/ to disable a flaky payment tag, then move unrelated account tests into tests/payments-history/. Those tests disappear without any tag edit. Exact @payments matching avoids the collision. When a broad word is intentional, document that it applies to projects, paths, suites, titles, and tags. Reviewers can then judge the real blast radius instead of reading it as shorthand for tag filtering.

A second failure mode appears when teams want AND semantics. Suppose @slow cases remain in the main run unless they also carry @quarantine. The array [exactTagPattern('@slow'), exactTagPattern('@quarantine')] is wrong because either match excludes. One combined expression can require both:

TypeScript
import { defineConfig } from '@playwright/test';
import { escapeRegexLiteral } from './tests/support/literal-pattern';

function hasBothTags(first: string, second: string): RegExp {
  const a = escapeRegexLiteral(first);
  const b = escapeRegexLiteral(second);
  const token = (source: string) => `(?:^|\\s)${source}(?=\\s|$)`;
  return new RegExp(`(?=.*${token(a)})(?=.*${token(b)})`, 'u');
}

export default defineConfig({
  grepInvert: hasBothTags('@slow', '@quarantine'),
});

That expression is harder to read and test. If the policy grows beyond two conditions, an explicit test-list file or separate project can be safer. Regex is good at text matching; it is a poor policy language once exclusions depend on owners, dates, browser projects, and issue status.

Diagnose an empty or suspiciously small run

Start with collection, not browser traces. A filtered-out test has no page actions and no trace because it never executes. npx playwright test --list applies collection and prints the selected tests without running them. Capture a baseline without the proposed dynamic exclusion and the filtered list using the same configuration, projects, and file arguments.

The fastest manual check is to run both commands and search the fully qualified identities. The project, file, suite, and title in the list explain matches that a short reporter label can hide.

Shell
set -eu

baseline="$(mktemp)"
filtered="$(mktemp)"
trap 'rm -f "$baseline" "$filtered"' EXIT

PW_EXCLUDE_TAG='' npx playwright test --list > "$baseline"
PW_EXCLUDE_TAG='@quarantine(payments)' npx playwright test --list > "$filtered"

if cmp -s "$baseline" "$filtered"; then
  echo 'grepInvert matched no collected test' >&2
  exit 1
fi

grep -F 'checkout.spec.ts' "$filtered"
grep -F 'completes card payment' "$filtered"

This diagnostic has two live branches. It fails if the proposed filter changes nothing, which catches a missing literal-parenthesis match. It also fails if the named checkout file or release-critical test disappears, which catches an over-broad result. Replace the example names with stable critical identities in your suite. Do not use a tautological fixture generated from the filtered output itself.

If the entire run is empty, print the compiled pattern source and the input value as JSON. JSON quoting makes spaces and escape characters visible. Do not print secrets, although a test-selection tag should never contain one. Then take one expected full identity from the baseline and evaluate the pattern against it in a Node test or temporary debugging expression. This establishes whether the regex matches before investigating Playwright configuration precedence.

If too many tests disappear, search every component of one unexpected identity. A word may be in the project name or file path. An unescaped dot may have matched a different separator. A raw pair of brackets may have collapsed several intended characters into one character class. A missing end boundary may have matched a tag prefix. Each shape points to a pattern defect, not a worker or retry issue.

If the configuration works locally but not in CI, retain the Node version, serialized tag value, pattern source, project names, and both list outputs. Shell quoting and environment interpolation are primary suspects. YAML can also coerce unquoted values in surprising ways before Node sees them. Put the value in a quoted environment string and let the TypeScript helper own regex construction.

Do not use the HTML report to estimate excluded coverage. It describes the tests the runner selected and executed; it cannot explain cases absent from collection as well as a baseline comparison can. Keep a small selection artifact when dynamic exclusions affect release scope.

Roll the change into CI without hiding coverage

Introduce dynamic exclusions as an observed mode before making them the only release lane. Run the ordinary required suite and a non-blocking filtered --list job first. Review exactly which tests the variable removes. Once the selection is stable, the filtered execution can serve its intended purpose, but a separate quarantine lane should still run excluded tests on a schedule or as a non-blocking job.

The pipeline below makes the selected tag visible, validates selection, runs the main lane, and then runs the quarantined tag separately with --grep. It does not claim quarantined failures are passing. They remain visible under their own job result.

YAML
jobs:
  release-tests:
    runs-on: ubuntu-latest
    env:
      PW_EXCLUDE_TAG: "@quarantine(payments)"
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npm run check:test-selection
      - run: npx playwright test

  quarantined-payments:
    runs-on: ubuntu-latest
    continue-on-error: true
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npx playwright install --with-deps
      - run: npx playwright test --grep '(^|\s)@quarantine\(payments\)(?=\s|$)'

npm run check:test-selection should implement repository-specific versions of the baseline and critical-case checks, not blindly copy placeholder names. The extra collection pass costs CI time, especially in repositories with expensive test discovery. That latency is the trade-off for proving that a dynamic coverage control did what reviewers intended.

Note that the quarantine job's --grep string inherits the same gi compilation. That is acceptable here because the job is deliberately inclusive: a lane whose purpose is to run everything under a quarantine label loses little by also catching a differently cased variant of it. The required lane is where the folding would hurt, and the required lane takes its filter from playwright.config.ts rather than from an argument. Keep that split, and do not mirror the CLI string into configuration as a shortcut.

Pinning the Node major reduces runtime drift, but it does not remove the need for feature detection. Developers, container images, and later CI upgrades may evaluate the same config under different Node releases. The helper produces the same literal semantics on both native and fallback paths; its tests should run wherever configuration is validated.

Quarantine tags need ownership and expiry outside the regex. An exact pattern can still exclude a test forever. Store an issue reference, owner, and review date in the team's quarantine process, then keep the runtime tag short and literal. Do not encode dates and ticket grammar into one elaborate expression. That makes selection harder to audit while solving none of the governance problem.

Suites that currently put markers in title prose need a staged migration. First, list the existing title pattern and save the selected identities. Add structured Playwright tags to the same tests without changing the old filter, then compare a candidate exact-tag list with the saved identities. Differences require review because duplicated words in filenames or describes may reveal that the old filter was excluding more than the team believed.

Next, run the tag-based filter in a non-blocking lane for several changes. Require new quarantines to use tag metadata and stop adding magic words to titles. Once the lists agree on the intended set, switch the required lane to the exact tag and remove the old title filter. Finally, rename prose titles for readability only after selection no longer depends on them. This order prevents a wording cleanup from changing release coverage.

The migration costs an extra collection pass and temporary dual metadata. It also forces the team to confront accidental exclusions that may have existed for months. Do not preserve those accidents merely to keep counts stable. Name the tests, decide whether they belong in the release lane, and record that decision in a tag or explicit list.

For highly regulated or safety-critical subsets, a checked-in test-list file may be easier to audit than a negative regex. Playwright supports --test-list and --test-list-invert options with fully qualified identities. The file requires maintenance when tests move or titles change, but a code review shows exactly which cases enter or leave the run. That explicit churn can be preferable to a compact pattern whose match set changes when an unrelated filename is edited.

Track selected counts as context, not invented quality targets. A sudden change from the repository's own prior baseline can trigger review, but there is no universal acceptable percentage of excluded tests. Publish the actual list alongside the count. Five excluded end-to-end purchase cases may carry more release risk than fifty excluded visual variants.

Know when grepInvert is the wrong tool

Do not use grepInvert to hide a permanently unsupported browser combination. A named Playwright project with explicit test annotations or project-specific test matching communicates that contract better. A global text filter can remove the case from other projects when the same word appears in their identities.

Do not pass untrusted regex source from a pull-request label or environment variable. Escaping makes literal text safe to match, while accepting raw syntax allows the caller to rewrite collection. If advanced operators are genuinely required, restrict them to reviewed repository configuration and test the resulting list.

Do not use an array when the business condition is "all of these tags." Array entries are independent exclusion reasons. Use a tested conjunction or move the policy into an explicit list generator. Readability usually wins once more than two conditions interact.

Do not replace test.skip() with a global inverse filter when the reason belongs beside one test. A local skip can carry a condition and description in source and appears as an intentional test outcome. A collection filter is better for run-wide operational slices, such as excluding one reviewed quarantine group from a release lane.

Do not rely on a green exit code as evidence that the filter is correct. A run with zero relevant tests can be green. The evidence is the selected identity list, the intended removed set, and the continued presence of critical cases.

Finally, do not escape text that is intentionally regex syntax and then wonder why alternatives stop working. Literal and regex inputs are different APIs even if both become a RegExp eventually. Name them differently, validate them differently, and keep literal data on the escaped path. That boundary prevents a punctuation change in a tag from quietly becoming a release-policy change.

// FIELD DISPATCH

Get the QA Field Notes

Weekly QA battles, AI testing guides, and interview drills. Free on Substack.

// LIVE COURSE / THE TESTING ACADEMY

Playwright Automation Mastery

Go beyond Selenium. Master Playwright with JS/TS in 90 days.

From the instructor behind this guide.

Playwright jobs are growing 8x faster than Selenium. 90 days / 75+ live hrs / Tue-Thu-Sat 7 AM IST.

Code PROMODE / 10% offJoin the batch

The Testing Academy editorial desk

Practical QA guidance built around test evidence, production tradeoffs, and interview-ready explanations.

Published July 25, 2026 / Reviewed August 4, 2026

PRIMARY REFERENCES

Verify the details at the source

QABattle guides are practical explanations. Product behavior, standards, and APIs can change, so use these primary references for the canonical details.

  1. 01
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  2. 02
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  3. 03
    Official playwright.dev reference

    playwright.dev

    Primary documentation selected and verified for the claims in this guide.

  4. 04
    Official developer.mozilla.org reference

    developer.mozilla.org

    Primary documentation selected and verified for the claims in this guide.

FAQ / QUICK ANSWERS

Questions testers ask

Why did grepInvert exclude a test that does not have my tag?

Playwright tests the expression against a combined string containing the project, file, describe titles, test title, and tags. The same text may have matched a filename or suite name, so inspect the fully qualified test identity with --list.

Do parentheses need escaping in a Playwright grepInvert value?

Parentheses are regular-expression grouping syntax unless escaped or safely encoded. If they came from a literal tag such as @quarantine(payments), convert the entire value to a literal pattern before building the RegExp.

Can I use RegExp.escape in playwright.config.ts?

Only when the Node runtime evaluating the configuration provides it. Feature-detect the method or use a reviewed fallback, and run a small unit test over punctuation, spaces, and Unicode before relying on dynamic filters.

Does an array of grepInvert patterns mean every pattern must match?

No. Playwright keeps tests whose full title does not match any pattern in the array, so each entry is another reason to exclude a test. Use one deliberately combined expression when the exclusion requires several conditions together.

How do I preview a grepInvert change without running the suite?

Run Playwright with --list under the proposed filter and compare it with an unfiltered list. Confirm both sides: intended tests disappear, while named release-critical tests remain selected.