cdk-nextjs 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/.jsii +2 -2
  2. package/docs/adapter-runtime.md +1 -1
  3. package/docs/harness-coverage.md +153 -73
  4. package/lib/adapter/adapter.mjs +12 -3
  5. package/lib/adapter/aws-cache-store.d.ts +7 -0
  6. package/lib/adapter/aws-cache-store.js +12 -1
  7. package/lib/adapter/aws-cache-store.test.js +32 -1
  8. package/lib/adapter/cache-handler.d.ts +5 -3
  9. package/lib/adapter/cache-handler.js +16 -10
  10. package/lib/adapter/cache-handler.mjs +103 -26
  11. package/lib/adapter/cache-handler.test.js +44 -1
  12. package/lib/adapter/cache-utils.d.ts +22 -0
  13. package/lib/adapter/cache-utils.js +44 -1
  14. package/lib/adapter/function-groups.js +17 -4
  15. package/lib/adapter/function-groups.test.js +33 -1
  16. package/lib/adapter/memory-cache-handler.d.ts +5 -1
  17. package/lib/adapter/memory-cache-handler.js +6 -2
  18. package/lib/adapter/s3-cache-handler.d.ts +12 -1
  19. package/lib/adapter/s3-cache-handler.js +30 -8
  20. package/lib/adapter/s3-cache-handler.test.js +84 -2
  21. package/lib/adapter/use-cache-common.d.ts +13 -0
  22. package/lib/adapter/use-cache-common.js +22 -6
  23. package/lib/adapter/use-cache-common.test.js +37 -1
  24. package/lib/adapter/use-cache-default-handler.mjs +33 -10
  25. package/lib/adapter/use-cache-remote-handler.mjs +33 -12
  26. package/lib/nextjs-api.js +1 -1
  27. package/lib/nextjs-build/nextjs-build.js +1 -1
  28. package/lib/nextjs-cache.js +1 -1
  29. package/lib/nextjs-compute/nextjs-containers.js +1 -1
  30. package/lib/nextjs-compute/nextjs-functions.js +1 -1
  31. package/lib/nextjs-distribution.js +1 -1
  32. package/lib/nextjs-post-deploy.js +1 -1
  33. package/lib/nextjs-static-assets.js +1 -1
  34. package/lib/root-constructs/nextjs-base-construct.js +1 -1
  35. package/lib/root-constructs/nextjs-global-containers.js +1 -1
  36. package/lib/root-constructs/nextjs-global-functions.js +1 -1
  37. package/lib/root-constructs/nextjs-regional-containers.js +1 -1
  38. package/lib/root-constructs/nextjs-regional-functions.js +1 -1
  39. package/lib/runtime/core.js +3 -1
  40. package/lib/runtime/image-utils.d.ts +8 -9
  41. package/lib/runtime/image-utils.js +16 -1
  42. package/lib/runtime/image-utils.test.js +13 -1
  43. package/lib/runtime/image.js +9 -3
  44. package/lib/runtime/lambda.mjs +50 -15
  45. package/lib/runtime/server.mjs +50 -15
  46. package/lib/runtime/tag-manifest.d.ts +25 -0
  47. package/lib/runtime/tag-manifest.js +37 -2
  48. package/lib/runtime/tag-manifest.test.js +27 -1
  49. package/package.json +3 -3
  50. package/scripts/e2e-deploy.sh +7 -2
  51. package/scripts/e2e-harness/README.md +72 -35
  52. package/scripts/e2e-harness/screen.mjs +148 -7
  53. package/scripts/playwright-install-deps.sh +40 -0
@@ -6,9 +6,10 @@ write, because the tests were written by the people who define the behavior.
6
6
 
7
7
  `examples/e2e-tests/` remains the per-commit gate on all four `NextjsType`s. This
8
8
  is the scheduled one — every sixth day of the month at 14:00 UTC, so the day of
