Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
194 changes: 194 additions & 0 deletions .agents/skills/gate-tests/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
---
name: gate-tests
description: >
How to use the `@gate` / `@force-gate` test directives instead of `it.skip`
or fake-green skip patterns. Use when a test is known-failing under some
test-matrix dimension (dev mode, a bundler, an experimental flag like
cacheComponents), when converting `if (isNextDev) return` guards or
env-var `describe.skip` branches, when adding a condition to
test/lib/gate/conditions.ts, or when keying a fixture's experimental flag
on a __NEXT_TEST_AXIS letter. Covers directive choice, condition tiers,
the test-axis fixture pattern, pitfalls, and verification commands.
user-invocable: false
metadata:
internal: true
---

# Gating tests with `@gate` / `@force-gate`

Full reference: [`test/lib/gate/README.md`](../../../test/lib/gate/README.md).
This skill is the decision guide: which directive to reach for, the standard
conversion patterns, and how to verify.

## Never write these — gate instead

| Anti-pattern | Replacement |
| ---------------------------------------------------------------------- | --------------------------------------------------------------------------- |
| `it.skip('...')` for a known failure | `// @gate <cond>` (or `@gate FIXME` if no condition explains it) |
| `if (isNextDev) { test('skipped in dev mode', () => {}); return }` | `// @force-gate prefetching` (or `!dev`) on the `describe` |
| `(flagEnabled ? describe.skip : describe)(...)` keyed on `process.env` | `// @force-gate <cond>` (lazy) on the `describe` |
| Duplicating a fixture directory per flag state | one fixture keyed on `__NEXT_TEST_AXIS` + a `@gate`/`@force-gate` |
| Branching expectations on `process.env.__NEXT_CACHE_COMPONENTS` | `if (await gate((c) => c.cacheComponents))` (`gate` from `next-test-utils`) |

The skip patterns are fake-greens: nothing tells you when the bug they hide is
fixed. `@gate` still runs the body and fails the suite the day the "known
failure" starts passing, so stale workarounds get deleted instead of rotting.

## Choosing the directive

Ask what kind of difference you're encoding:

1. **A behavior change — both states assert something meaningful.** Don't
gate the test at all: fork inside the body with the runtime `gate()` —
same condition registry, no inversion — which pinpoints exactly what
differs, and also covers `it.each`, where a pragma cannot attach:
`if (await gate((c) => c.cacheComponents)) { ... } else { ... }`. It
mirrors React's `gate(flags => ...)`; a pragma expression string works
too (`await gate('cacheComponents && !dev')`). A suite-level pragma is
too coarse here — it hides _what_ is different between the states.
2. **A flag that changes the behavior of existing surface**
(`cacheComponents`, `optimisticRouting`) **and the suite is written for one
state.** `// @gate <cond>` on the test or `describe`. The body runs; a
false condition inverts the expectation (failure absorbed, a pass fails as
"stale gate"). The off state fails for a meaningful reason — the behavior
differs — so a pass is real information: the gate is stale, delete it.
3. **A new API — the off state proves nothing.** Typically
`// @force-gate <cond>` (lazy) on the `describe`. An API that throws when
its flag is off — or is inert, like `useOffline()`, which compiles to a
hook that always reports online — can only fail vacuously (often slowly,
by timing out), and browser e2e time is considerable, so skip the run
(and the fixture build) instead of paying for it. Working example:
`test/e2e/app-dir/use-offline/`. This is discretion, not a rule: when the
flag changes behavior the suite can observe, the off state is meaningful
and `@gate` buys the staleness check.
4. **`@force-gate <cond>` also when running the body is impossible**, not
merely failing: prefetching is off in dev, deploy has no local build
output, the fixture cannot even build under the condition.
- Static condition (`!dev`, `bundler`…) → real Jest `○ skipped` at
collection.
- Lazy condition on a `describe` → the fixture **build is skipped** when
false; tests report passed-with-`⚠ skipped by @force-gate` (Jest cannot
skip at runtime). Build-skipping covers `start`/`dev` suites where
`nextTestSetup` owns the build — not `skipStart` suites, not deploy.
5. Pragmas stack: a common pair is a static `// @force-gate prefetching` plus
a lazy `// @gate <flag>` on the same `describe`.

### Is the off-state run worth its cost?

Browser e2e time is not free, so weigh what the gated-off run buys. For a
behavior flag it usually replaces a run that was already being paid for — a
fixture that pins its flags runs identically with and without the axis set,
so keying the flag on an axis converts a redundant duplicate into coverage —
and it is what proves a pass isn't vacuous: a test that passes with the
feature off wasn't testing the feature. Absorbed failures also fail fast, so
the off-state run is cheaper than it sounds. For a new API the calculus
flips: the off state can only throw, which proves nothing, so use a lazy
`// @force-gate <flag>` on the `describe` — the fixture build is skipped
too, so the off state costs almost nothing.

