@uniflowed/router 0.2.0 → 0.4.0

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.
package/testing.js ADDED
@@ -0,0 +1,980 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/router/testing`: Server Components and server actions, under test.
4
+ //
5
+ // Three levels, and each one is the real code path at that level rather than a
6
+ // second implementation of it that a test could pass against while production
7
+ // failed:
8
+ //
9
+ // 1. **A request scope** — [`withRequest`]. A server function, a data
10
+ // function or an async Server Component called as a function reads
11
+ // `cookies()`, `headers()` and `after()` from the request it is inside.
12
+ // This is the request lifecycle every host begins (`beginRequest`), with a
13
+ // `Request` the test describes, settled when the body returns so that an
14
+ // `after()` callback has run by the time the test asserts.
15
+ // 2. **A server action through its endpoint** — [`callAction`] and
16
+ // [`serverReferences`]. The arguments are encoded by the encoder the
17
+ // browser's reference uses, posted to the dispatcher every host runs, and
18
+ // the answer decoded by the decoder the browser uses. So an argument that
19
+ // cannot cross fails at the call site exactly as it does in a browser, an
20
+ // action that throws is the fixed `500` a browser gets, and what came back
21
+ // is what JSON carried rather than the object the function returned.
22
+ // 3. **A production build, in process** — [`buildApp`] and [`openBuild`].
23
+ // `uf build --adapter node` writes the application as a `Request` →
24
+ // `Response` function (`handler.js`); this loads it and answers requests
25
+ // the way `@uniflowed/server/node` does — the build's files first, then
26
+ // the application, inside the handler's own request lifecycle — with no
27
+ // socket and no port. HTML documents, Flight payloads, route handlers,
28
+ // redirects, error and not-found boundaries, the route cache and a form
29
+ // posted before hydration all come out of the build's real renderer.
30
+ //
31
+ // # Why these live in the router
32
+ //
33
+ // Each helper drives something only this package has: the action wire
34
+ // (`./internal/action-wire.js`), the action dispatcher
35
+ // (`./internal/action-endpoint.js`) and the routing errors a Server Component
36
+ // throws (`./internal/routing.js`). Written anywhere else they would either
37
+ // reach into this package's internals by path, or copy them — and a copy of
38
+ // the wire grammar is exactly the thing that agrees with the real one until it
39
+ // does not. The router is also published, so a project can use these today.
40
+ //
41
+ // # What is deliberately not here
42
+ //
43
+ // - **Rendering one Server Component to Flight in the test's own process.**
44
+ // React's Flight renderer only loads where `react` resolved under the
45
+ // `react-server` condition, and a test worker resolves the other React for
46
+ // everything else it runs. A render that faked the boundary — calling an
47
+ // async component and rendering its element with `react-dom/server` — would
48
+ // let a function prop cross into a client component, which Flight refuses,
49
+ // and would pass. [`openBuild`] renders through the real graphs instead, and
50
+ // `createTestApp` in `@uniflowed/test/app` does the same through `uf dev`.
51
+ // - **A mock of the endpoint.** `callAction` runs the dispatcher itself. What
52
+ // it substitutes is the manifest's id — a test calls a function it imported,
53
+ // not an id a build minted — and nothing else.
54
+ // - **Following a redirect.** Every answer is returned as the endpoint or the
55
+ // build wrote it, `303` and `Location` included, so a test can assert on it.
56
+
57
+ import { type CacheOptions, CacheStore } from "@uniflowed/server/cache";
58
+ import { beginRequest } from "@uniflowed/server/host";
59
+
60
+ import { ServerActionError } from "./action.js";
61
+ import { createActionDispatcher } from "./internal/action-endpoint.js";
62
+ import {
63
+ ACTION_CONTENT_TYPE,
64
+ ACTION_HEADER,
65
+ type ActionArgument,
66
+ ActionValueError,
67
+ decodeActionResult,
68
+ encodeActionArguments,
69
+ encodeActionResult,
70
+ } from "./internal/action-wire.js";
71
+ import {
72
+ FORM_ACTION_CONTENT_TYPE,
73
+ FORM_BOUND_FIELD,
74
+ FORM_ID_FIELD,
75
+ FORM_REF_FIELD,
76
+ withFormAction,
77
+ } from "./internal/form-action.js";
78
+ import {
79
+ ForbiddenError,
80
+ NotFoundError,
81
+ RedirectError,
82
+ UnauthorizedError,
83
+ } from "./internal/routing.js";
84
+
85
+ /**
86
+ * The origin a test request is addressed to when it names only a path.
87
+ *
88
+ * `localhost` rather than a real name, so nothing a test writes can be mistaken
89
+ * for a request to somebody's site, and plain `http` because nothing here opens
90
+ * a connection.
91
+ */
92
+ export const TEST_ORIGIN = "http://localhost";
93
+
94
+ /**
95
+ * The id every action `callAction` calls is filed under.
96
+ *
97
+ * A real id is `HMAC-SHA256(build id, module ‖ export ‖ kind)` and only a build
98
+ * can mint one. A test holds the function rather than an id, so it is filed in
99
+ * a one-row table under this canonical 64-hex-digit id, and the dispatcher's
100
+ * lookup, comparison and refusals run unchanged. It reads as `test action` in
101
+ * hexspeak so that it is recognisable in a log line.
102
+ */
103
+ export const TEST_ACTION_ID: string = "7e57ac7107e57ac7".repeat(4);
104
+
105
+ /**
106
+ * The request a test describes.
107
+ *
108
+ * Every field is optional; the empty description is a `GET` of
109
+ * `http://localhost/` with no cookies. `cookies` is written into the `Cookie`
110
+ * header (after any `cookie` in `headers`), so a test says which session it is
111
+ * without spelling the header.
112
+ */
113
+ export type TestRequestInit = {|
114
+ /** A path on [`TEST_ORIGIN`], or an absolute URL. Defaults to `/`. */
115
+ readonly url?: string,
116
+ readonly method?: string,
117
+ readonly headers?: { readonly [string]: string },
118
+ readonly cookies?: { readonly [string]: string },
119
+ readonly body?: string | URLSearchParams | FormData,
120
+ /**
121
+ * The cache the host would install for this request, which is what
122
+ * `revalidateTag`, `revalidatePath` and `cacheFunction` reach.
123
+ *
124
+ * Absent means none, which is what a project without `app.rendering.cache`
125
+ * gets — so an action that calls `revalidateTag` there fails here as it
126
+ * fails there. A bare `CacheStore` is installed with `route`, `fetch` and
127
+ * `data` all on.
128
+ */
129
+ readonly cache?: CacheStore | CacheOptions,
130
+ |};
131
+
132
+ /** What a request did to the cache it was answered with. */
133
+ export type Revalidations = {|
134
+ /** Every tag `revalidateTag` or `updateTag` expired, in call order. */
135
+ readonly tags: $ReadOnlyArray<string>,
136
+ /** Every path `revalidatePath` expired, in call order. */
137
+ readonly paths: $ReadOnlyArray<string>,
138
+ |};
139
+
140
+ /**
141
+ * Build the `Request` a [`TestRequestInit`] describes.
142
+ *
143
+ * Exported because a route handler or a middleware under test takes a
144
+ * `Request`, and building one by hand is where a missing `Host` or a
145
+ * misspelled cookie header comes from. `Host` is always set to the URL's own,
146
+ * since that is what every uf host compares `Origin` against.
147
+ */
148
+ export function testRequest(init?: TestRequestInit): Request {
149
+ const url = new URL(init?.url ?? "/", TEST_ORIGIN);
150
+ const headers = new Headers();
151
+ const named: { readonly [string]: string } = init?.headers ?? {};
152
+ for (const name of Object.keys(named)) {
153
+ headers.set(name, named[name]);
154
+ }
155
+ if (!headers.has("host")) {
156
+ headers.set("host", url.host);
157
+ }
158
+ const cookies = init?.cookies;
159
+ if (cookies != null) {
160
+ const pairs = Object.entries(cookies).map(
161
+ ([name, value]) => `${name}=${encodeURIComponent(String(value))}`,
162
+ );
163
+ const given = headers.get("cookie");
164
+ headers.set("cookie", [given, ...pairs].filter(Boolean).join("; "));
165
+ }
166
+ const method = (init?.method ?? "GET").toUpperCase();
167
+ return new Request(url.href, {
168
+ method,
169
+ headers,
170
+ body: method === "GET" || method === "HEAD" ? undefined : init?.body,
171
+ });
172
+ }
173
+
174
+ /**
175
+ * Run `body` inside a request, as a host would, and settle the request after.
176
+ *
177
+ * Inside, `cookies()`, `headers()`, `requestId()`, `draftMode()` and `after()`
178
+ * answer about `request`; `revalidateTag` and `cacheFunction` reach `cache`
179
+ * when one is given. When `body` has finished — returned, resolved or thrown —
180
+ * the request is settled, which is when a host runs what `after()` deferred,
181
+ * so a test can assert on an `after()` callback's effect as soon as this
182
+ * resolves.
183
+ *
184
+ * `body`'s value or rejection is passed through unchanged. That is the point
185
+ * for a Server Component: `notFound()` inside one rejects with the router's
186
+ * `NotFoundError`, which a test asserts with `rejects.toBeInstanceOf`.
187
+ *
188
+ * Two calls are two requests, however they interleave: the scope is the
189
+ * asynchronous call tree, which is what makes a test of request isolation
190
+ * meaningful here.
191
+ */
192
+ export async function withRequest<T>(
193
+ request: Request | TestRequestInit,
194
+ body: () => T | Promise<T>,
195
+ ): Promise<T> {
196
+ const cache = request instanceof Request ? undefined : request.cache;
197
+ const built = request instanceof Request ? request : testRequest(request);
198
+ return (await withLifecycle(built, cache, async () => body())).value;
199
+ }
200
+
201
+ /** How `callAction` sends a call: the way a hydrated page does, or a native form post. */
202
+ export type ActionDoor = "fetch" | "form";
203
+
204
+ /** What a test says about one call to a server action. */
205
+ export type ActionCallInit = {|
206
+ /**
207
+ * The page the call is made from — the URL the browser posts to. Defaults
208
+ * to `/`. A path guard's middleware is not run: an action authorizes
209
+ * itself, which is what a test of one should prove.
210
+ */
211
+ readonly url?: string,
212
+ /** Headers beyond the ones the browser's reference sends. Override to test a refusal. */
213
+ readonly headers?: { readonly [string]: string },
214
+ readonly cookies?: { readonly [string]: string },
215
+ readonly cache?: CacheStore | CacheOptions,
216
+ /**
217
+ * Which door the call comes through.
218
+ *
219
+ * `"fetch"`, the default, is a hydrated page: `POST`, `uf-action`, JSON.
220
+ * `"form"` is a form submitted before the page hydrated: a urlencoded post
221
+ * whose last argument is the form, answered with a `303` rather than JSON.
222
+ * The two answer a `redirect()` differently, and that is the reason to be
223
+ * able to ask for either.
224
+ */
225
+ readonly door?: ActionDoor,
226
+ /** `module#export`, for the `ServerActionError` a failed reference throws. */
227
+ readonly name?: string,
228
+ |};
229
+
230
+ /**
231
+ * What one call to a server action came to.
232
+ *
233
+ * `kind` is what the *action* did. `response` is what the endpoint answered,
234
+ * which is what a browser gets, and the two doors answer the same kind
235
+ * differently: `redirect()` is a `204` carrying `Location` and the
236
+ * `uf-action-outcome` header through `"fetch"`, and a `303` through `"form"`;
237
+ * `notFound()`, `unauthorized()` and `forbidden()` are their page statuses
238
+ * through `"fetch"`. Assert on both when both matter.
239
+ *
240
+ * Every outcome has been settled — `after()` callbacks have run — and carries
241
+ * what the call did to the cache in `revalidated`.
242
+ */
243
+ export type ActionOutcome<R> =
244
+ | {|
245
+ readonly kind: "returned",
246
+ /**
247
+ * Through the `"fetch"` door, the value as the browser decodes it from
248
+ * the answer — so `undefined` inside an object is gone and an object is
249
+ * a new one. Through `"form"`, the value the action returned, which a
250
+ * browser never sees.
251
+ */
252
+ readonly value: R,
253
+ readonly response: Response,
254
+ readonly revalidated: Revalidations,
255
+ |}
256
+ | {|
257
+ readonly kind: "redirect",
258
+ readonly to: string,
259
+ readonly permanent: boolean,
260
+ readonly response: Response,
261
+ readonly revalidated: Revalidations,
262
+ |}
263
+ | {|
264
+ readonly kind: "not-found",
265
+ readonly response: Response,
266
+ readonly revalidated: Revalidations,
267
+ |}
268
+ | {|
269
+ readonly kind: "unauthorized",
270
+ readonly response: Response,
271
+ readonly revalidated: Revalidations,
272
+ |}
273
+ | {|
274
+ readonly kind: "forbidden",
275
+ readonly response: Response,
276
+ readonly revalidated: Revalidations,
277
+ |}
278
+ | {|
279
+ readonly kind: "threw",
280
+ readonly error: mixed,
281
+ readonly response: Response,
282
+ readonly revalidated: Revalidations,
283
+ |}
284
+ | {|
285
+ /**
286
+ * The action returned a value the wire cannot carry back — a `Date`, a
287
+ * `Map`, a class instance. `error` is the `ActionValueError` naming
288
+ * where it was; the browser got a `500`.
289
+ */
290
+ readonly kind: "unsendable",
291
+ readonly error: ActionValueError,
292
+ readonly response: Response,
293
+ readonly revalidated: Revalidations,
294
+ |}
295
+ | {|
296
+ /**
297
+ * The endpoint refused the request before running anything: a guard
298
+ * the test's `headers` overrode, or a payload over a limit only the
299
+ * decoder counts. `response.status` says which.
300
+ */
301
+ readonly kind: "refused",
302
+ readonly response: Response,
303
+ readonly revalidated: Revalidations,
304
+ |};
305
+
306
+ /**
307
+ * Call a server action the way a browser does, through the endpoint that
308
+ * answers one, and say what happened.
309
+ *
310
+ * The arguments are encoded by the browser's own encoder first, so an argument
311
+ * that cannot cross — a `Date`, a function, a `File`, a cycle — rejects this
312
+ * call with an `ActionValueError` before anything is sent, which is where a
313
+ * browser throws it too. Then the call is posted to the dispatcher every uf
314
+ * host runs, inside a request with the test's cookies and headers and the
315
+ * page's own `Origin` and `Host`, and the answer is classified into an
316
+ * [`ActionOutcome`].
317
+ *
318
+ * The function is filed under [`TEST_ACTION_ID`] in a table of one, which is
319
+ * the only thing substituted. Middleware is not run: the endpoint runs below
320
+ * it in every host, and an action that relies on a path guard is an action
321
+ * with a hole in it (see the server actions guide).
322
+ *
323
+ * Through the `"form"` door the last argument must be the `FormData`, and the
324
+ * ones before it are the bound arguments a form carries in its hidden fields
325
+ * — `useActionState`'s previous state, for one. That door answers with a
326
+ * `303`; the page rendered again with a `useActionState` result needs the
327
+ * whole application, which is [`openBuild`]'s `submit`.
328
+ */
329
+ export async function callAction<Args extends $ReadOnlyArray<ActionArgument>, R>(
330
+ action: (...args: Args) => Promise<R>,
331
+ args: Args,
332
+ init?: ActionCallInit,
333
+ ): Promise<ActionOutcome<R>> {
334
+ const door = init?.door ?? "fetch";
335
+ const request = door === "form" ? formActionRequest(args, init) : fetchActionRequest(args, init);
336
+
337
+ // What the function itself did, seen before the endpoint turns it into a
338
+ // status. The endpoint answers every failure identically on purpose; a test
339
+ // is the one caller entitled to know which failure it was.
340
+ let seen:
341
+ | {| readonly returned: true, readonly value: mixed |}
342
+ | {| readonly returned: false, readonly error: mixed |}
343
+ | null = null;
344
+ const observed = async (...received: Args): Promise<R> => {
345
+ try {
346
+ const value = await action(...received);
347
+ seen = { returned: true, value };
348
+ return value;
349
+ } catch (error) {
350
+ seen = { returned: false, error };
351
+ throw error;
352
+ }
353
+ };
354
+ const dispatch = createActionDispatcher({
355
+ actions: [
356
+ {
357
+ id: TEST_ACTION_ID,
358
+ module: init?.name ?? "server action under test",
359
+ export: "action",
360
+ load: async () => ({ action: observed }),
361
+ },
362
+ ],
363
+ });
364
+
365
+ const answered = await withLifecycle(request, init?.cache, () => dispatch(request));
366
+ const { revalidated } = answered;
367
+ const response = answered.value;
368
+ if (response == null) {
369
+ // The dispatcher declines only a request that names no action, and both
370
+ // requests built above name one. Reaching this is this module's bug.
371
+ throw new Error("@uniflowed/router/testing: the action endpoint declined its own request");
372
+ }
373
+
374
+ const outcome = seen;
375
+ if (outcome == null) {
376
+ return { kind: "refused", response, revalidated };
377
+ }
378
+ if (outcome.returned === false) {
379
+ const error = outcome.error;
380
+ if (error instanceof RedirectError) {
381
+ return { kind: "redirect", to: error.to, permanent: error.permanent, response, revalidated };
382
+ }
383
+ if (error instanceof NotFoundError) return { kind: "not-found", response, revalidated };
384
+ if (error instanceof UnauthorizedError) return { kind: "unauthorized", response, revalidated };
385
+ if (error instanceof ForbiddenError) return { kind: "forbidden", response, revalidated };
386
+ return { kind: "threw", error, response, revalidated };
387
+ }
388
+ if (door === "fetch") {
389
+ if (response.status !== 200) {
390
+ // It returned and the endpoint still answered 500: the result could not
391
+ // be encoded. Encode it again for the test, to hand back the reason.
392
+ return { kind: "unsendable", error: unsendable(outcome.value), response, revalidated };
393
+ }
394
+ // The decoded answer is structurally the `R` the action returned: the
395
+ // endpoint held that value to the grammar before encoding it, and the
396
+ // decoder checked it again. The cast is the one place that says so.
397
+ const value: R = decodeActionResult(await response.clone().text()) as $FlowFixMe;
398
+ return { kind: "returned", value, response, revalidated };
399
+ }
400
+ if (response.status === 500) {
401
+ return { kind: "unsendable", error: unsendable(outcome.value), response, revalidated };
402
+ }
403
+ const value: R = outcome.value as $FlowFixMe;
404
+ return { kind: "returned", value, response, revalidated };
405
+ }
406
+
407
+ /**
408
+ * Stand-ins for a `"use server"` module's exports that call through the wire.
409
+ *
410
+ * For a client component under test. `uf test` imports a `"use server"`
411
+ * module as the module itself — there is no bundler replacing it with
412
+ * references — so a component that calls one would otherwise call the
413
+ * function directly, outside any request, with arguments nothing encoded.
414
+ * Handing these to `uft.mock` instead makes each call what it is in a
415
+ * browser: encoded, posted to the endpoint, run inside a request carrying
416
+ * `init`'s cookies, and decoded. A routing call is followed as the browser's
417
+ * reference follows it (a redirect resolves with nothing; `notFound()`,
418
+ * `unauthorized()` and `forbidden()` reject with the router's error), and any
419
+ * other failure rejects with the `ServerActionError` a browser's reference throws.
420
+ *
421
+ * `init` may be a function, read on every call, so a test can change who is
422
+ * signed in between two calls. Every non-function export is passed through.
423
+ */
424
+ export function serverReferences<M extends { readonly [string]: mixed }>(
425
+ module: M,
426
+ init?: ActionCallInit | (() => ActionCallInit),
427
+ ): M {
428
+ const references: { [string]: mixed } = {};
429
+ for (const [name, exported] of Object.entries(module)) {
430
+ if (typeof exported !== "function") {
431
+ references[name] = exported;
432
+ continue;
433
+ }
434
+ // A `"use server"` export is `async (...ActionArgument) => …` by the RSC
435
+ // graph's contract; `Object.entries` cannot carry that type, so it is
436
+ // restated for the one call below.
437
+ const action: (...args: $ReadOnlyArray<ActionArgument>) => Promise<mixed> =
438
+ exported as $FlowFixMe;
439
+ const reference = async (...args: $ReadOnlyArray<ActionArgument>): Promise<mixed> => {
440
+ const given: ActionCallInit = (typeof init === "function" ? init() : init) ?? {};
441
+ const label = given.name ?? name;
442
+ const outcome = await callAction(action, args, {
443
+ url: given.url,
444
+ headers: given.headers,
445
+ cookies: given.cookies,
446
+ cache: given.cache,
447
+ name: label,
448
+ door: "fetch",
449
+ });
450
+ // What the browser's reference does with each outcome: a redirect
451
+ // resolves with nothing once it has navigated (there is no navigation
452
+ // here, so it resolves at once), and the other three routing calls
453
+ // reject with the error the server caught, for the route's boundary.
454
+ if (outcome.kind === "returned") {
455
+ return outcome.value;
456
+ }
457
+ if (outcome.kind === "redirect") {
458
+ return undefined;
459
+ }
460
+ if (outcome.kind === "not-found") throw new NotFoundError();
461
+ if (outcome.kind === "unauthorized") throw new UnauthorizedError();
462
+ if (outcome.kind === "forbidden") throw new ForbiddenError();
463
+ throw new ServerActionError(label, outcome.response.status);
464
+ };
465
+ // The same `$$FORM_ACTION` the browser's reference carries, so a form
466
+ // bound to one renders as the real form would.
467
+ references[name] = withFormAction(reference, TEST_ACTION_ID, []);
468
+ }
469
+ // Same keys, and every function replaced by one with its signature: `M`.
470
+ return references as $FlowFixMe;
471
+ }
472
+
473
+ /** A request to the JSON door, exactly as `createServerReference` sends one. */
474
+ function fetchActionRequest(args: $ReadOnlyArray<ActionArgument>, init?: ActionCallInit): Request {
475
+ const body = encodeActionArguments(args);
476
+ const url = new URL(init?.url ?? "/", TEST_ORIGIN);
477
+ const headers = merged(
478
+ { origin: url.origin, "content-type": ACTION_CONTENT_TYPE },
479
+ { [ACTION_HEADER]: TEST_ACTION_ID },
480
+ init?.headers,
481
+ );
482
+ return testRequest({
483
+ url: url.href,
484
+ method: "POST",
485
+ headers,
486
+ cookies: init?.cookies,
487
+ body,
488
+ });
489
+ }
490
+
491
+ /**
492
+ * A request to the native-form door, exactly as a browser submits the form
493
+ * React wrote from `$$FORM_ACTION` before the page hydrated.
494
+ */
495
+ function formActionRequest(args: $ReadOnlyArray<ActionArgument>, init?: ActionCallInit): Request {
496
+ const form = args[args.length - 1];
497
+ if (!(form instanceof FormData)) {
498
+ throw new TypeError(
499
+ '@uniflowed/router/testing: a call through the "form" door ends with the FormData ' +
500
+ "the form submits; the arguments before it are the ones the form carries bound.",
501
+ );
502
+ }
503
+ const fields = new URLSearchParams();
504
+ fields.append(`${FORM_REF_FIELD}0`, "");
505
+ fields.append(`${FORM_ID_FIELD}0`, TEST_ACTION_ID);
506
+ const bound = args.slice(0, -1);
507
+ if (bound.length > 0) {
508
+ fields.append(`${FORM_BOUND_FIELD}0`, encodeActionArguments(bound));
509
+ }
510
+ for (const [name, value] of form.entries()) {
511
+ if (typeof value !== "string") {
512
+ // What the browser-side encoder says about a file, said at the same place.
513
+ throw new ActionValueError(`the form field ${JSON.stringify(name)}`, "is a file");
514
+ }
515
+ fields.append(name, value);
516
+ }
517
+ const url = new URL(init?.url ?? "/", TEST_ORIGIN);
518
+ return testRequest({
519
+ url: url.href,
520
+ method: "POST",
521
+ headers: merged(
522
+ {
523
+ origin: url.origin,
524
+ "sec-fetch-site": "same-origin",
525
+ "content-type": FORM_ACTION_CONTENT_TYPE,
526
+ },
527
+ init?.headers,
528
+ ),
529
+ cookies: init?.cookies,
530
+ body: fields.toString(),
531
+ });
532
+ }
533
+
534
+ /**
535
+ * Header maps combined left to right, a later one winning a name.
536
+ *
537
+ * A loop rather than a spread, because Flow cannot say what spreading two
538
+ * indexed objects produces, and the order is the contract: the test's own
539
+ * `headers` come last so that a test can override a guard on purpose.
540
+ */
541
+ function merged(...maps: $ReadOnlyArray<?{ readonly [string]: string }>): {
542
+ readonly [string]: string,
543
+ } {
544
+ const out: { [string]: string } = {};
545
+ for (const map of maps) {
546
+ const entries: { readonly [string]: string } = map ?? {};
547
+ for (const name of Object.keys(entries)) {
548
+ out[name.toLowerCase()] = entries[name];
549
+ }
550
+ }
551
+ return out;
552
+ }
553
+
554
+ /** Why a value an action returned could not be its answer. */
555
+ function unsendable(value: mixed): ActionValueError {
556
+ try {
557
+ encodeActionResult(value);
558
+ } catch (error) {
559
+ if (error instanceof ActionValueError) {
560
+ return error;
561
+ }
562
+ }
563
+ return new ActionValueError("the result", "could not be encoded");
564
+ }
565
+
566
+ /**
567
+ * One request through `beginRequest`, with the test's cache installed and
568
+ * watched, settled when `body` is done.
569
+ *
570
+ * The cache is installed on the context before `run`, which is the moment
571
+ * `createFetchHandler` installs a host's — before the guard and the dispatcher,
572
+ * so a mutation reaches the store that answered the request.
573
+ */
574
+ async function withLifecycle<T>(
575
+ request: Request,
576
+ cache: CacheStore | CacheOptions | void,
577
+ body: () => Promise<T>,
578
+ ): Promise<{| readonly value: T, readonly revalidated: Revalidations |}> {
579
+ const lifecycle = beginRequest(request);
580
+ const tags: Array<string> = [];
581
+ const paths: Array<string> = [];
582
+ const installed: CacheOptions | null =
583
+ cache == null
584
+ ? null
585
+ : cache instanceof CacheStore
586
+ ? { store: cache, route: true, fetch: true, data: true }
587
+ : cache;
588
+ const restore = installed == null ? () => {} : watchInvalidations(installed.store, tags, paths);
589
+ lifecycle.context.cache = installed;
590
+ try {
591
+ const value = await lifecycle.run(body);
592
+ return { value, revalidated: { tags, paths } };
593
+ } finally {
594
+ await lifecycle.settle();
595
+ restore();
596
+ }
597
+ }
598
+
599
+ /**
600
+ * Record every tag and path `store` is asked to expire until the returned
601
+ * function is called.
602
+ *
603
+ * On the instance, and put back afterwards, because the store is the test's
604
+ * own object and the only other way to see an invalidation — its statistics —
605
+ * counts them without naming them.
606
+ */
607
+ function watchInvalidations(
608
+ store: CacheStore,
609
+ tags: Array<string>,
610
+ paths: Array<string>,
611
+ ): () => void {
612
+ // Taken off the instance and called with it below, which is the binding the
613
+ // lint rule is protecting; the method never runs unbound.
614
+ // $FlowFixMe[method-unbinding]
615
+ const byTag = store.revalidateTag;
616
+ // $FlowFixMe[method-unbinding]
617
+ const byPath = store.revalidatePath;
618
+ // $FlowFixMe[cannot-write] the instance's own property shadows the method for this request only.
619
+ store.revalidateTag = (tag: string): number => {
620
+ tags.push(tag);
621
+ return byTag.call(store, tag);
622
+ };
623
+ // $FlowFixMe[cannot-write] as above.
624
+ store.revalidatePath = (path: string): number => {
625
+ paths.push(path);
626
+ return byPath.call(store, path);
627
+ };
628
+ return () => {
629
+ // $FlowFixMe[cannot-write] removing the shadow puts the prototype's method back.
630
+ delete store.revalidateTag;
631
+ // $FlowFixMe[cannot-write] as above.
632
+ delete store.revalidatePath;
633
+ };
634
+ }
635
+
636
+ // ---------------------------------------------------------------------------
637
+ // A production build, in process
638
+ // ---------------------------------------------------------------------------
639
+
640
+ /** A request to a built application, described by what a test cares about. */
641
+ export type BuiltRequestInit = {|
642
+ readonly method?: string,
643
+ readonly headers?: { readonly [string]: string },
644
+ readonly cookies?: { readonly [string]: string },
645
+ readonly body?: string | URLSearchParams | FormData,
646
+ |};
647
+
648
+ /**
649
+ * A built application, answering requests in the test's own process.
650
+ *
651
+ * Every method takes a path on the application's origin and returns the
652
+ * `Response` the build wrote, unfollowed and still streaming.
653
+ */
654
+ export type BuiltApp = {|
655
+ /** The directory `handler.js` was loaded from. */
656
+ readonly directory: string,
657
+ /** Any request: a route handler, a static file, a `POST`. */
658
+ readonly fetch: (pathname: string, init?: BuiltRequestInit) => Promise<Response>,
659
+ /** A document, asked for the way a browser navigating to it asks. */
660
+ readonly render: (pathname: string, init?: BuiltRequestInit) => Promise<Response>,
661
+ /**
662
+ * The route's React Flight payload, as a client navigation fetches it.
663
+ * Rejects when the answer is not a Flight stream, with the status and the
664
+ * start of what came back instead.
665
+ */
666
+ readonly flight: (pathname: string, init?: BuiltRequestInit) => Promise<Response>,
667
+ /**
668
+ * Submit a form on the page at `pathname` before it hydrates.
669
+ *
670
+ * Renders the page, reads the `index`th `<form>`'s hidden fields — the ones
671
+ * React wrote for a server action: its build id, its bound arguments, the
672
+ * `useActionState` key — and posts them with `fields` as a browser without
673
+ * JavaScript would. So the action is dialled by the id the build minted,
674
+ * through the endpoint's second door, and a `useActionState` form comes back
675
+ * as the page rendered with the action's result.
676
+ */
677
+ readonly submit: (
678
+ pathname: string,
679
+ fields: { readonly [string]: string },
680
+ options?: {| readonly form?: number, readonly cookies?: { readonly [string]: string } |},
681
+ ) => Promise<Response>,
682
+ /** Resolves once every request answered so far has settled (`after()` has run). */
683
+ readonly settled: () => Promise<void>,
684
+ /**
685
+ * Every exception the application threw instead of answering, oldest first.
686
+ *
687
+ * A host answers one with a bare `500` and hands it to its error reporting;
688
+ * the `500` is what `fetch` returns, and this is the report, so a test can
689
+ * assert on why without the reason ever being in a response.
690
+ */
691
+ readonly errors: () => $ReadOnlyArray<mixed>,
692
+ |};
693
+
694
+ /** A loaded `handler.js`, checked field by field. */
695
+ type Handler = {|
696
+ readonly fetch: (request: Request) => Promise<Response>,
697
+ readonly beginRequest: (request: Request) => {|
698
+ readonly run: <T>(body: () => Promise<T>) => Promise<T>,
699
+ readonly settle: () => Promise<void>,
700
+ |},
701
+ readonly routing?: mixed,
702
+ |};
703
+
704
+ /**
705
+ * Open the application `uf build --adapter node` wrote to `directory`.
706
+ *
707
+ * `directory` holds `handler.js` and `static/` — `.uf/deploy/node` under the
708
+ * project. Requests are answered as `@uniflowed/server/node` answers them:
709
+ * `app.router`'s redirects and headers, then a file under `static/` for a
710
+ * `GET` or `HEAD`, then the application, all inside the lifecycle the
711
+ * handler's own `beginRequest` begins. A request is settled when its body has
712
+ * been read to the end or cancelled, which is when a Node host settles one.
713
+ *
714
+ * Nothing listens. That is what makes it usable where a test may not open a
715
+ * socket, and why two opened builds cannot collide on a port.
716
+ */
717
+ export async function openBuild(directory: string | URL): Promise<BuiltApp> {
718
+ const { fileURLToPath, pathToFileURL } = await import("node:url");
719
+ const path = await import("node:path");
720
+ const root = path.resolve(directory instanceof URL ? fileURLToPath(directory.href) : directory);
721
+ // The build's own module, whose path is only known at run time; Flow types
722
+ // only a literal specifier, and `asHandler` checks what came back.
723
+ // $FlowFixMe[unsupported-syntax]
724
+ const loaded: mixed = await import(pathToFileURL(path.join(root, "handler.js")).href);
725
+ const handler = asHandler(loaded, root);
726
+ const { createServeHandler } = await import("@uniflowed/server/node");
727
+ const serve = createServeHandler({
728
+ staticDir: path.join(root, "static"),
729
+ handle: handler.fetch,
730
+ // The build's own rules, whatever their shape; `createServeHandler` reads them.
731
+ routing: handler.routing as $FlowFixMe,
732
+ });
733
+ const pending: Set<Promise<void>> = new Set();
734
+ const thrown: Array<mixed> = [];
735
+
736
+ async function answer(request: Request): Promise<Response> {
737
+ const lifecycle = handler.beginRequest(request);
738
+ let response: Response;
739
+ try {
740
+ response = await lifecycle.run(() => serve(request));
741
+ } catch (error) {
742
+ // Obligation 6 of the adapter contract: a bare 500, nothing of the error.
743
+ // The error goes where a host's error reporting would put it: `errors()`.
744
+ thrown.push(error);
745
+ response = new Response("500 Internal Server Error\n", {
746
+ status: 500,
747
+ headers: { "content-type": "text/plain; charset=utf-8" },
748
+ });
749
+ }
750
+ let settle: () => void = () => {};
751
+ const done = new Promise<void>((resolve) => {
752
+ settle = () => {
753
+ lifecycle.settle().then(resolve, resolve);
754
+ };
755
+ });
756
+ pending.add(done);
757
+ void done.then(() => pending.delete(done));
758
+ const body = response.body;
759
+ if (body == null) {
760
+ settle();
761
+ return response;
762
+ }
763
+ // Settle when the test has read the body, or given up on it: the moment a
764
+ // Node host's response `finish`es. Earlier would run `after()` while a
765
+ // Suspense boundary is still streaming.
766
+ const reader = body.getReader();
767
+ const watched = new ReadableStream<Uint8Array>({
768
+ async pull(controller) {
769
+ const next = await reader.read();
770
+ if (next.done === true) {
771
+ controller.close();
772
+ settle();
773
+ } else {
774
+ controller.enqueue(next.value);
775
+ }
776
+ },
777
+ async cancel(reason) {
778
+ await reader.cancel(reason);
779
+ settle();
780
+ },
781
+ });
782
+ return new Response(watched, {
783
+ status: response.status,
784
+ statusText: response.statusText,
785
+ headers: response.headers,
786
+ });
787
+ }
788
+
789
+ function request(
790
+ pathname: string,
791
+ init?: BuiltRequestInit,
792
+ extra?: { readonly [string]: string },
793
+ ): Request {
794
+ if (!pathname.startsWith("/") || pathname.startsWith("//")) {
795
+ throw new TypeError(
796
+ "@uniflowed/router/testing: a built app is asked for a path, like `/notes`",
797
+ );
798
+ }
799
+ return testRequest({
800
+ url: pathname,
801
+ method: init?.method,
802
+ headers: merged(extra, init?.headers),
803
+ cookies: init?.cookies,
804
+ body: init?.body,
805
+ });
806
+ }
807
+
808
+ return {
809
+ directory: root,
810
+ fetch: (pathname, init) => answer(request(pathname, init)),
811
+ render: (pathname, init) => answer(request(pathname, init, { accept: "text/html" })),
812
+ flight: async (pathname, init) => {
813
+ const url = new URL(pathname, TEST_ORIGIN);
814
+ url.pathname = `${url.pathname.replace(/\/$/, "")}/__uf.flight`;
815
+ const response = await answer(request(`${url.pathname}${url.search}`, init));
816
+ if (!(response.headers.get("content-type") ?? "").includes("text/x-component")) {
817
+ const text = (await response.text()).slice(0, 200);
818
+ throw new Error(
819
+ `@uniflowed/router/testing: ${pathname} answered ${String(response.status)} ` +
820
+ `without a Flight payload: ${text}`,
821
+ );
822
+ }
823
+ return response;
824
+ },
825
+ submit: async (pathname, fields, options) => {
826
+ const page = await answer(
827
+ request(pathname, { cookies: options?.cookies }, { accept: "text/html" }),
828
+ );
829
+ const html = await page.text();
830
+ const form = formsIn(html)[options?.form ?? 0];
831
+ if (form == null) {
832
+ throw new Error(
833
+ `@uniflowed/router/testing: ${pathname} has no form number ${String(options?.form ?? 0)}`,
834
+ );
835
+ }
836
+ const body = new URLSearchParams();
837
+ for (const [name, value] of form.hidden) body.append(name, value);
838
+ for (const [name, value] of Object.entries(fields)) body.append(name, value);
839
+ const target = new URL(form.action === "" ? pathname : form.action, TEST_ORIGIN);
840
+ return answer(
841
+ testRequest({
842
+ url: `${target.pathname}${target.search}`,
843
+ method: "POST",
844
+ headers: {
845
+ origin: TEST_ORIGIN,
846
+ "sec-fetch-site": "same-origin",
847
+ "content-type": FORM_ACTION_CONTENT_TYPE,
848
+ },
849
+ cookies: options?.cookies,
850
+ body: body.toString(),
851
+ }),
852
+ );
853
+ },
854
+ settled: async () => {
855
+ while (pending.size > 0) {
856
+ await Promise.all(Array.from(pending));
857
+ }
858
+ },
859
+ errors: () => [...thrown],
860
+ };
861
+ }
862
+
863
+ /** Options for [`buildApp`]. */
864
+ export type BuildAppOptions = {|
865
+ /** The directory holding `uf.config.js`. */
866
+ readonly root: string | URL,
867
+ /** The `uf` to run. Defaults to `UF_BINARY`, which `uf test` sets, then `uf`. */
868
+ readonly binary?: string,
869
+ /** Added to the build's environment, e.g. `UF_BUILD_ID` for reproducible action ids. */
870
+ readonly env?: { readonly [string]: string },
871
+ |};
872
+
873
+ /**
874
+ * Build the project at `root` for production and [`openBuild`] the result.
875
+ *
876
+ * Runs `uf build --adapter node`, which writes `.uf/deploy/node` under the
877
+ * project, and rejects with the build's own output when it fails — an RSC
878
+ * contract violation is a build failure, and the test should say which one.
879
+ *
880
+ * A build is seconds, not milliseconds: call this once, in `beforeAll`, and
881
+ * share the app across the file. Two files building the same project at once
882
+ * would write the same directory, so give the integration tests of one
883
+ * project one file.
884
+ */
885
+ export async function buildApp(options: BuildAppOptions): Promise<BuiltApp> {
886
+ const { spawn } = await import("node:child_process");
887
+ const { fileURLToPath } = await import("node:url");
888
+ const path = await import("node:path");
889
+ const root = path.resolve(
890
+ options.root instanceof URL ? fileURLToPath(options.root.href) : options.root,
891
+ );
892
+ const binary = options.binary ?? process.env.UF_BINARY ?? "uf";
893
+ const child = spawn(binary, ["--cwd", root, "build", "--adapter", "node", "--color", "never"], {
894
+ env: { ...process.env, ...options.env },
895
+ stdio: ["ignore", "pipe", "pipe"],
896
+ });
897
+ let transcript = "";
898
+ const keep = (chunk: mixed) => {
899
+ transcript = (transcript + String(chunk)).slice(-16384);
900
+ };
901
+ child.stdout?.on("data", keep);
902
+ child.stderr?.on("data", keep);
903
+ const code = await new Promise<number | null>((resolve, reject) => {
904
+ child.once("error", reject);
905
+ child.once("close", resolve);
906
+ });
907
+ if (code !== 0) {
908
+ throw new Error(
909
+ `@uniflowed/router/testing: \`uf build\` exited ${String(code)}\n${transcript}`,
910
+ );
911
+ }
912
+ return openBuild(path.join(root, ".uf", "deploy", "node"));
913
+ }
914
+
915
+ /** `loaded` as a handler, or an error naming what it is missing. */
916
+ function asHandler(loaded: mixed, root: string): Handler {
917
+ const candidate =
918
+ loaded != null && typeof loaded === "object" && loaded.default != null && loaded.fetch == null
919
+ ? loaded.default
920
+ : loaded;
921
+ if (
922
+ candidate == null ||
923
+ typeof candidate !== "object" ||
924
+ typeof candidate.fetch !== "function" ||
925
+ typeof candidate.beginRequest !== "function"
926
+ ) {
927
+ throw new TypeError(
928
+ `@uniflowed/router/testing: ${root}/handler.js is not a uf handler (it needs \`fetch\` and ` +
929
+ "`beginRequest`). Open the directory `uf build --adapter node` wrote, `.uf/deploy/node`.",
930
+ );
931
+ }
932
+ // Both functions were checked above; `mixed` cannot carry that.
933
+ return candidate as $FlowFixMe;
934
+ }
935
+
936
+ /** One `<form>` in a document: where it posts, and the hidden fields React wrote. */
937
+ type FormFields = {| readonly action: string, readonly hidden: $ReadOnlyArray<[string, string]> |};
938
+
939
+ /**
940
+ * Every `<form>` in `html`, in document order, with its hidden inputs.
941
+ *
942
+ * A reader for the markup React writes and for nothing more: attributes in
943
+ * double quotes, the five entities `react-dom/server` escapes. That is enough
944
+ * because the document is the build's own; it is not an HTML parser and is not
945
+ * offered as one.
946
+ */
947
+ export function formsIn(html: string): Array<FormFields> {
948
+ const forms: Array<FormFields> = [];
949
+ const formPattern = /<form\b([^>]*)>([\s\S]*?)<\/form>/g;
950
+ for (const match of html.matchAll(formPattern)) {
951
+ const attributes = attributesOf(match[1]);
952
+ const hidden: Array<[string, string]> = [];
953
+ for (const input of match[2].matchAll(/<input\b([^>]*?)\/?>/g)) {
954
+ const fields = attributesOf(input[1]);
955
+ if (fields.get("type") === "hidden" && fields.has("name")) {
956
+ hidden.push([fields.get("name") ?? "", fields.get("value") ?? ""]);
957
+ }
958
+ }
959
+ forms.push({ action: attributes.get("action") ?? "", hidden });
960
+ }
961
+ return forms;
962
+ }
963
+
964
+ function attributesOf(source: string): Map<string, string> {
965
+ const found = new Map<string, string>();
966
+ for (const attribute of source.matchAll(/([^\s=/]+)(?:="([^"]*)")?/g)) {
967
+ found.set(attribute[1].toLowerCase(), unescapeHtml(attribute[2] ?? ""));
968
+ }
969
+ return found;
970
+ }
971
+
972
+ function unescapeHtml(text: string): string {
973
+ return text
974
+ .replace(/&quot;/g, '"')
975
+ .replace(/&#x27;/g, "'")
976
+ .replace(/&#39;/g, "'")
977
+ .replace(/&lt;/g, "<")
978
+ .replace(/&gt;/g, ">")
979
+ .replace(/&amp;/g, "&");
980
+ }