9
- the week drifts — on `NextjsGlobalFunctions` only. The other three run by
10
- `workflow_dispatch` (its `nextjs_type` input) or by hand; see "Running on the
11
- other `NextjsType`s".
9
+ the week drifts — on all four types in one run, `NextjsGlobalFunctions` first
10
+ (see "Sharding"). A `workflow_dispatch` runs one type (its `nextjs_type` input,
11
+ `NextjsGlobalFunctions` by default) or `all`; see "Running on the other
12
+ `NextjsType`s".
12
13
 
13
14
  What has actually been run, what failed, and whether each failure is a bug or
14
15
  acceptable: [`docs/harness-coverage.md`](../../docs/harness-coverage.md).
@@ -26,7 +27,8 @@ acceptable: [`docs/harness-coverage.md`](../../docs/harness-coverage.md).
26
27
  | `scripts/e2e-harness/common.sh` | Shared file names, stack naming, output reads, and the tag check that gates every delete. |
27
28
  | `.github/actions/build-nextjs` | Checks out and builds vercel/next.js. The cache-miss path, shared by the `nextjs` job and a shard's fallback. |
28
29
  | `test/deploy-tests-manifest.json` | Which next.js test files run (`NEXT_EXTERNAL_TESTS_FILTERS`). |
29
- | `.github/workflows/e2e-harness.yml` | Every six days + `workflow_dispatch`. A matrix of `shard_total` jobs, one stack each. |
30
+ | `test/harness-smoke-manifest.json` | One file every type passes, for checking the workflow itself in about an hour (`test_filters`). |
31
+ | `.github/workflows/e2e-harness.yml` | Every six days (all four types) + `workflow_dispatch`. A matrix of (type, shard) jobs, one stack each. |
30
32
 
31
33
  ## One shared stack, not one per test file
32
34
 
@@ -36,8 +38,8 @@ one deployment — every file genuinely has different code to ship. What there i
36
38
  way to do is ship it into infrastructure that already exists.
37
39
 
38
40
  Every test file in a run deploys into the same stack (`hrns-shared` by default,
39
- `hrns-shard-<n>` under the workflow's matrix — see "Sharding" below) with
40
- `cdk deploy --hotswap-fallback`. Creating and propagating the CloudFront
41
+ `hrns-shard-<n>`, with the type's infix, under the workflow's matrix — see
42
+ "Sharding" below) with `cdk deploy --hotswap-fallback`. Creating and propagating the CloudFront
41
43
  distribution costs ~4 minutes once per stack; every test file reuses it. That is
42
44
  the whole saving, and it is most of the cost of a run.
43
45
 
@@ -141,9 +143,31 @@ of them at once locally" — and delete it afterwards with
141
143
 
142
144
  A stack holds one app at a time, so the only way to run two test files at once is
143
145
  to give them two stacks. `HARNESS_SHARED_STACK_SUFFIX` is that knob, and