## Conditions

Every name in a pragma must be declared in `test/lib/gate/conditions.ts`
(typos fail the suite at collection). Two tiers:

- **static** — the run's shape: `dev`, `start`, `deploy`, `mode`, `turbopack`,
`rspack`, `webpack`, `bundler`, `react18`, `wasm`, `ci`, plus the
always-false `FIXME`/`TODO`. `prod` and `prefetching` are semantic aliases
for `!dev` — prefer the name that states _why_ the suite cannot run.
- **lazy** — a predicate over the fixture's _resolved_ `next.config`
(`cacheComponents`, `ppr`, `useOffline`, `output`, …).

Adding one is a two-line change; follow the guidance at the top of
`conditions.ts`. The rule that matters: **lazy conditions read the resolved
config, never `process.env`** — env vars don't survive config resolution
(`__NEXT_CACHE_COMPONENTS` only applies when the fixture doesn't set
`cacheComponents` itself, and resolution implies flags the fixture never
mentions).

## Pattern: cover both states of an experimental flag

Instead of pinning a flag on (which makes the plain and axis runs identical),
key it on a test axis and gate the suite. Axes are lettered (`A`, `B`, …) —
a fixed enumeration, not a boolean and not a sharding bucket. Key the flag so
it is **enabled by default** — then the suite exercises the feature in plain
local runs with no special env, and the axis run covers the off state:

```js
// next.config.js — pin every dimension except the one under test
const nextConfig = {
cacheComponents: true,
experimental: {
concurrentRouterQueue: process.env.__NEXT_TEST_AXIS !== 'A',
},
}
```

```ts
// @gate concurrentRouterQueue
it('fails loudly on link navigation', async () => { ... })
```

The plain run exercises the feature; the axis-A run covers the off state —
the gated tests are expected to fail there, and the suite fails the day they
start passing. Working example: `test/e2e/app-dir/concurrent-router-queue/`
(tests whose expectations hold in both states stay ungated). The same keying
pairs with a lazy `@force-gate` when the off state proves nothing —
`test/e2e/app-dir/use-offline/` — which skips the redundant axis run (build
included) instead of covering it. Axis `A` aliases `__NEXT_CACHE_COMPONENTS`
for now (see `scripts/run-jest.sh`) — fine, because these fixtures pin
`cacheComponents` explicitly, so that run's env default is a no-op for them.

**Keep exactly one flag varying per fixture.** A red shard must attribute to a
single dimension.

## Pitfalls

- A pragma the transform can't attach is a **hard error**: a blank line
between pragma and `it(`, `it.each`/`it.failing`, or a pragma inside a
JSDoc block. Prose comments must not begin with `@gate`. A pragma on a
skipped test (`it.skip`, `xit`, …) errors as ambiguous — remove the skip or
the pragma. A skip without a pragma is respected.
- A `describe`-level gate does not reach `it.each` tests.
- Gated-false bodies that fail by _stalling_ waste the full Jest timeout —
and under a lazy gate they fail the suite anyway (the runtime inversion
only absorbs thrown errors; a static gate rides Jest's native
`test.failing`, which does absorb timeouts). Bodies that fail via `retry()`
timeouts also make the off-state run slow; a fast first assertion is worth
having.
- Failures cascade in the off state: an absorbed failure mid-body skips the
body's cleanup (e.g. a browser context left offline), so later tests may
fail for cascade reasons. Acceptable for a tripwire, but don't puzzle over
the individual failure messages in a gated-off run.
- `afterEach` failures (e.g. redbox matchers) are not gated — only the body is.
- `jest.retryTimes(1)` on non-dev CI means a _flaky_ gated-false test passes
whenever it happens to fail; the tripwire is only deterministic for
deterministic tests.
- Gated titles are unchanged in the Jest output; the
`⚠ gated test failed as expected` log line is the only signal.
- `pragma-transform.js` bails out early on files containing neither `@gate`
nor `@force-gate` as substrings — keep both checks if you touch it.

## Verify a gated suite in every state it can run in

```sh
# plain run (flag on): expect normal passes, no warnings
NEXT_SKIP_ISOLATE=1 pnpm test-start-webpack test/e2e/app-dir/<suite>/<suite>.test.ts

# axis run (flag off): expect `⚠ gated test failed as expected (@gate …)`
__NEXT_TEST_AXIS=A NEXT_SKIP_ISOLATE=1 pnpm test-start-webpack test/e2e/app-dir/<suite>/<suite>.test.ts

# dev (static @force-gate !dev): expect `○ skipped` at collection, no fixture boot
NEXT_SKIP_ISOLATE=1 pnpm test-dev-webpack test/e2e/app-dir/<suite>/<suite>.test.ts
```

