Files
vercel__next.js/test/lib/gate/runtime.ts
Josh Story 4cb547213b test: support deploy exclusions with force gates (#98149)
## Summary

- add static gate conditions for the deployment CI variants used by
existing test suites
- support `@force-gate` on parameterized `describe.each` suites
- document and test the force-gate infrastructure needed by the
migration

This establishes the gate support used by the deployment-exclusion
migration PRs above it.

## Verification

- `pnpm typescript`
- `pnpm jest test/unit/gate/pragma-transform.test.ts
test/unit/gate/runtime.test.ts --runInBand`

<!-- NEXT_JS_LLM -->
2026-09-08 19:41:08 -07:00

649 lines
22 KiB
TypeScript

/**
* The `@gate` runtime.
*
* `test/lib/gate/pragma-transform.js` rewrites
*
* ```
* // @gate !cacheComponents
* it('name', body)
* ```
*
* into `_test_gate([{force: false, source: '!cacheComponents'}], 'it')('name', body)`,
* and this module installs that `_test_gate` global.
*
* ## What a gate does
*
* `// @gate <condition>` **always runs the test**. If the condition is true the
* test behaves normally. If the condition is false the expectation is inverted:
* a failing body is reported as a pass, and a *passing* body is reported as a
* failure — "the gate is stale, delete it". That inversion is the whole point.
* An `it.skip` is a dead end that nobody revisits; a `@gate` is a tripwire that
* fires the day the underlying bug is fixed.
*
* Conditions are declared in `./conditions.ts`. Static ones (mode, bundler)
* are known at collection time, so a false static `@gate` registers the test
* through Jest's own `test.failing` and the inversion is native. Lazy ones are
* read from the booted fixture's resolved config the first time a gate asks,
* which can only happen inside the test body — those tests wrap the body and
* invert the outcome at runtime.
*
* ## `@force-gate`
*
* `// @force-gate <condition>` skips the test when the condition is false,
* giving up the stale-gate tripwire entirely. Prefer `@gate`; reach for
* `@force-gate` only when running the body is impossible rather than merely
* failing: dev mode has no build output, deploy mode cannot touch the
* filesystem.
*
* A static condition is decided at collection time, a real Jest
* `○ skipped`. A *lazy* one (read off the fixture's resolved config) cannot
* be known then, so it force-passes the test at runtime instead (see
* `wrapGatedBody`), and on a `describe` it also skips the fixture build
* (`nextTestSetup`). Hooks registered inside such a `describe` are skipped
* too (see `wrapGatedHook`): the fixture they would prepare or inspect was
* never booted.
*/
import { evaluate, parse, type ExprNode } from './expr'
import { getCondition } from './conditions'
import { getResolvedConfigForGates, hasFixture } from './state'
import type { ResolvedNextConfig } from './resolved-config'
/** The shape the transform emits. */
export type GatePragma = {
force: boolean
source: string
}
export type Gate = GatePragma & {
node: ExprNode
names: string[]
/** True when any referenced condition has to be read off the fixture. */
needsResolvedConfig: boolean
}
type TestFn = (
name: string,
fn?: jest.ProvidesCallback,
timeout?: number
) => void
/**
* Gates on an enclosing `describe` apply to every test inside it, including
* tests that carry no pragma of their own. The stack is pushed while the
* `describe` body is being collected.
*/
const describeGateStack: Gate[] = []
/** Bodies this module already wrapped, so inherited gates are not re-applied. */
const gatedBodies = new WeakSet<Function>()
/**
* Hook callbacks the harness itself registered (`nextTestSetup`'s setup and
* teardown) while a gated `describe` body was being collected. They must run
* even when a lazy `@force-gate` skips the suite: they are what makes the
* skip decision, and they clear the fixture afterwards.
*/
const ungatedHooks = new WeakSet<Function>()
/**
* Marks a hook callback as harness-internal so `wrapHookGlobals` leaves it
* alone. Suite code never needs this.
*/
export function ungatedHook<T extends Function>(hook: T): T {
ungatedHooks.add(hook)
return hook
}
function staleGateMessage(gate: Gate): string {
return (
`Gated test passed unexpectedly.\n\n` +
`This test is marked \`// @gate ${gate.source}\`, and that condition is ` +
`currently false, so the test was expected to fail — but it passed.\n` +
`The gate is stale: delete the \`// @gate ${gate.source}\` pragma (and ` +
`whatever workaround came with it).`
)
}
function parseGate(pragma: GatePragma): Gate {
let parsed
try {
parsed = parse(pragma.source)
} catch (err) {
throw new Error(
`Could not parse \`// @${pragma.force ? 'force-gate' : 'gate'} ${
pragma.source
}\`: ${(err as Error).message}`
)
}
const lazyNames = parsed.names.filter(
(name) => getCondition(name).kind === 'lazy'
)
// `needsResolvedConfig` also classifies a `@force-gate`. A static force-gate
// (mode/bundler) is decided while tests are collected — a real Jest skip. A
// *lazy* force-gate can't be known then, so it is decided at runtime once the
// fixture's config is resolvable: it force-passes the test (and, on a
// `describe`, skips the build) rather than emitting a collection-time skip.
return { ...pragma, ...parsed, needsResolvedConfig: lazyNames.length > 0 }
}
function readCondition(name: string, config?: ResolvedNextConfig): unknown {
const condition = getCondition(name)
if (condition.kind === 'static') return condition.value()
if (!config) {
// Unreachable: `needsResolvedConfig` makes us resolve the config first.
throw new Error(`\`@gate ${name}\` was evaluated without a config`)
}
return condition.value(config)
}
type GateDecision =
| { type: 'run' }
| { type: 'force-pass'; gate: Gate }
| { type: 'invert'; gate: Gate }
/**
* Decides what to do with a test, given the gates that apply to it. Resolves
* the fixture config once if any gate needs it.
*
* A false `@force-gate` skips the test (force-pass) and takes precedence over
* an inverted `@gate` on the same test — you can't assert-fail a test you're
* skipping. Otherwise the first false `@gate` inverts the expectation, and if
* every gate holds the test runs normally.
*/
async function decideGates(gates: Gate[]): Promise<GateDecision> {
let config: ResolvedNextConfig | undefined
if (gates.some((gate) => gate.needsResolvedConfig)) {
config = await getResolvedConfigForGates()
}
const read = (name: string) => readCondition(name, config)
for (const gate of gates) {
if (gate.force && !evaluate(gate.node, read)) {
return { type: 'force-pass', gate }
}
}
for (const gate of gates) {
if (!gate.force && !evaluate(gate.node, read)) {
return { type: 'invert', gate }
}
}
return { type: 'run' }
}
/** The condition values a `gate()` predicate receives. */
export type GateConditions = Record<string, unknown>
/**
* Reads conditions by name, validating against the registry (an undeclared
* name throws, same as a pragma). Lazy reads without a resolved config raise
* the standard "no fixture is registered" error.
*/
function makeConditionsObject(
config: ResolvedNextConfig | undefined
): GateConditions {
return new Proxy({} as GateConditions, {
get(_target, prop) {
if (typeof prop !== 'string') return undefined
const condition = getCondition(prop)
if (condition.kind === 'static') return condition.value()
if (config === undefined) {
// No fixture is registered (otherwise `gate()` resolved the config
// before calling the predicate); this throws the explanatory error.
void getResolvedConfigForGates()
}
return condition.value(config!)
},
has: () => true,
})
}
/**
* The runtime counterpart of the pragma, for conditional logic *inside* a test
* body — the same condition registry, evaluated on demand:
*
* ```ts
* import { gate } from 'next-test-utils'
*
* if (await gate((conditions) => conditions.cacheComponents)) {
* expect(...).toBe(...)
* } else {
* expect(...).toBe(...)
* }
* ```
*
* The function form mirrors React's `gate(flags => flags.enableFoo)`
* (`scripts/jest/setupTests.js`), except it is imported rather than installed
* as a global, and it is async, because lazy conditions read the booted
* fixture's resolved config. A string is also accepted and evaluated in the
* pragma expression language: `await gate('cacheComponents && !dev')`.
*
* Unlike the pragma there is no inversion — it only answers the question — so
* reach for it when a body should run under both states but assert
* differently, and for tests a pragma cannot attach to (`it.each`). When the
* whole body is a known failure, use `// @gate` instead and keep the tripwire.
*/
export async function gate(
arg: string | ((conditions: GateConditions) => unknown)
): Promise<boolean> {
if (typeof arg === 'string') {
const parsed = parseGate({ force: false, source: arg })
let config: ResolvedNextConfig | undefined
if (parsed.needsResolvedConfig) {
config = await getResolvedConfigForGates()
}
return Boolean(evaluate(parsed.node, (name) => readCondition(name, config)))
}
// Whether the predicate reads a lazy condition can't be known without
// running it, so resolve the config up front whenever a fixture is
// registered (memoized on the instance). Suites without a fixture can still
// gate on static conditions.
const config = hasFixture() ? await getResolvedConfigForGates() : undefined
return Boolean(arg(makeConditionsObject(config)))
}
/**
* Runs `callback` and throws `errorIfItPasses` if it *doesn't* fail. Adapted
* from React's `scripts/jest/setupTests.js`.
*/
export async function expectTestToFail(
callback: () => unknown,
errorIfItPasses: Error
): Promise<void> {
let didError = false
try {
await callback()
} catch {
didError = true
}
if (!didError) throw errorIfItPasses
}
function wrapGatedBody(
gates: Gate[],
callback: Function
): jest.ProvidesCallback {
if (callback.length > 0) {
throw new Error(
`A gated test cannot use the \`done\` callback, because the gate has to ` +
`observe whether the test failed. Return a promise instead.`
)
}
const body = async function gatedBody(this: unknown): Promise<void> {
const decision = await decideGates(gates)
if (decision.type === 'run') {
await callback.call(this)
return
}
if (decision.type === 'force-pass') {
// A lazy `@force-gate` whose condition is false: the test can't be
// attempted here, so skip the body and report a pass. Jest can't turn a
// running test into `○ skipped`, so this shows as passed — the warning is
// the signal. A *static* force-gate never reaches here; it is a real skip.
require('console').warn(
` ⚠ skipped by \`@force-gate ${decision.gate.source}\``
)
return
}
const error = new Error(staleGateMessage(decision.gate))
Error.captureStackTrace(error, body)
await expectTestToFail(() => callback.call(this), error)
require('console').warn(
` ⚠ gated test failed as expected (@gate ${decision.gate.source})`
)
}
gatedBodies.add(body)
return body as jest.ProvidesCallback
}
/**
* The hook counterpart of `wrapGatedBody`, for a `beforeAll` / `afterAll` /
* `beforeEach` / `afterEach` registered inside a `describe` that carries a
* lazy `@force-gate`. When the gate force-skips the suite (and its build),
* the hooks must not run either: they would prepare or inspect a fixture that
* was never booted, while the suite's tests all force-pass without them.
*
* Only a false lazy `@force-gate` skips a hook. An inverted `@gate` still
* runs its tests, so they need their hooks, and a static `@force-gate` skips
* the whole `describe` at collection time, hooks included.
*/
function wrapGatedHook(
gates: Gate[],
callback: Function
): () => Promise<unknown> {
return async function gatedHook(this: unknown): Promise<unknown> {
if (!hasFixture()) {
// The condition cannot be resolved here: either this hook runs before
// `nextTestSetup`'s own `beforeAll` registered the fixture, or it is an
// `afterAll` running after the fixture was cleared. Run as if ungated.
return callback.apply(this)
}
const config = await getResolvedConfigForGates()
if (findLazyForceSkip(gates, config) !== null) {
// Silent: the suite-level "build skipped" warning and the per-test
// force-pass warnings already carry the signal, and a static
// `describe.skip` skips hooks silently too.
return
}
return callback.apply(this)
}
}
/**
* The body for a test registered through Jest's own `test.failing`, used when
* a false static `@gate` is known at collection time. Jest inverts the outcome
* natively; this wrapper only keeps the log lines consistent with the
* runtime-inverted (lazy) path.
*/
function wrapFailingBody(
gate: Gate,
callback: Function
): jest.ProvidesCallback {
if (callback.length > 0) {
throw new Error(
`A gated test cannot use the \`done\` callback, because the gate has to ` +
`observe whether the test failed. Return a promise instead.`
)
}
const body = async function gatedFailingBody(this: unknown): Promise<void> {
try {
await callback.call(this)
} catch (error) {
require('console').warn(
` ⚠ gated test failed as expected (@gate ${gate.source})`
)
throw error
}
// The body passed. Jest is about to fail this test with its generic
// "Failing test passed even though it was supposed to fail" error, which
// points at a `.failing` modifier the author never wrote — so explain the
// real situation alongside it.
require('console').warn(staleGateMessage(gate))
}
gatedBodies.add(body)
return body as jest.ProvidesCallback
}
/**
* Finds a false static `@gate` at collection time, which decides the whole
* test early: it registers through Jest's native `test.failing` instead of
* wrapping the body. Not applicable when a lazy `@force-gate` is also present
* — that could override the inversion with a force-pass, so the decision has
* to wait for the fixture's config at runtime. (If a lazy plain gate is false
* as well, the outcome is an inversion either way; the static one is simply
* the gate that gets named.)
*/
function findStaticInversion(gates: Gate[]): Gate | null {
if (gates.some((gate) => gate.force && gate.needsResolvedConfig)) {
return null
}
return (
gates.find(
(gate) =>
!gate.force &&
!gate.needsResolvedConfig &&
!evaluate(gate.node, (name) => readCondition(name))
) ?? null
)
}
function resolveTestFn(kind: string): TestFn {
const g = global as any
switch (kind) {
case 'it':
return g.it
case 'test':
return g.test
case 'fit':
return g.fit ?? g.it.only
case 'it.only':
return g.it.only
case 'test.only':
return g.test.only
case 'describe':
return g.describe
case 'describe.only':
return g.describe.only
default:
throw new Error(`\`@gate\` does not support \`${kind}(...)\``)
}
}
/** The `.skip` counterpart of `resolveTestFn`, for a false `@force-gate`. */
function resolveSkipFn(kind: string): TestFn {
const g = global as any
if (kind.startsWith('describe')) return g.describe.skip
if (kind.startsWith('test')) return g.test.skip
return g.it.skip
}
export function _test_gate(pragmas: GatePragma[], kind: string) {
// Parsing and validation happen while the test file is being collected, so a
// typo'd condition fails the whole suite instead of one test.
const allGates = pragmas.map(parseGate)
return createGatedTest(
allGates,
kind,
() => resolveTestFn(kind),
() => resolveSkipFn(kind)
)
}
/** `describe.each(table)` binds the table before receiving the suite call. */
export function _test_gate_describe_each(
pragmas: GatePragma[],
table: readonly unknown[]
) {
const g = global as any
const allGates = pragmas.map(parseGate)
return createGatedTest(
allGates,
'describe.each',
() => g.describe.each(table),
() => g.describe.skip.each(table)
)
}
function createGatedTest(
allGates: Gate[],
kind: string,
getTestFn: () => TestFn,
getSkipFn: () => TestFn
) {
// A static `@force-gate` is decided at collection time (a real Jest skip).
// Everything else — `@gate`, and *lazy* `@force-gate` — is resolved at
// runtime, so it inherits down into the tests via the describe stack.
const staticForceGates = allGates.filter(
(gate) => gate.force && !gate.needsResolvedConfig
)
const runtimeGates = allGates.filter(
(gate) => !gate.force || gate.needsResolvedConfig
)
const isDescribe = kind.startsWith('describe')
return function gated(name: string, callback: Function, timeout?: number) {
// A false static `@force-gate` is a real Jest skip, decided right here.
const forcedOff = staticForceGates.find(
(gate) => !evaluate(gate.node, (condition) => readCondition(condition))
)
if (forcedOff) {
return getSkipFn()(name, callback as jest.ProvidesCallback, timeout)
}
const testFn = getTestFn()
if (isDescribe) {
// Register the `describe` normally, but make its runtime gates (including
// a lazy `@force-gate`) visible while its body is collected so nested
// tests inherit them and `nextTestSetup` can gate the build.
return testFn(name, function (this: unknown, ...args: unknown[]) {
describeGateStack.push(...runtimeGates)
try {
return callback.apply(this, args)
} finally {
describeGateStack.length -= runtimeGates.length
}
} as jest.ProvidesCallback)
}
const applicable = [...describeGateStack, ...runtimeGates]
const staticInversion = findStaticInversion(applicable)
const failingFn = (testFn as { failing?: TestFn }).failing
if (staticInversion && typeof failingFn === 'function') {
return failingFn(
name,
wrapFailingBody(staticInversion, callback),
timeout
)
}
return testFn(name, wrapGatedBody(applicable, callback), timeout)
}
}
/**
* A snapshot of the gates on the enclosing `describe`(s), taken while the
* describe body is being collected. `nextTestSetup` reads this synchronously to
* find a lazy `@force-gate` that should gate the fixture build. The stack is
* empty again once collection finishes, so it must be read at call time.
*/
export function getActiveDescribeGates(): Gate[] {
return [...describeGateStack]
}
/** Whether any of `gates` is a lazy `@force-gate` (resolved from config). */
export function hasLazyForceGate(gates: Gate[]): boolean {
return gates.some((gate) => gate.force && gate.needsResolvedConfig)
}
/**
* The first lazy `@force-gate` in `gates` whose condition is false against the
* resolved `config` — i.e. the one that says "don't build this fixture here" —
* or `null` if none apply. Used by `nextTestSetup` to skip the build.
*/
export function findLazyForceSkip(
gates: Gate[],
config: ResolvedNextConfig
): GatePragma | null {
for (const gate of gates) {
if (
gate.force &&
gate.needsResolvedConfig &&
!evaluate(gate.node, (name) => readCondition(name, config))
) {
return gate
}
}
return null
}
/**
* Every `it` / `test` — gated or not — has to consult the enclosing
* `describe`'s gates, so the globals are wrapped once. This is the same
* technique `test/lib/e2e-utils` uses to inject a per-test timeout, and the two
* wrappers compose.
*
* Known gap: `it.each` and friends bypass the wrapper, so a `describe`-level
* gate does not reach them. `it.each` cannot carry a pragma of its own either
* (the transform rejects it).
*/
function wrapTestGlobals(): void {
for (const key of ['it', 'test'] as const) {
const original = (global as any)[key]
if (typeof original !== 'function' || original.__gateWrapped) continue
const wrapped = new Proxy(original, {
apply(target, thisArg, args: any[]) {
const [name, callback, timeout] = args
if (
describeGateStack.length === 0 ||
typeof callback !== 'function' ||
gatedBodies.has(callback)
) {
return Reflect.apply(target, thisArg, args)
}
const applicable = [...describeGateStack]
const staticInversion = findStaticInversion(applicable)
const failingFn = (target as { failing?: TestFn }).failing
if (staticInversion && typeof failingFn === 'function') {
return Reflect.apply(failingFn, thisArg, [
name,
wrapFailingBody(staticInversion, callback),
timeout,
])
}
return Reflect.apply(target, thisArg, [
name,
wrapGatedBody(applicable, callback),
timeout,
])
},
})
Object.defineProperty(wrapped, '__gateWrapped', { value: true })
;(global as any)[key] = wrapped
}
}
/**
* The hook counterpart of `wrapTestGlobals`. Hooks carry no pragma, so the
* only thing that can skip one is a lazy `@force-gate` inherited from the
* enclosing `describe`, which is exactly the case where the fixture was never
* booted and running the hook would fail (or worse, hang) for no benefit.
*/
function wrapHookGlobals(): void {
for (const key of [
'beforeAll',
'afterAll',
'beforeEach',
'afterEach',
] as const) {
const original = (global as any)[key]
if (typeof original !== 'function' || original.__gateWrapped) continue
const wrapped = new Proxy(original, {
apply(target, thisArg, args: any[]) {
const [callback] = args
if (
typeof callback !== 'function' ||
// A `done`-style hook cannot be observed; leave it alone.
callback.length > 0 ||
!hasLazyForceGate(describeGateStack) ||
ungatedHooks.has(callback)
) {
return Reflect.apply(target, thisArg, args)
}
return Reflect.apply(target, thisArg, [
wrapGatedHook([...describeGateStack], callback),
...args.slice(1),
])
},
})
Object.defineProperty(wrapped, '__gateWrapped', { value: true })
;(global as any)[key] = wrapped
}
}
/** Called from `test/jest-setup-after-env.ts`. */
export function installGate(): void {
;(global as any)._test_gate = _test_gate
;(global as any)._test_gate_describe_each = _test_gate_describe_each
wrapTestGlobals()
wrapHookGlobals()
}
/** Test-only: the parse/validate half, without registering anything. */
export const __testing = { parseGate, decideGates, wrapGatedBody }