144
- `.github/workflows/e2e-harness.yml` turns it into a matrix: `shard_total` jobs
145
- (10 by default), each with a shared stack named `hrns-shard-<n>` and each running
146
- its own slice of the file list.
146
+ `.github/workflows/e2e-harness.yml` turns it into a matrix of (type, shard) jobs:
147
+ `shard_total` per type (by default 15 for Global Containers, whose ~4.3 minutes a
148
+ file do not fit 10 shards into the run step's cap, and 10 for the others), each
149
+ with a shared stack named `hrns-shard-<n>` (with the type's infix) and each
150
+ running its own slice of that type's file list.
151
+
152
+ A run of one type starts all its shards at once. A run of `all` (the schedule)
153
+ lists 45 jobs, Global Functions first, then Regional Functions, Regional
154
+ Containers and Global Containers, and runs at most 10 at a time, which is about 11
155
+ hours end to end (about 95 shard-hours: Global Containers' 15 shards take about
156
+ 2.5 hours each, the others about 2; the shards of one type end together, so the
157
+ last five Global Containers shards run with half the slots idle). Ten is the peak
158
+ a single-type run of the other three already reaches: no more CloudFront
159
+ distributions at once than a Global Functions run, under the CloudFront
160
+ response-headers-policy quota (3 per distribution), no more ECR pulls than a
161
+ Regional Containers run, and no more of the org's concurrent jobs than the
162
+ schedule has always taken. A slot goes to the next job as soon as one frees, so
163
+ types overlap where one ends and the next begins; that is safe, because no two
164
+ entries share a stack name. Results arrive in that order: a Global Functions
165
+ regression shows up first, about 2 hours in.
166
+
167
+ To check the workflow itself rather than compatibility, dispatch `all` with
168
+ `shard_total: 1` and `test_filters: ../test/harness-smoke-manifest.json`: one
169
+ file on each type, four jobs, about an hour (Global Containers' ~15-minute warm-up
170
+ and its delete are most of it).
147
171
 
148
172
  The slice comes from next.js's own `run-tests.js -g <n>/<N>`, which splits
149
173
  **after** `NEXT_EXTERNAL_TESTS_FILTERS` has been applied — `run-tests.js` filters
@@ -158,9 +182,10 @@ prints per-file durations to build one from.
158
182
 
159
183
  Each shard is self-contained, which is what makes this safe:
160
184
 
161
- - `e2e-warm.sh` warms _its_ stack, so the ~4-minute distribution create is paid
162
- once per shard but in parallel — the same ~4 minutes of wall clock however many
163
- shards there are.
185
+ - `e2e-warm.sh` warms _its_ stack, so the ~4-minute distribution create (Global
186
+ types only) is paid once per shard but in parallel — the same ~4 minutes of
187
+ wall clock however many shards start together (in an `all` run, once per wave
188
+ of 10 Global shards).
164
189
  - `e2e-sweep.sh --apply --shared` in an `always()` step deletes _its_ stack, since
165
190
  `--shared` resolves the same suffix. Nothing lowers the age floor account-wide,
166
191
  so one shard finishing early cannot delete another's stack out from under it.
@@ -179,9 +204,10 @@ workflow's `concurrency` group is what guarantees that, and it queues rather tha
179
204
  cancels. It is one group for every type, not one per type: each run's
180
205
  account-wide sweep could otherwise delete another type's idle-looking shard stack
181
206
  as that run warms into it. GitHub keeps only one _pending_ run per group, so
182
- dispatch a second type once the first has started, not while one is queued. A
183
- pending run that gets cancelled that way, the scheduled one included, loses no
184
- sweep: every run sweeps, not just the scheduled one.
207
+ dispatch a second type once the first has started, not while one is queued, or
208
+ dispatch `all`. A dispatch on a schedule day waits behind the schedule's ~11
209
+ hours. A pending run that gets cancelled that way, the scheduled one included,
210
+ loses no sweep: every run sweeps, not just the scheduled one.
185
211
 
186
212
  ## Caching the next.js build
187
213
 
@@ -239,8 +265,9 @@ the `v1` in it to discard every entry). Two things to know:
239
265
  comparing against is whatever the other workflows are using at the time.
240
266
  - **The Playwright _browser_ is cached; its system libraries are not.** Those are
241
267
  apt packages outside any cacheable path, so
242
- `playwright install --with-deps chromium` still runs in each shard. It is a
243
- no-op for the download on a hit.
268
+ `scripts/playwright-install-deps.sh` (`playwright install-deps chromium`)
269
+ still runs in each shard. It caps and retries each attempt, because apt on its
270
+ own can hang on a stalled mirror until the job times out.
244
271
 
245
272
  A `workflow_dispatch` from a branch writes to that branch's own cache scope, so
246
273
  the first dispatched run on a new branch pays the build once. It can still _read_
@@ -275,16 +302,16 @@ last. A session that must have its own working tree wants a `git worktree` _and_
275
302
  its own next.js checkout _and_ its own stack suffix; three of those is usually the
276
303
  point at which running one at a time is the cheaper answer.
277
304
 
278
- ## Why the schedule runs `NextjsGlobalFunctions`
305
+ ## Why `NextjsGlobalFunctions` is the default
279
306
 
280
- All four types run (see "Running on the other `NextjsType`s"); the schedule picks
281
- one. The harness builds every request URL as `new URL(path, deploymentUrl)` —
282
- `getFullUrl` in `test/lib/next-test-utils.ts` assigns `pathname` outright — so any
283
- prefix in the deployment URL is dropped. `NextjsRegionalFunctions`'s API Gateway
284
- REST URL always ends in `/<stage>`, so it runs behind `stage-proxy.mjs`, a local
285
- proxy that puts the stage back (see "Running on `NextjsRegionalFunctions`") — a
286
- second front door, not the one users deploy. A CloudFront distribution is served
287
- at the origin root.
307
+ All four types run on the schedule (see "Sharding"); this one runs first, and is
308
+ what a dispatch runs unless told otherwise. The harness builds every request URL
309
+ as `new URL(path, deploymentUrl)` — `getFullUrl` in `test/lib/next-test-utils.ts`
310
+ assigns `pathname` outright — so any prefix in the deployment URL is dropped.
311
+ `NextjsRegionalFunctions`'s API Gateway REST URL always ends in `/<stage>`, so it
312
+ runs behind `stage-proxy.mjs`, a local proxy that puts the stage back (see
313
+ "Running on `NextjsRegionalFunctions`") — a second front door, not the one users
314
+ deploy. A CloudFront distribution is served at the origin root.
288
315
 
289
316
  It is also the front door the suite was written for. `NEXT_TEST_MODE=deploy` is
290
317
  the mode Vercel validates edge-fronted deployments with, so its tests tolerate a
@@ -308,17 +335,18 @@ Two caveats worth knowing before reading a failure as a regression:
308
335
  limitation documented at `src/nextjs-distribution.ts`. A test that varies on a
309
336
  header outside that list can be served a wrong cached response. That is a real
310
337
  product limitation, not a harness artifact — and not a regression either.
311
- - The other three types are not on the schedule; see "Running on the other
312
- `NextjsType`s". `examples/e2e-tests` is the per-commit gate on all three.
338
+ - The schedule runs the other three types too, after this one, so their results
339
+ land hours later in the same run; see "Sharding".
313
340
 
314
341
  ## Running on the other `NextjsType`s
315
342
 
316
343
  `HARNESS_NEXTJS_TYPE` picks the root construct: `global-functions` (the default),
317
344
  `regional-functions`, `global-containers` or `regional-containers`. Each type but
318
345
  the default gets a stack infix of its own — `hrns-rf-*`, `hrns-gc-*`,
319
- `hrns-rc-*` — so no run can deploy into another type's stack. In CI, runs of two
320
- types still queue behind each other (see "Sharding"). The workflow's
321
- `nextjs_type` input sets it for every shard.
346
+ `hrns-rc-*` — so no run can deploy into another type's stack, and shards of all
347
+ four types can share one run. In CI, two *runs* still queue behind each other
348
+ (see "Sharding"). The workflow's `nextjs_type` input sets the type, or `all`
349
+ runs every type, as the schedule does.
322
350
 
323
351
  ```bash
324
352
  ADAPTER_DIR=$PWD HARNESS_NEXTJS_TYPE=global-containers ./scripts/e2e-warm.sh
@@ -396,7 +424,7 @@ reason alone.
396
424
 
397
425
  `HARNESS_NEXTJS_TYPE=regional-functions` deploys `NextjsRegionalFunctions`
398
426
  instead, into `hrns-rf-<suffix>` stacks so it can never land in a Global run's.
399
- By hand or `workflow_dispatch`; the schedule runs `NextjsGlobalFunctions` only.
427
+ By hand, by `workflow_dispatch`, or on the schedule with the other three types.
400
428
 
401
429
  The stage is the whole problem, and `stage-proxy.mjs` is the whole answer. The
402
430
  suite discards any path in the deployment URL (above), and a REST API's is always
@@ -464,7 +492,8 @@ To check one candidate by hand instead:
464
492
  ```bash
465
493
  # in the next.js checkout, against the fixture root (usually the test file's dir
466
494
  # or its parent)
467
- find <fixture> -name "middleware.*"
495
+ # a middleware.* is edge unless it sets `runtime: "nodejs"` in its config
496
+ find <fixture> -name "middleware.*" -exec grep -LE "runtime\s*:\s*.nodejs." {} +
468
497
  grep -rlE "runtime\s*[:=]\s*.(experimental-)?edge." <fixture>
469
498
  ```
470
499
 
@@ -476,7 +505,7 @@ Mind the "or its parent": for a `test/e2e/<name>/test/index.test.ts` the fixture
476
505
  lives a directory _above_ the test file, and screening only the test file's own
477
506
  directory quietly misses its `middleware.js`.
478
507
 
479
- Six more screens are worth running before spending a deploy on a candidate, all
508
+ Seven more screens are worth running before spending a deploy on a candidate, all
480
509
  against the test file rather than the fixture:
481
510
 
482
511
  - `skipDeployment: true` in the `nextTestSetup` call — next.js replaces the whole
@@ -484,6 +513,13 @@ against the test file rather than the fixture:
484
513
  early-returns (`test/lib/e2e-utils/index.ts`). The costliest screen to have been
485
514
  missing: it accounts for a third of what was previously counted as the candidate
486
515
  pool, and two files reached `rules.include` on the strength of a 4s "pass".
516
+ - `// @force-gate <expr>` on every top-level `describe`, where `<expr>` is false
517
+ in deploy mode — next.js 16.4's spelling of the same exclusion, which replaced
518
+ most `skipDeployment: true` calls. A gated-out suite is skipped, so a file whose
519
+ every suite is gated out "passes" having deployed nothing. `screen.mjs`
520
+ evaluates the expression with the harness's own conditions (`deploy`,
521
+ `adapter`, `turbopack`); one it cannot know up front (`cacheComponents`) never
522
+ disqualifies, and a file with one suite left still deploys it.
487
523
  - `describe.skip` / `(isNextDev ? describe : describe.skip)` — a file skipped