A suite with a lazy `@force-gate` on the `describe` should additionally show
`skipping build` behavior (no `next build`) in the state where the condition
is false.

Unit tests for the infrastructure itself: `pnpm test-unit test/unit/gate/`.

## Related skills

- `$flags` — adding the experimental flag itself (config-shared, schema,
define-env)
- `$router-act` — the prefetch-timing patterns most gated suites also use
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ Use skills for conditional, deep workflows. Keep baseline iteration/build/test p
- `$react-sync` - build a local React checkout and sync it into Next.js for testing
- `$runtime-debug` - runtime-bundle/module-resolution regression reproduction and verification
- `$next-rspack` - @next/rspack-core and @next/rspack-binding maintenance (rspack/ directory)
- `$gate-tests` - `@gate`/`@force-gate` test directives: replacing `it.skip`/fake-green skips, conditions, variant-shard fixtures
- `$authoring-skills` - how to create and maintain skills in `.agents/skills/`

## Context-Efficient Workflows
Expand Down
2 changes: 1 addition & 1 deletion crates/next-custom-transforms/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ DEALINGS IN THE SOFTWARE.

#![recursion_limit = "2048"]
#![deny(clippy::all)]
#![feature(box_patterns)]
#![feature(deref_patterns)]
#![feature(arbitrary_self_types)]
#![feature(arbitrary_self_types_pointers)]

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ impl ImportMap {
}

