@tinvar/react 0.0.0-stage → 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/LICENSE +21 -0
- package/package.json +168 -4
- package/src/action-server.js +41 -0
- package/src/action.d.ts +68 -0
- package/src/action.js +167 -0
- package/src/bootstrap.js +153 -0
- package/src/channel.d.ts +46 -0
- package/src/channel.js +132 -0
- package/src/client-modules.d.ts +8 -0
- package/src/client-modules.js +88 -0
- package/src/client.d.ts +36 -0
- package/src/client.js +42 -0
- package/src/compose-route.d.ts +33 -0
- package/src/compose-route.js +107 -0
- package/src/csrf.d.ts +11 -0
- package/src/csrf.js +49 -0
- package/src/deferred-boundary.d.ts +61 -0
- package/src/deferred-boundary.js +646 -0
- package/src/deferred-groups.d.ts +24 -0
- package/src/deferred-groups.js +198 -0
- package/src/deferred-revalidation.d.ts +37 -0
- package/src/deferred-revalidation.js +108 -0
- package/src/deferred-triggers.d.ts +26 -0
- package/src/deferred-triggers.js +172 -0
- package/src/deferred.d.ts +24 -0
- package/src/deferred.jsx +100 -0
- package/src/document-load.js +95 -0
- package/src/endpoint.d.ts +87 -0
- package/src/endpoint.js +304 -0
- package/src/form-data.js +219 -0
- package/src/form.d.ts +177 -0
- package/src/form.js +615 -0
- package/src/forms.d.ts +4 -0
- package/src/forms.js +10 -0
- package/src/fragment-client.d.ts +70 -0
- package/src/fragment-client.js +510 -0
- package/src/head.d.ts +85 -0
- package/src/head.js +360 -0
- package/src/inbox-view.d.ts +17 -0
- package/src/inbox-view.js +105 -0
- package/src/inbox.d.ts +108 -0
- package/src/inbox.js +293 -0
- package/src/index.combined.d.ts +31 -0
- package/src/index.d.ts +5 -0
- package/src/index.js +29 -0
- package/src/index.server.d.ts +4 -0
- package/src/index.server.js +8 -0
- package/src/infinite-scroll.d.ts +108 -0
- package/src/infinite-scroll.js +715 -0
- package/src/layout-props.d.ts +30 -0
- package/src/layout-props.js +143 -0
- package/src/link-click.d.ts +3 -0
- package/src/link-click.js +68 -0
- package/src/link.d.ts +63 -0
- package/src/link.js +274 -0
- package/src/live.d.ts +65 -0
- package/src/live.js +977 -0
- package/src/merge-slot.d.ts +41 -0
- package/src/merge-slot.js +253 -0
- package/src/merge.d.ts +111 -0
- package/src/merge.js +149 -0
- package/src/once-slot.d.ts +14 -0
- package/src/once-slot.js +75 -0
- package/src/once-store.d.ts +26 -0
- package/src/once-store.js +211 -0
- package/src/once.d.ts +44 -0
- package/src/once.js +65 -0
- package/src/optimistic.d.ts +14 -0
- package/src/optimistic.js +159 -0
- package/src/optional.d.ts +10 -0
- package/src/optional.js +31 -0
- package/src/page-root.d.ts +56 -0
- package/src/page-root.js +455 -0
- package/src/payload.d.ts +15 -0
- package/src/payload.js +101 -0
- package/src/poll.d.ts +55 -0
- package/src/poll.js +224 -0
- package/src/precognition.js +190 -0
- package/src/prefetch-cache.d.ts +46 -0
- package/src/prefetch-cache.js +256 -0
- package/src/progress.d.ts +38 -0
- package/src/progress.js +243 -0
- package/src/remember-store.d.ts +50 -0
- package/src/remember-store.js +486 -0
- package/src/rescue.d.ts +10 -0
- package/src/rescue.js +30 -0
- package/src/resumable.js +822 -0
- package/src/resume-store.js +364 -0
- package/src/route.d.ts +50 -0
- package/src/route.js +227 -0
- package/src/router-hooks.js +234 -0
- package/src/router.d.ts +586 -0
- package/src/router.js +3555 -0
- package/src/segment.d.ts +36 -0
- package/src/segment.js +116 -0
- package/src/server.d.ts +71 -0
- package/src/server.js +242 -0
- package/src/submit.js +333 -0
- package/src/telemetry.d.ts +71 -0
- package/src/telemetry.js +851 -0
- package/src/testing.d.ts +89 -0
- package/src/testing.js +634 -0
- package/src/transfer.d.ts +48 -0
- package/src/transfer.js +135 -0
- package/src/transport.js +390 -0
- package/src/upload.d.ts +342 -0
- package/src/upload.js +1240 -0
- package/src/use-channel.d.ts +21 -0
- package/src/use-channel.js +51 -0
- package/src/use-form.d.ts +86 -0
- package/src/use-form.js +432 -0
- package/src/use-http.d.ts +56 -0
- package/src/use-http.js +248 -0
- package/src/use-inbox.d.ts +41 -0
- package/src/use-inbox.js +201 -0
- package/src/use-remember.d.ts +10 -0
- package/src/use-remember.js +132 -0
- package/src/when-mounted.d.ts +15 -0
- package/src/when-mounted.js +27 -0
- package/README.md +0 -3
package/src/testing.js
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
// The JavaScript half of the testing layer: what codegen's mocks of
|
|
2
|
+
// `@queries/*` and `@actions/*` are built on, and the harness server
|
|
3
|
+
// components render in (tinvar-spec.md, "Testing", "Client components").
|
|
4
|
+
//
|
|
5
|
+
// "Vitest plus React Testing Library on the JavaScript side. Codegen emits:
|
|
6
|
+
// typed fakes from Data objects (`fakeRackData({ name: 'R-14' })`); mock
|
|
7
|
+
// versions of `@queries/*` and `@actions/*`, with call recording. Server
|
|
8
|
+
// components render in the same harness against those mocks."
|
|
9
|
+
//
|
|
10
|
+
// The fakes are plain functions codegen writes beside the types; nothing of
|
|
11
|
+
// them is here. What is here is the mocks' state -- the calls each one was
|
|
12
|
+
// made with, and what it answers -- and renderServer(), which awaits a tree
|
|
13
|
+
// of server components into one React Testing Library can render.
|
|
14
|
+
//
|
|
15
|
+
// This module never runs anywhere but a Vitest worker. It refuses to load
|
|
16
|
+
// outside one, and in any process that says production, before anything of
|
|
17
|
+
// it is defined; and `queryFunctions()` in @tinvar/renderer/vite fails a
|
|
18
|
+
// Vite build whose graph reaches it. A mock that answered in production
|
|
19
|
+
// would be an action that does nothing and says it succeeded (ADR 0061).
|
|
20
|
+
|
|
21
|
+
import { cloneElement, isValidElement } from 'react';
|
|
22
|
+
import { ActionError } from './action.js';
|
|
23
|
+
import { runInRenderContext } from './server.js';
|
|
24
|
+
|
|
25
|
+
/** Found by `queryFunctions()`, which fails any Vite build whose graph holds it. */
|
|
26
|
+
export const TESTING_RUNTIME = 'tinvar:testing-runtime';
|
|
27
|
+
|
|
28
|
+
/** The variables any of which, saying `production`, refuse this module. Mirrors Tinvar\Support\DevMode. */
|
|
29
|
+
const PRODUCTION_MARKERS = ['NODE_ENV', 'TINVAR_ENV', 'APP_ENV'];
|
|
30
|
+
|
|
31
|
+
refuseOutsideTests(globalThis.process?.env);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Throws unless this is a Vitest worker in a process that is not production.
|
|
35
|
+
* Exported for its own test; it has already run by the time anything can
|
|
36
|
+
* import it.
|
|
37
|
+
*
|
|
38
|
+
* Vitest sets VITEST in every worker it starts. A browser has no `process`
|
|
39
|
+
* at all, so a bundle that somehow carried this module stops here too.
|
|
40
|
+
*
|
|
41
|
+
* @param {Record<string, string | undefined> | undefined} env
|
|
42
|
+
*/
|
|
43
|
+
export function refuseOutsideTests(env) {
|
|
44
|
+
if (env === undefined || env === null || typeof env !== 'object') {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'[tinvar] @tinvar/react/testing loaded where there is no process: it runs only in a Vitest worker.',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
for (const name of PRODUCTION_MARKERS) {
|
|
50
|
+
if (
|
|
51
|
+
String(env[name] ?? '')
|
|
52
|
+
.trim()
|
|
53
|
+
.toLowerCase() === 'production'
|
|
54
|
+
) {
|
|
55
|
+
throw new Error(
|
|
56
|
+
`[tinvar] @tinvar/react/testing is refused: ${name}=production. Its mocks answer for queries and ` +
|
|
57
|
+
'actions without running them, so it never loads in production.',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (!env.VITEST) {
|
|
62
|
+
throw new Error('[tinvar] @tinvar/react/testing runs only in a Vitest worker, and VITEST is not set.');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Marks an action reference; the same symbol action.js marks real ones with. */
|
|
67
|
+
const REFERENCE = Symbol.for('tinvar.action');
|
|
68
|
+
|
|
69
|
+
/** Where a mock keeps its entry. */
|
|
70
|
+
const MOCK = Symbol('tinvar.mock');
|
|
71
|
+
|
|
72
|
+
const ACTION_PREFIX = '/_action/';
|
|
73
|
+
const QUERY_PREFIX = '/_query/';
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* @typedef {{
|
|
77
|
+
* kind: 'query' | 'action',
|
|
78
|
+
* name: string,
|
|
79
|
+
* calls: unknown[],
|
|
80
|
+
* fallback: () => unknown,
|
|
81
|
+
* answer: { type: 'value', value: unknown } | { type: 'refuse', status: number, errors: Record<string, string[]> } | null,
|
|
82
|
+
* }} Entry
|
|
83
|
+
*/
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Every mock this worker has made, by kind and wire name: an action is
|
|
87
|
+
* reached by name from a <Form>'s fetch as well as by its reference.
|
|
88
|
+
*
|
|
89
|
+
* @type {Map<string, Entry>}
|
|
90
|
+
*/
|
|
91
|
+
const entries = new Map();
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {'query' | 'action'} kind
|
|
95
|
+
* @param {string} name
|
|
96
|
+
* @param {() => unknown} fallback
|
|
97
|
+
* @returns {Entry}
|
|
98
|
+
*/
|
|
99
|
+
function register(kind, name, fallback) {
|
|
100
|
+
if (typeof name !== 'string' || name === '') {
|
|
101
|
+
throw new TypeError(`[tinvar] A mocked ${kind} needs its wire name.`);
|
|
102
|
+
}
|
|
103
|
+
if (typeof fallback !== 'function') {
|
|
104
|
+
throw new TypeError(
|
|
105
|
+
`[tinvar] The mocked ${kind} ${name} needs a function that makes its default answer.`,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
// A module evaluated again -- vi.resetModules() -- makes its mocks again;
|
|
109
|
+
// they keep the calls and answers a test gave the first ones.
|
|
110
|
+
const key = `${kind}:${name}`;
|
|
111
|
+
const existing = entries.get(key);
|
|
112
|
+
if (existing) {
|
|
113
|
+
existing.fallback = fallback;
|
|
114
|
+
return existing;
|
|
115
|
+
}
|
|
116
|
+
/** @type {Entry} */
|
|
117
|
+
const entry = { kind, name, calls: [], fallback, answer: null };
|
|
118
|
+
entries.set(key, entry);
|
|
119
|
+
return entry;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* What a mock answers for one call: what the test gave it, or else a fresh
|
|
124
|
+
* default from its fake.
|
|
125
|
+
*
|
|
126
|
+
* @param {Entry} entry
|
|
127
|
+
* @param {unknown} args
|
|
128
|
+
*/
|
|
129
|
+
function answerFor(entry, args) {
|
|
130
|
+
const answer = entry.answer;
|
|
131
|
+
if (answer === null) return entry.fallback();
|
|
132
|
+
if (answer.type === 'value') {
|
|
133
|
+
return typeof answer.value === 'function' ? answer.value(args) : answer.value;
|
|
134
|
+
}
|
|
135
|
+
if (entry.kind === 'action') {
|
|
136
|
+
throw new ActionError(entry.name, refusalOutcome(answer.status, answer.errors));
|
|
137
|
+
}
|
|
138
|
+
throw new Error(`[tinvar] The query ${entry.name} was refused (${answer.status}), as the test asked.`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* The outcome transport.js classifies a refusal as, for an ActionError.
|
|
143
|
+
*
|
|
144
|
+
* @param {number} status
|
|
145
|
+
* @param {Record<string, string[]>} errors
|
|
146
|
+
*/
|
|
147
|
+
function refusalOutcome(status, errors) {
|
|
148
|
+
const headers = new Headers();
|
|
149
|
+
return status === 422
|
|
150
|
+
? { type: 'validation', status: 422, errors, body: { errors }, headers }
|
|
151
|
+
: { type: 'http', status, body: null, headers, code: null, reference: null, retryAfter: null };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A stand-in for one `@queries/*` function: called as the real one is, it
|
|
156
|
+
* records its arguments and resolves with what the test said, or a fake.
|
|
157
|
+
* What codegen's mock query modules are made of.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} name the query's wire name
|
|
160
|
+
* @param {() => unknown} fallback
|
|
161
|
+
*/
|
|
162
|
+
export function mockQuery(name, fallback) {
|
|
163
|
+
const entry = register('query', name, fallback);
|
|
164
|
+
/** @param {Record<string, unknown>} [args] */
|
|
165
|
+
const call = async (args = {}) => {
|
|
166
|
+
entry.calls.push(args);
|
|
167
|
+
return answerFor(entry, args);
|
|
168
|
+
};
|
|
169
|
+
Object.defineProperties(call, {
|
|
170
|
+
query: { value: name, enumerable: true },
|
|
171
|
+
[MOCK]: { value: entry },
|
|
172
|
+
});
|
|
173
|
+
return Object.freeze(call);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A stand-in for one `@actions/*` reference. It is a reference -- <Form>,
|
|
178
|
+
* useForm and useHttp take it, `.optimistic()` works -- and called directly
|
|
179
|
+
* it records its arguments and resolves with what the test said, or an
|
|
180
|
+
* empty ActionResult. A post a <Form> makes reaches it through
|
|
181
|
+
* actionFetch(), which a test installs as fetch.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} name the action's wire name
|
|
184
|
+
* @param {() => unknown} fallback
|
|
185
|
+
*/
|
|
186
|
+
export function mockAction(name, fallback) {
|
|
187
|
+
return actionReference(register('action', name, fallback), null);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* @param {Entry} entry
|
|
192
|
+
* @param {((state: Record<string, any>) => Record<string, unknown>) | null} patch
|
|
193
|
+
*/
|
|
194
|
+
function actionReference(entry, patch) {
|
|
195
|
+
/** @param {Record<string, unknown>} [args] */
|
|
196
|
+
const call = async (args = {}) => {
|
|
197
|
+
entry.calls.push(args);
|
|
198
|
+
return answerFor(entry, args);
|
|
199
|
+
};
|
|
200
|
+
Object.defineProperties(call, {
|
|
201
|
+
action: { value: entry.name, enumerable: true },
|
|
202
|
+
url: { value: ACTION_PREFIX + encodeURIComponent(entry.name), enumerable: true },
|
|
203
|
+
optimisticPatch: { value: patch },
|
|
204
|
+
[REFERENCE]: { value: true },
|
|
205
|
+
[MOCK]: { value: entry },
|
|
206
|
+
optimistic: {
|
|
207
|
+
/** @param {(state: Record<string, any>) => Record<string, unknown>} next */
|
|
208
|
+
value: (next) => {
|
|
209
|
+
if (typeof next !== 'function') {
|
|
210
|
+
throw new TypeError('[tinvar] optimistic() takes a function from the current state to a patch.');
|
|
211
|
+
}
|
|
212
|
+
return actionReference(entry, next);
|
|
213
|
+
},
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
return Object.freeze(call);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** @param {unknown} mock @returns {Entry} */
|
|
220
|
+
function entryOf(mock) {
|
|
221
|
+
const entry = mock !== null && typeof mock === 'function' ? /** @type {any} */ (mock)[MOCK] : undefined;
|
|
222
|
+
if (entry === undefined) {
|
|
223
|
+
throw new TypeError(
|
|
224
|
+
'[tinvar] That is not a mocked query or action. Import it from @queries/* or @actions/* in a test ' +
|
|
225
|
+
'run with tinvarTesting(), which puts the mocks codegen wrote in their place.',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return entry;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The arguments each call to a mock was made with, oldest first: a direct
|
|
233
|
+
* call's as given, a <Form>'s as it posted them.
|
|
234
|
+
*
|
|
235
|
+
* @param {unknown} mock
|
|
236
|
+
* @returns {unknown[]}
|
|
237
|
+
*/
|
|
238
|
+
export function calls(mock) {
|
|
239
|
+
return [...entryOf(mock).calls];
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* What a mock answers from now on, until resetMocks(): a value, or a
|
|
244
|
+
* function of the call's arguments. A promise that never settles keeps a
|
|
245
|
+
* server component suspended; a function that throws fails the call.
|
|
246
|
+
*
|
|
247
|
+
* @param {unknown} mock
|
|
248
|
+
* @param {unknown} value
|
|
249
|
+
*/
|
|
250
|
+
export function answer(mock, value) {
|
|
251
|
+
entryOf(mock).answer = { type: 'value', value };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Has a mock refuse from now on: an action rejects with the ActionError the
|
|
256
|
+
* endpoint's answer would give -- field errors with 422 -- and a <Form>
|
|
257
|
+
* posting to it gets that status; a query rejects.
|
|
258
|
+
*
|
|
259
|
+
* @param {unknown} mock
|
|
260
|
+
* @param {{ status?: number, errors?: Record<string, string[]> }} [refusal]
|
|
261
|
+
*/
|
|
262
|
+
export function refuse(mock, { status = 422, errors = {} } = {}) {
|
|
263
|
+
if (!Number.isInteger(status) || status < 400 || status > 599) {
|
|
264
|
+
throw new RangeError(`[tinvar] A refusal is a 4xx or 5xx status, not ${status}.`);
|
|
265
|
+
}
|
|
266
|
+
entryOf(mock).answer = { type: 'refuse', status, errors };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* A fake's defaults under a test's overrides, at any depth: a plain object
|
|
271
|
+
* given for a field that holds one is laid over that field's own defaults,
|
|
272
|
+
* so `fakeOrderData({ customer: { name: 'Ada' } })` keeps the customer's
|
|
273
|
+
* other fields. Anything else given -- a list, null, a string -- takes the
|
|
274
|
+
* field's place whole, and a field given as `undefined` keeps its default.
|
|
275
|
+
* What codegen's fakes return; neither argument is changed.
|
|
276
|
+
*
|
|
277
|
+
* The keys an object can be given its prototype by (`__proto__`,
|
|
278
|
+
* `constructor`, `prototype`) are refused, not copied: a fake is built from
|
|
279
|
+
* what a test wrote, and a merge that let one through would change every
|
|
280
|
+
* object in the worker.
|
|
281
|
+
*
|
|
282
|
+
* @template T
|
|
283
|
+
* @param {T} base
|
|
284
|
+
* @param {unknown} overrides
|
|
285
|
+
* @returns {T}
|
|
286
|
+
*/
|
|
287
|
+
export function withOverrides(base, overrides) {
|
|
288
|
+
return /** @type {T} */ (layered(base, overrides, 'overrides'));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
292
|
+
|
|
293
|
+
/** @param {unknown} value @returns {value is Record<string, unknown>} */
|
|
294
|
+
function isPlainObject(value) {
|
|
295
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return false;
|
|
296
|
+
const prototype = Object.getPrototypeOf(value);
|
|
297
|
+
return prototype === Object.prototype || prototype === null;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** @param {unknown} base @param {unknown} over @param {string} where @returns {unknown} */
|
|
301
|
+
function layered(base, over, where) {
|
|
302
|
+
if (over === undefined) return base;
|
|
303
|
+
if (!isPlainObject(over) || !isPlainObject(base)) return over;
|
|
304
|
+
/** @type {Record<string, unknown>} */
|
|
305
|
+
const out = { ...base };
|
|
306
|
+
for (const key of Object.keys(over)) {
|
|
307
|
+
if (UNSAFE_KEYS.has(key)) {
|
|
308
|
+
throw new TypeError(
|
|
309
|
+
`[tinvar] ${where} names \`${key}\`, which no fake has: it would change the prototype.`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
out[key] = layered(base[key], over[key], `${where}.${key}`);
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Forgets every call and answer, so each test starts from the fakes. */
|
|
318
|
+
export function resetMocks() {
|
|
319
|
+
for (const entry of entries.values()) {
|
|
320
|
+
entry.calls = [];
|
|
321
|
+
entry.answer = null;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* A fetch for component tests: a post to `/_action/{name}` answered by that
|
|
327
|
+
* action's mock, and one to `/_query/{name}` -- what useHttp() sends -- by
|
|
328
|
+
* that query's mock, each recorded and then answered as its endpoint would;
|
|
329
|
+
* anything else refused with a 404, never sent. Install it with
|
|
330
|
+
* `vi.stubGlobal('fetch', actionFetch)`.
|
|
331
|
+
*
|
|
332
|
+
* A query's call is recorded with the arguments it was sent under `args`,
|
|
333
|
+
* as a direct call's are, and answered `{ value }`; a refusal the test
|
|
334
|
+
* asked for is its status, with the field errors at 422.
|
|
335
|
+
*
|
|
336
|
+
* @param {RequestInfo | URL} input
|
|
337
|
+
* @param {RequestInit} [init]
|
|
338
|
+
* @returns {Promise<Response>}
|
|
339
|
+
*/
|
|
340
|
+
export async function actionFetch(input, init = {}) {
|
|
341
|
+
// The page's own origin only, as the endpoint is: a post elsewhere is not
|
|
342
|
+
// one of the application's actions, whatever its path says.
|
|
343
|
+
const here = new URL(globalThis.location?.href ?? 'http://localhost/');
|
|
344
|
+
const request = input instanceof Request ? input : null;
|
|
345
|
+
const url = new URL(request?.url ?? String(input), here);
|
|
346
|
+
const method = (init.method ?? request?.method ?? 'GET').toUpperCase();
|
|
347
|
+
const ours = url.origin === here.origin;
|
|
348
|
+
const kind =
|
|
349
|
+
ours && url.pathname.startsWith(ACTION_PREFIX)
|
|
350
|
+
? 'action'
|
|
351
|
+
: ours && url.pathname.startsWith(QUERY_PREFIX)
|
|
352
|
+
? 'query'
|
|
353
|
+
: null;
|
|
354
|
+
const name =
|
|
355
|
+
kind === null
|
|
356
|
+
? null
|
|
357
|
+
: actionOf(url.pathname.slice((kind === 'action' ? ACTION_PREFIX : QUERY_PREFIX).length));
|
|
358
|
+
const entry = name === null ? undefined : entries.get(`${kind}:${name}`);
|
|
359
|
+
if (entry === undefined || method !== 'POST') {
|
|
360
|
+
return json(404, {
|
|
361
|
+
error: {
|
|
362
|
+
code: 'not_mocked',
|
|
363
|
+
message: `No mocked ${kind ?? 'action'} answers ${method} ${url.pathname}.`,
|
|
364
|
+
},
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const sent = await bodyOf(init.body ?? (request === null ? null : await request.text()));
|
|
369
|
+
if (entry.kind === 'query') return queryAnswer(entry, sent);
|
|
370
|
+
|
|
371
|
+
const args = sent;
|
|
372
|
+
entry.calls.push(args);
|
|
373
|
+
try {
|
|
374
|
+
const body = await answerFor(entry, args);
|
|
375
|
+
return json(200, body ?? { value: null, flash: {}, revalidated: [], payloads: {} });
|
|
376
|
+
} catch (error) {
|
|
377
|
+
if (error instanceof ActionError) {
|
|
378
|
+
return json(error.status, error.kind === 'validation' ? { errors: error.errors } : null);
|
|
379
|
+
}
|
|
380
|
+
throw error;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* A query's answer over the wire: `{ value }`, or the refusal the test asked
|
|
386
|
+
* for. Its arguments are what useHttp() sends under `args`; a body without
|
|
387
|
+
* them is refused with the 422 the endpoint gives, and not recorded.
|
|
388
|
+
*
|
|
389
|
+
* @param {Entry} entry
|
|
390
|
+
* @param {unknown} sent
|
|
391
|
+
*/
|
|
392
|
+
async function queryAnswer(entry, sent) {
|
|
393
|
+
const args = isPlainObject(sent) ? (sent.args ?? {}) : null;
|
|
394
|
+
if (!isPlainObject(args)) {
|
|
395
|
+
return json(422, { errors: { args: ['The arguments are an object under `args`.'] } });
|
|
396
|
+
}
|
|
397
|
+
entry.calls.push(args);
|
|
398
|
+
const answer = entry.answer;
|
|
399
|
+
if (answer?.type === 'refuse') {
|
|
400
|
+
return json(answer.status, answer.status === 422 ? { errors: answer.errors } : null);
|
|
401
|
+
}
|
|
402
|
+
return json(200, { value: (await answerFor(entry, args)) ?? null });
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** An action's name from its path segment, or null for one that does not decode. @param {string} segment */
|
|
406
|
+
function actionOf(segment) {
|
|
407
|
+
try {
|
|
408
|
+
return decodeURIComponent(segment);
|
|
409
|
+
} catch {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** @param {BodyInit | null | undefined} body */
|
|
415
|
+
async function bodyOf(body) {
|
|
416
|
+
if (typeof body === 'string') {
|
|
417
|
+
try {
|
|
418
|
+
return JSON.parse(body);
|
|
419
|
+
} catch {
|
|
420
|
+
return body;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
if (body instanceof FormData) {
|
|
424
|
+
/** @type {Record<string, unknown>} */
|
|
425
|
+
const out = {};
|
|
426
|
+
for (const [key, value] of body.entries()) out[key] = value;
|
|
427
|
+
return out;
|
|
428
|
+
}
|
|
429
|
+
return {};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** @param {number} status @param {unknown} body */
|
|
433
|
+
function json(status, body) {
|
|
434
|
+
return new Response(body === null ? '' : JSON.stringify(body), {
|
|
435
|
+
status,
|
|
436
|
+
headers: { 'content-type': 'application/json' },
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
441
|
+
// Server components
|
|
442
|
+
|
|
443
|
+
/** Where 'use client' functions are kept: one set however often this module is loaded. */
|
|
444
|
+
const CLIENT = Symbol.for('tinvar.testing.client');
|
|
445
|
+
/** @type {WeakSet<Function>} */
|
|
446
|
+
const clientComponents = /** @type {any} */ (globalThis)[CLIENT] ?? new WeakSet();
|
|
447
|
+
/** @type {any} */ (globalThis)[CLIENT] = clientComponents;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Marks the exports of a 'use client' module as client components, which
|
|
451
|
+
* renderServer() leaves for React to render. `tinvarTesting()` adds the call
|
|
452
|
+
* to each such module; a test has no need of it.
|
|
453
|
+
*
|
|
454
|
+
* @param {...unknown} exports
|
|
455
|
+
*/
|
|
456
|
+
export function markClient(...exports) {
|
|
457
|
+
for (const value of exports) {
|
|
458
|
+
if (typeof value === 'function') clientComponents.add(value);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/** @param {unknown} type */
|
|
463
|
+
export function isClientComponent(type) {
|
|
464
|
+
return typeof type === 'function' && clientComponents.has(type);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Renders the server components of a tree, as the renderer's rsc
|
|
469
|
+
* environment does, and hands back what is left: host elements and client
|
|
470
|
+
* components, with every server component's output in its place, for React
|
|
471
|
+
* Testing Library's render().
|
|
472
|
+
*
|
|
473
|
+
* A server component is any function component not exported from a
|
|
474
|
+
* 'use client' module. It is called with its props and awaited, inside a
|
|
475
|
+
* render context whose `shared()` answers from `options.shared` -- a key not
|
|
476
|
+
* given fails, as a key PHP does not share does -- and whose query()
|
|
477
|
+
* answers from the mocks. Suspense boundaries are waited out, so what comes
|
|
478
|
+
* back is the tree as it finally streams; a query the test answered with a
|
|
479
|
+
* promise that never settles never comes back.
|
|
480
|
+
*
|
|
481
|
+
* The props a server component gives a client component or an element are
|
|
482
|
+
* held to what Flight can send: a function there that is not an action
|
|
483
|
+
* reference fails the render, as it would in the real one.
|
|
484
|
+
*
|
|
485
|
+
* @param {import('react').ReactNode} node
|
|
486
|
+
* @param {{ shared?: Record<string, unknown> }} [options]
|
|
487
|
+
* @returns {Promise<import('react').ReactNode>}
|
|
488
|
+
*/
|
|
489
|
+
export function renderServer(node, { shared = {} } = {}) {
|
|
490
|
+
const context = {
|
|
491
|
+
requestId: 'test',
|
|
492
|
+
nonce: 'test-nonce',
|
|
493
|
+
get allowed() {
|
|
494
|
+
return [...entries.values()].filter((e) => e.kind === 'query').map((e) => e.name);
|
|
495
|
+
},
|
|
496
|
+
client: {
|
|
497
|
+
/** @param {string} name @param {Record<string, unknown>} args */
|
|
498
|
+
call: async (name, args = {}) => {
|
|
499
|
+
if (name.startsWith('$shared.')) {
|
|
500
|
+
const key = name.slice('$shared.'.length);
|
|
501
|
+
if (!Object.hasOwn(shared, key)) {
|
|
502
|
+
throw new Error(
|
|
503
|
+
`[tinvar] A component read shared('${key}'), and renderServer() was given no value for it. ` +
|
|
504
|
+
`Pass { shared: { ${key}: ... } }.`,
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
return shared[key];
|
|
508
|
+
}
|
|
509
|
+
const entry = entries.get(`query:${name}`);
|
|
510
|
+
if (entry === undefined) throw new Error(`[tinvar] No mocked query is named ${name}.`);
|
|
511
|
+
entry.calls.push(args);
|
|
512
|
+
return answerFor(entry, args);
|
|
513
|
+
},
|
|
514
|
+
},
|
|
515
|
+
mints: {
|
|
516
|
+
/** @param {string} component */
|
|
517
|
+
mint: async (component) => `test-fragment-token:${component}`,
|
|
518
|
+
},
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
return runInRenderContext(context, () => resolve(node));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* @param {unknown} node
|
|
526
|
+
* @returns {Promise<any>}
|
|
527
|
+
*/
|
|
528
|
+
async function resolve(node) {
|
|
529
|
+
if (Array.isArray(node)) return Promise.all(node.map((child) => resolve(child)));
|
|
530
|
+
if (isThenable(node)) return resolve(await node);
|
|
531
|
+
if (!isValidElement(node)) return node;
|
|
532
|
+
|
|
533
|
+
const element = /** @type {import('react').ReactElement<Record<string, unknown>>} */ (node);
|
|
534
|
+
const type = element.type;
|
|
535
|
+
|
|
536
|
+
if (typeof type === 'function' && !isClientComponent(type) && !type.prototype?.isReactComponent) {
|
|
537
|
+
let output;
|
|
538
|
+
try {
|
|
539
|
+
output = type(element.props);
|
|
540
|
+
} catch (error) {
|
|
541
|
+
throw explainHooks(error, type);
|
|
542
|
+
}
|
|
543
|
+
return resolve(await output);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// A host element, a fragment, Suspense, a provider, or a client
|
|
547
|
+
// component: rendered by React. What it was handed may hold server
|
|
548
|
+
// components still -- children, a fallback -- so those are rendered.
|
|
549
|
+
// Everything here came out of a server component, so a host element's
|
|
550
|
+
// props and a client component's are what Flight would have to send.
|
|
551
|
+
const sent = typeof type === 'string' ? `<${type}>` : isClientComponent(type) ? `<${nameOf(type)}>` : null;
|
|
552
|
+
/** @type {Record<string, unknown>} */
|
|
553
|
+
const props = {};
|
|
554
|
+
let changed = false;
|
|
555
|
+
for (const [key, value] of Object.entries(element.props)) {
|
|
556
|
+
if (sent !== null) assertSendable(value, `${sent}'s ${key}`);
|
|
557
|
+
if (holdsElements(value, key === 'children')) {
|
|
558
|
+
props[key] = await resolve(value);
|
|
559
|
+
changed = true;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return changed ? cloneElement(element, props) : element;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Whether a prop is something to render: an element, or a list holding
|
|
567
|
+
* one, and as children a promise too -- what an async child streams as. A
|
|
568
|
+
* promise in any other prop is one Flight sends as a promise, for the
|
|
569
|
+
* client component to use(), so it is left one. A list of plain data -- a
|
|
570
|
+
* page's products -- is left as it is.
|
|
571
|
+
*
|
|
572
|
+
* @param {unknown} value
|
|
573
|
+
* @param {boolean} children
|
|
574
|
+
* @returns {boolean}
|
|
575
|
+
*/
|
|
576
|
+
function holdsElements(value, children) {
|
|
577
|
+
if (isValidElement(value) || (children && isThenable(value))) return true;
|
|
578
|
+
return Array.isArray(value) && value.some((item) => holdsElements(item, children));
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* What Flight refuses: a function, unless it is an action reference (which
|
|
583
|
+
* the real build sends as `{ action, url }`), and a symbol.
|
|
584
|
+
*
|
|
585
|
+
* @param {unknown} value
|
|
586
|
+
* @param {string} where
|
|
587
|
+
* @param {Set<unknown>} [seen]
|
|
588
|
+
*/
|
|
589
|
+
function assertSendable(value, where, seen = new Set()) {
|
|
590
|
+
if (typeof value === 'symbol') {
|
|
591
|
+
throw new TypeError(`[tinvar] ${where} is a symbol, which a server component cannot hand a client one.`);
|
|
592
|
+
}
|
|
593
|
+
if (typeof value === 'function') {
|
|
594
|
+
if (/** @type {any} */ (value)[REFERENCE] === true) return;
|
|
595
|
+
throw new TypeError(
|
|
596
|
+
`[tinvar] ${where} is a function, which a server component cannot hand a client one ` +
|
|
597
|
+
'("Functions cannot be passed directly to Client Components"). Pass an action reference or data.',
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
if (value === null || typeof value !== 'object' || seen.has(value) || isValidElement(value)) return;
|
|
601
|
+
seen.add(value);
|
|
602
|
+
for (const [key, inner] of Object.entries(value)) assertSendable(inner, `${where}.${key}`, seen);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** @param {unknown} value @returns {value is PromiseLike<unknown>} */
|
|
606
|
+
function isThenable(value) {
|
|
607
|
+
return (
|
|
608
|
+
value !== null && typeof value === 'object' && typeof (/** @type {any} */ (value).then) === 'function'
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/** @param {Function} type */
|
|
613
|
+
function nameOf(type) {
|
|
614
|
+
return /** @type {any} */ (type).displayName || type.name || 'a client component';
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* A hook called outside React means a client component was called as a
|
|
619
|
+
* server one: its module has no 'use client', or the test runs without
|
|
620
|
+
* tinvarTesting(), which marks those that do.
|
|
621
|
+
*
|
|
622
|
+
* @param {unknown} error
|
|
623
|
+
* @param {Function} type
|
|
624
|
+
*/
|
|
625
|
+
function explainHooks(error, type) {
|
|
626
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
627
|
+
if (!/hook|reading 'use[A-Z]/i.test(message)) return error;
|
|
628
|
+
return new Error(
|
|
629
|
+
`[tinvar] ${nameOf(type)} was rendered as a server component and called a hook (${message}). ` +
|
|
630
|
+
"A component with hooks is a client one: its module needs 'use client', and the test config " +
|
|
631
|
+
'tinvarTesting() from @tinvar/renderer/vitest.',
|
|
632
|
+
{ cause: error },
|
|
633
|
+
);
|
|
634
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** What one export takes and writes, as codegen describes it. */
|
|
2
|
+
export interface ExportShape {
|
|
3
|
+
args: object;
|
|
4
|
+
formats: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/** What one import takes and reads, as codegen describes it. */
|
|
8
|
+
export interface ImportShape {
|
|
9
|
+
args: object;
|
|
10
|
+
columns: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** A start the server refused: its status, its error code, and a 422's field errors. */
|
|
14
|
+
export declare class TransferError extends Error {
|
|
15
|
+
readonly transfer: string;
|
|
16
|
+
readonly status: number;
|
|
17
|
+
readonly code: string | null;
|
|
18
|
+
readonly errors: Record<string, string[]>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The record a start queued: its id, for the page that lists the user's transfers. */
|
|
22
|
+
export interface Started {
|
|
23
|
+
transfer: string;
|
|
24
|
+
status: 'queued';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Transfers<
|
|
28
|
+
E extends { [N in keyof E]: ExportShape },
|
|
29
|
+
I extends { [N in keyof I]: ImportShape },
|
|
30
|
+
> {
|
|
31
|
+
/** Starts an export: the file is written by a worker, and linked from the user's list once it is. */
|
|
32
|
+
export<N extends keyof E & string>(
|
|
33
|
+
name: N,
|
|
34
|
+
options?: { args?: E[N]['args']; format?: E[N]['formats']; signal?: AbortSignal; fetch?: typeof fetch },
|
|
35
|
+
): Promise<Started>;
|
|
36
|
+
/** Sends a CSV file to an import: its rows are applied by a worker, all of them or none. */
|
|
37
|
+
import<N extends keyof I & string>(
|
|
38
|
+
name: N,
|
|
39
|
+
file: Blob,
|
|
40
|
+
options?: { args?: I[N]['args']; signal?: AbortSignal; fetch?: typeof fetch },
|
|
41
|
+
): Promise<Started>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Builds the typed `transfer` codegen exports. */
|
|
45
|
+
export declare function transfers<
|
|
46
|
+
E extends { [N in keyof E]: ExportShape },
|
|
47
|
+
I extends { [N in keyof I]: ImportShape },
|
|
48
|
+
>(): Transfers<E, I>;
|