488
524
  upstream reports as passing in a few seconds without deploying anything, and
489
525
  adding it to `rules.include` claims coverage that does not exist. This screen
@@ -521,7 +557,8 @@ pass.
521
557
  each screen, and how many are already included. Regenerate it with `--write`
522
558
  whenever `next` is upgraded; `--check` exits nonzero if it has drifted, which is
523
559
  the only thing that keeps those numbers worth quoting. The workflow runs it in
524
- shard 1 of every run against the default next.js ref.
560
+ the first job of every run against the default next.js ref (Global Functions
561
+ shard 1 in an `all` run).
525
562
 
526
563
  Passing every screen makes a file a _candidate_, not a pass. It still has to
527
564
  be deployed and watched, and anything that fails gets root-caused and given a
@@ -17,7 +17,8 @@
17
17
  *
18
18
  * - **edge / middleware** — cdk-nextjs throws during `next build` for any
19
19
  * non-`nodejs` runtime (`assertNodeRuntimes`), so the fixture is unbuildable
20
- * and no per-case `failed` entry can rescue it.
20
+ * and no per-case `failed` entry can rescue it. A `middleware.*` counts as
21
+ * edge, its default, unless it opts into `runtime: "nodejs"`.
21
22
  * - **skipped-upstream** — `describe.skip`, so the file reports "passing" in