Expr::Member(MemberExpr {
obj: box Expr::Ident(obj),
obj: Expr::Ident(obj),
prop: MemberProp::Ident(prop),
..
}) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ fn effect_has_side_effect_deps(call: &CallExpr) -> bool {
if let Expr::Array(arr) = &*call.args[1].expr {
for elem in arr.elems.iter().flatten() {
if let ExprOrSpread {
expr: box Expr::Call(_),
expr: Expr::Call(_),
..
} = elem
{
Expand Down Expand Up @@ -137,7 +137,7 @@ impl Fold for OptimizeServerReact {

fn fold_expr(&mut self, expr: Expr) -> Expr {
if let Expr::Call(call) = &expr {
if let Callee::Expr(box Expr::Ident(f)) = &call.callee {
if let Callee::Expr(Expr::Ident(f)) = &call.callee {
// Mark `useEffect` as DCE'able
if let Some(use_effect_ident) = &self.use_effect_ident
&& &f.to_id() == use_effect_ident
Expand All @@ -154,7 +154,7 @@ impl Fold for OptimizeServerReact {
return wrap_expr_with_env_prod_condition(call.clone());
}
} else if let Some(react_ident) = &self.react_ident
&& let Callee::Expr(box Expr::Member(member)) = &call.callee
&& let Callee::Expr(Expr::Member(member)) = &call.callee
&& let Expr::Ident(f) = &*member.obj
&& &f.to_id() == react_ident
&& let MemberProp::Ident(i) = &member.prop
Expand All @@ -179,8 +179,8 @@ impl Fold for OptimizeServerReact {

if let Pat::Array(array_pat) = &decl.name
&& array_pat.elems.len() == 2
&& let Some(box Expr::Call(call)) = &decl.init
&& let Callee::Expr(box Expr::Ident(f)) = &call.callee
&& let Some(Expr::Call(call)) = &decl.init
&& let Callee::Expr(Expr::Ident(f)) = &call.callee
&& let Some(use_state_ident) = &self.use_state_ident
&& &f.to_id() == use_state_ident
&& call.args.len() == 1
Expand Down
24 changes: 12 additions & 12 deletions crates/next-custom-transforms/src/transforms/server_actions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1567,15 +1567,15 @@ impl<C: Comments> VisitMut for ServerActions<C> {
let old_current_export_name = self.current_export_name.take();

match n {
PropOrSpread::Prop(box Prop::KeyValue(KeyValueProp {
PropOrSpread::Prop(Prop::KeyValue(KeyValueProp {
key: PropName::Ident(ident_name),
value: box Expr::Arrow(_) | box Expr::Fn(_),
value: Expr::Arrow(_) | Expr::Fn(_),
..
})) => {
self.current_export_name = None;
self.arrow_or_fn_expr_ident = Some(ident_name.clone().into());
}
PropOrSpread::Prop(box Prop::Method(MethodProp { key, .. })) => {
PropOrSpread::Prop(Prop::Method(MethodProp { key, .. })) => {
let key = key.clone();

if let PropName::Ident(ident_name) = &key {
Expand Down Expand Up @@ -1603,7 +1603,7 @@ impl<C: Comments> VisitMut for ServerActions<C> {

if !self.in_module_level
&& self.should_track_names
&& let PropOrSpread::Prop(box Prop::Shorthand(i)) = n
&& let PropOrSpread::Prop(Prop::Shorthand(i)) = n
{
self.names.push(Name::from(&*i));
self.should_track_names = false;
Expand Down Expand Up @@ -1686,7 +1686,7 @@ impl<C: Comments> VisitMut for ServerActions<C> {
}

fn visit_mut_call_expr(&mut self, n: &mut CallExpr) {
if let Callee::Expr(box Expr::Ident(Ident { sym, .. })) = &mut n.callee
if let Callee::Expr(Expr::Ident(Ident { sym, .. })) = &mut n.callee
&& (sym == "jsxDEV" || sym == "_jsxDEV")
{
// Do not visit the 6th arg in a generated jsxDEV call, which is a `this`
Expand Down Expand Up @@ -2837,7 +2837,7 @@ impl<C: Comments> VisitMut for ServerActions<C> {
(&attr.value, &attr.name)
{
match &container.expr {
JSXExpr::Expr(box Expr::Arrow(_)) | JSXExpr::Expr(box Expr::Fn(_)) => {
JSXExpr::Expr(Expr::Arrow(_)) | JSXExpr::Expr(Expr::Fn(_)) => {
self.arrow_or_fn_expr_ident = Some(ident_name.clone().into());
}
_ => {}
Expand All @@ -2852,7 +2852,7 @@ impl<C: Comments> VisitMut for ServerActions<C> {
let old_current_export_name = self.current_export_name.take();
let old_arrow_or_fn_expr_ident = self.arrow_or_fn_expr_ident.take();

if let (Pat::Ident(ident), Some(box Expr::Arrow(_) | box Expr::Fn(_))) =
if let (Pat::Ident(ident), Some(Expr::Arrow(_) | Expr::Fn(_))) =
(&var_declarator.name, &var_declarator.init)
{
if self.in_module_level
Expand Down Expand Up @@ -3301,7 +3301,7 @@ fn has_body_directive(maybe_body: &Option<BlockStmt>) -> (bool, bool) {
for stmt in body.stmts.iter() {
match stmt {
Stmt::Expr(ExprStmt {
expr: box Expr::Lit(Lit::Str(Str { value, .. })),
expr: Expr::Lit(Lit::Str(Str { value, .. })),
..
}) => {
if value == "use server" {
Expand Down Expand Up @@ -3440,7 +3440,7 @@ impl DirectiveVisitor<'_> {

match stmt {
Stmt::Expr(ExprStmt {
expr: box Expr::Lit(Lit::Str(Str { value, span, .. })),
expr: Expr::Lit(Lit::Str(Str { value, span, .. })),
..
}) => {
if value == "use server" {
Expand Down Expand Up @@ -3575,8 +3575,8 @@ impl DirectiveVisitor<'_> {
}
Stmt::Expr(ExprStmt {
expr:
box Expr::Paren(ParenExpr {
expr: box Expr::Lit(Lit::Str(Str { value, .. })),
Expr::Paren(ParenExpr {
expr: Expr::Lit(Lit::Str(Str { value, .. })),
..
}),
span,
Expand Down Expand Up @@ -3668,7 +3668,7 @@ impl VisitMut for ClosureReplacer<'_> {
fn visit_mut_prop_or_spread(&mut self, n: &mut PropOrSpread) {
n.visit_mut_children_with(self);

if let PropOrSpread::Prop(box Prop::Shorthand(i)) = n {
if let PropOrSpread::Prop(Prop::Shorthand(i)) = n {
let name = Name::from(&*i);
if let Some(index) = self.used_ids.iter().position(|used_id| *used_id == name) {
*n = PropOrSpread::Prop(Box::new(Prop::KeyValue(KeyValueProp {
Expand Down
10 changes: 9 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
const nextJest = require('next/jest')
const { withGateTransformer } = require('./test/lib/gate/jest-transformer')

const createJestConfig = nextJest()

Expand Down Expand Up @@ -89,4 +90,11 @@ if (enableTestReport) {
}

// createJestConfig is exported in this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)
const createConfig = createJestConfig(customJestConfig)

module.exports = async function createConfigWithGates() {
// `withGateTransformer` chains the `@gate` pragma rewrite in front of the
// SWC transformer that `next/jest` configured, keeping next/jest's SWC
// options as the single source of truth. See test/lib/gate/.
return withGateTransformer(await createConfig())
}
2 changes: 1 addition & 1 deletion packages/next/src/server/app-render/dynamic-rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,7 +355,7 @@ export function abortAndThrowOnSynchronousRequestDataAccess(
// promise (see makeRuntimeHangingPromise). Unlike
// `abortOnSynchronousPlatformIOAccess`, which aborts a runtime prerender
// all the same and therefore must not record anything.
trackRuntimeDataAccessed(prerenderStore)
trackRuntimeDataAccessed(prerenderStore, expression)

const prerenderSignal = prerenderStore.controller.signal
if (prerenderSignal.aborted === false) {
Expand Down
Loading
Loading