@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/action.js +58 -1
- package/client.js +27 -8
- package/internal/action-endpoint.js +102 -2
- package/internal/action-wire.js +70 -8
- package/internal/compose.js +14 -4
- package/internal/deployment.js +3 -2
- package/internal/devtools.js +1 -1
- package/internal/error-view.js +1 -1
- package/internal/flight-browser.js +86 -10
- package/internal/flight-chunks.js +7 -7
- package/internal/flight-rows.js +7 -2
- package/internal/flight-ssr.js +10 -3
- package/internal/flight.js +32 -0
- package/internal/hydration.js +11 -6
- package/internal/navigation-cache.js +1 -1
- package/internal/payload-rows.js +5 -4
- package/internal/payload.js +18 -12
- package/internal/prepare-document.js +6 -0
- package/internal/resolve.js +17 -11
- package/internal/routing.js +77 -12
- package/internal/runtime.js +114 -6
- package/internal/stream.js +29 -14
- package/middleware.js +20 -4
- package/package.json +6 -4
- package/rsc-ssr.js +14 -5
- package/rsc.js +23 -7
- package/server.js +2 -2
- package/testing.js +980 -0
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(/"/g, '"')
|
|
975
|
+
.replace(/'/g, "'")
|
|
976
|
+
.replace(/'/g, "'")
|
|
977
|
+
.replace(/</g, "<")
|
|
978
|
+
.replace(/>/g, ">")
|
|
979
|
+
.replace(/&/g, "&");
|
|
980
|
+
}
|