22
23
  * seconds without deploying anything. Adding it claims coverage that does not
23
24
  * exist. This one over-reports, because the match is textual and some skips are
@@ -30,6 +31,10 @@
30
31
  * The costliest screen to have been missing — it covers 236 of what were
31
32
  * otherwise counted as candidates, and two files had already been added to
32
33
  * `rules.include` on the strength of a 4s "pass".
34
+ * - **force-gate** — next.js 16.4's spelling of `skipDeployment`:
35
+ * `// @force-gate !deploy` on a `describe`, which skips the suite. Only a file
36
+ * whose every top-level suite is gated out in deploy mode is disqualified,
37
+ * since one that keeps a suite still deploys it.
33
38
  * - **mode-gated** — the third spelling of the same trap, and the one that cost a
34
39
  * deploy slot to find: the file's real cases sit inside `if (isNextStart)` or
35
40
  * `if (isNextDev)`, and every other mode gets an empty
@@ -75,6 +80,11 @@ const TEST_RE = /\.test\.[tj]sx?$/;
75
80
  * first undercounts by ~17 files.
76
81
  */
77
82
  const EDGE_RUNTIME_RE = /runtime\s*[:=]\s*['"](experimental-)?edge['"]/;
83
+ /** A middleware file's opt-in to the Node runtime: `config = { runtime: "nodejs" }`. */
84
+ const NODE_RUNTIME_RE = /runtime\s*:\s*['"]nodejs['"]/;
85
+ /** `source` less its comments, so a commented-out opt-in doesn't count. */
86
+ // A block comment only where a line starts one: `/*` is common inside a matcher.
87
+ const withoutComments = (source) => source.replace(/^\s*\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm, "$1");
78
88
  /**
79
89
  * An empty `it('should skip …', () => {})` — next.js's stub for the modes a
80
90
  * mode-gated file does not cover. `dev` in the title means dev is the excluded
@@ -83,6 +93,124 @@ const EDGE_RUNTIME_RE = /runtime\s*[:=]\s*['"](experimental-)?edge['"]/;
83
93
  const MODE_GATED_STUB_RE =
84
94
  /it\(\s*(['"`])should skip (?!.*\bdev\b)[^'"`]*\1\s*,\s*\(\s*\)\s*=>\s*\{\s*\}\s*\)/;
85
95
 
96
+ /**
97
+ * The `test/lib/gate/conditions.ts` values the harness runs under:
98
+ * `NEXT_TEST_MODE=deploy`, `IS_TURBOPACK_TEST=1` and `NEXT_ENABLE_ADAPTER=1`
99
+ * (`.github/workflows/e2e-harness.yml`), and none of the other env flags. A
100
+ * condition missing here (`cacheComponents`, read off the fixture's config) is
101
+ * unknown, which never disqualifies.
102
+ */
103
+ const DEPLOY_GATE_CONDITIONS = {
104
+ mode: "deploy",
105
+ dev: false,
106
+ start: false,
107
+ deploy: true,
108
+ prod: true,
109
+ prefetching: true,
110
+ bundler: "turbopack",
111
+ turbopack: true,
112
+ rspack: false,
113
+ webpack: false,
114
+ adapter: true,
115
+ nodeMiddleware: false,
116
+ standaloneOutput: false,
117
+ turbopackDev: false,
118
+ turbopackBuild: false,
119
+ react18: false,
120
+ wasm: false,
121
+ FIXME: false,
122
+ TODO: false,
123
+ };
124
+
125
+ /**
126
+ * A `@force-gate` expression (`test/lib/gate/expr.ts`'s grammar: `!`, `&&`,
127
+ * `||`, `==`/`!=`/`===`/`!==`, parentheses, names, quoted strings, booleans)
128
+ * under {@link DEPLOY_GATE_CONDITIONS}: `true`, `false`, or `undefined` when it
129
+ * turns on a condition that isn't known up front.
130
+ */
131
+ function evaluateGate(source) {
132
+ const tokens = source.match(/&&|\|\||[!=]==?|[!()]|'[^']*'|"[^"]*"|[A-Za-z_$][\w$]*/g) ?? [];
133
+ let i = 0;
134
+ // Condition values are coerced by truthiness, as next.js does (`mode` is a string).
135
+ const truth = (value) => (value === undefined ? undefined : Boolean(value));
136
+ const and = (a, b) => {
137
+ const [x, y] = [truth(a), truth(b)];
138
+ return x === false || y === false ? false : x && y ? true : undefined;
139
+ };
140
+ const or = (a, b) => {
141
+ const [x, y] = [truth(a), truth(b)];
142
+ return x === true || y === true ? true : x === false && y === false ? false : undefined;
143
+ };
144
+ const primary = () => {
145
+ const token = tokens[i++];
146
+ if (token === "(") {
147
+ const value = expression();
148
+ i++; // ")"
149
+ return value;
150
+ }
151
+ if (token === "!") {
152
+ const value = primary();
153
+ return value === undefined ? undefined : !value;
154
+ }
155
+ if (token === "true" || token === "false") return token === "true";
156
+ if (/^['"]/.test(token)) return token.slice(1, -1);
157
+ return Object.hasOwn(DEPLOY_GATE_CONDITIONS, token) ? DEPLOY_GATE_CONDITIONS[token] : undefined;
158
+ };
159
+ const binary = () => {
160
+ let left = primary();
161
+ while (/^[!=]==?$/.test(tokens[i] ?? "")) {
162
+ const negate = tokens[i++].startsWith("!");
163
+ const right = primary();
164
+ left = left === undefined || right === undefined ? undefined : (left === right) !== negate;
165
+ }
166
+ return left;
167
+ };
168
+ // `&&` and `||` share one precedence level, left to right, as in expr.ts.
169
+ const expression = () => {
170
+ let left = binary();
171
+ while (tokens[i] === "&&" || tokens[i] === "||") {
172
+ const op = tokens[i++];
173
+ const right = binary();
174
+ left = op === "&&" ? and(left, right) : or(left, right);
175
+ }
176
+ return left;
177
+ };
178
+ return truth(expression());
179
+ }
180
+
181
+ /**
182
+ * Whether `@force-gate` pragmas skip every top-level suite in `text` under
183
+ * deploy mode. next.js 16.4 moved deploy exclusions from
184
+ * `nextTestSetup({ skipDeployment: true })` to `// @force-gate !deploy` on the
185
+ * suite, and a gated-out suite is skipped rather than failed: a file whose every
186
+ * suite is gated out reports a pass having deployed nothing, the
187
+ * `skipDeployment` trap again. A file with one suite left still deploys it, so
188
+ * it stays a candidate.
189
+ */
190
+ function forceGatedOutOfDeploy(text) {
191
+ const lines = text.split("\n");
192
+ // The outermost suites, a top-level `it`/`test` among them: column 0 usually,
193
+ // but some files declare theirs inside a loop (`filesystem-cache` does, in a
194
+ // `for (const cacheEnabled of …)`). A `.skip` or `.todo` never runs, so it is
195
+ // no suite left to deploy.
196
+ const indentOf = (line) => /^(\s*)(?:describe|it|test)\b(?!\.(?:skip|todo)\b)/.exec(line)?.[1].length;
197
+ const outer = Math.min(...lines.flatMap((line) => indentOf(line) ?? []));
198
+ let suites = 0;
199
+ let gatedOut = 0;
200
+ for (let index = 0; index < lines.length; index++) {
201
+ if (indentOf(lines[index]) !== outer) continue;
202
+ suites++;
203
+ for (let above = index - 1; above >= 0 && /^\s*\/\//.test(lines[above]); above--) {
204
+ const pragma = /^\s*\/\/\s*@force-gate\s+(.+?)\s*$/.exec(lines[above]);
205
+ if (pragma && evaluateGate(pragma[1]) === false) {
206
+ gatedOut++;
207
+ break;
208
+ }
209
+ }
210
+ }
211
+ return suites > 0 && gatedOut === suites;
212
+ }
213
+
86
214
  const args = process.argv.slice(2);
87
215
  const flag = (name) => args.includes(`--${name}`);
88
216
  const value = (name) => {
@@ -175,7 +303,15 @@ function screen(testFile, decided = []) {
175
303
  const fixture = walk(fixtureRoot(testFile));
176
304
  const sources = fixture.filter((p) => SOURCE_RE.test(p));
177
305
 
178
- if (fixture.some((p) => /\/middleware\.[tj]sx?$/.test(p))) {
306
+ // `middleware.*` builds for the edge runtime unless it exports
307
+ // `config = { runtime: "nodejs" }`, which cdk-nextjs deploys like `proxy.*`.
308
+ if (
309
+ fixture.some(
310
+ (p) =>
311
+ /\/middleware\.[tj]sx?$/.test(p) &&
312
+ !NODE_RUNTIME_RE.test(withoutComments(read(p))),
313
+ )
314
+ ) {
179
315
  reasons.push("middleware");
180
316
  }
181
317
  if (sources.some((p) => EDGE_RUNTIME_RE.test(read(p)))) reasons.push("edge");
@@ -189,13 +325,18 @@ function screen(testFile, decided = []) {
189
325
  // early-returns, and jest reports it as passing in ~4s having deployed
190
326
  // nothing. next.js declaring a file out of scope for deploy mode is the same
191
327
  // signal as `isNextDeploy`, just spelled in the setup call.
192
- // Read through relative imports for this one screen only. `skipDeployment: true`
193
- // in a shared helper replaces every file that calls it, so following the import
194
- // cannot over-report; the mode gates above can, since a helper's `isNextDev`
195
- // branch may cover only some of a file's cases.
196
- if (/skipDeployment:\s*true/.test(testSourceText(testFile))) {
328
+ // Read through relative imports for this screen and the `@force-gate` one.
329
+ // `skipDeployment: true` in a shared helper replaces every file that calls it,
330
+ // so following the import cannot over-report; the mode gates above can, since a
331
+ // helper's `isNextDev` branch may cover only some of a file's cases. A helper's
332
+ // suites are the file's suites, so `@force-gate` counts them too.
333
+ const sourceText = testSourceText(testFile);
334
+ if (/skipDeployment:\s*true/.test(sourceText)) {
197
335
  reasons.push("skipDeployment");
198
336
  }
337
+ if (forceGatedOutOfDeploy(sourceText)) {
338
+ reasons.push("force-gate");
339
+ }
199
340
  // `test/e2e/**/test-template/{{ toFileName name }}/…` is a scaffold for
200
341
  // `pnpm new-test`, not a test.
201
342
  if (testFile.includes("{{")) reasons.push("scaffold");
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Installs Playwright's system packages (`playwright install-deps chromium`)
4
+ # with apt network timeouts and bounded retries. CI only: it runs apt as root
5
+ # and writes to /etc/apt/apt.conf.d. Run it from the directory whose
6
+ # node_modules has Playwright, then install the browser itself unprivileged
7
+ # with `playwright install chromium`.
8
+ #
9
+ # apt has no overall deadline: a mirror that stops answering partway through a
10
+ # fetch can hang `apt-get update` indefinitely. On 2026-10-07 two harness
11
+ # shards hung right after fetching noble-security InRelease, one for two hours.
12
+ # Each attempt is capped and retried instead.
13
+ #
14
+ # The cap runs inside sudo, not around it. sudo starts its command in a new
15
+ # session, so a `timeout` outside it kills sudo but not the apt-get under it,
16
+ # which keeps holding /var/lib/apt/lists/lock and fails every retry on it.
17
+ #
18
+ # Usage: scripts/playwright-install-deps.sh
19
+
20
+ set -euo pipefail
21
+
22
+ ATTEMPTS="${PLAYWRIGHT_INSTALL_ATTEMPTS:-3}"
23
+ # Normal runs take 15 s to 4 min (2026-10-07).
24
+ ATTEMPT_TIMEOUT="${PLAYWRIGHT_INSTALL_TIMEOUT:-420}"
25
+
26
+ sudo tee /etc/apt/apt.conf.d/99cdk-nextjs-timeouts > /dev/null << 'EOF'
27
+ Acquire::Retries "3";
28
+ Acquire::http::Timeout "30";
29
+ Acquire::https::Timeout "30";
30
+ EOF
31
+
32
+ for attempt in $(seq 1 "$ATTEMPTS"); do
33
+ # sudo resets PATH; playwright's shim needs node.
34
+ if sudo env "PATH=$PATH" timeout --kill-after=30 "$ATTEMPT_TIMEOUT" \
35
+ ./node_modules/.bin/playwright install-deps chromium; then
36
+ exit 0
37
+ fi
38
+ echo "playwright-install-deps: attempt $attempt/$ATTEMPTS failed or exceeded ${ATTEMPT_TIMEOUT}s" >&2
39
+ done
40
+ exit 1