@client-compose/client 0.7.0 → 0.9.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/README.md +55 -3
- package/dist/index.d.ts +69 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +175 -14
- package/dist/index.js.map +1 -1
- package/dist/proxy.d.ts +3 -1
- package/dist/proxy.d.ts.map +1 -1
- package/dist/proxy.js +20 -0
- package/dist/proxy.js.map +1 -1
- package/dist/strings.d.ts +26 -0
- package/dist/strings.d.ts.map +1 -0
- package/dist/strings.js +130 -0
- package/dist/strings.js.map +1 -0
- package/dist/testing.d.ts +52 -7
- package/dist/testing.d.ts.map +1 -1
- package/dist/testing.js +121 -10
- package/dist/testing.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +250 -22
- package/src/proxy.ts +28 -0
- package/src/strings.ts +178 -0
- package/src/testing.ts +178 -12
package/src/testing.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
// screens: { profile: () => screen("profile", [island("header", ProfileHeaderV1, { name: "Ada" })]) },
|
|
7
7
|
// actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }) },
|
|
8
8
|
// islands: { "profile/posts": (r) => islandResponse("profile", island("posts", PostsV1, …, "page:3")) },
|
|
9
|
+
// strings: () => stringsResponse("en", { retry: "Try again" }),
|
|
9
10
|
// });
|
|
10
11
|
// // pass gateway.fetch as the client's fetch, then assert on gateway.actionRequests()
|
|
11
12
|
//
|
|
@@ -20,14 +21,22 @@ import {
|
|
|
20
21
|
type IslandDescriptor,
|
|
21
22
|
type IslandEntry,
|
|
22
23
|
type IslandError,
|
|
24
|
+
type PendingIsland,
|
|
25
|
+
type PluralRules,
|
|
23
26
|
PROTOCOL_VERSION,
|
|
24
27
|
type ScreenMeta,
|
|
28
|
+
type Strings,
|
|
25
29
|
} from "@client-compose/protocol";
|
|
26
30
|
|
|
27
|
-
/**
|
|
31
|
+
/**
|
|
32
|
+
* A response for the fake composer to send: a JSON body, or a screen's stream (spec §4.6), as
|
|
33
|
+
* lines that come when they're given.
|
|
34
|
+
*/
|
|
28
35
|
export interface FakeResponse {
|
|
29
36
|
status: number;
|
|
30
|
-
body
|
|
37
|
+
body?: unknown;
|
|
38
|
+
/** Opens the stream's body, for each request answered with it. */
|
|
39
|
+
stream?: () => ReadableStream<Uint8Array>;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/** A request the fake composer received. */
|
|
@@ -65,6 +74,11 @@ export interface RecordedActionRequest extends RecordedRequest {
|
|
|
65
74
|
idempotencyKey: string | undefined;
|
|
66
75
|
}
|
|
67
76
|
|
|
77
|
+
/** A strings request (spec §4.7): the app strings, in the client's language. */
|
|
78
|
+
export interface RecordedStringsRequest extends RecordedRequest {
|
|
79
|
+
client: ClientContext | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
68
82
|
export interface FakeGatewayHandlers {
|
|
69
83
|
/** By screen id. */
|
|
70
84
|
screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
|
|
@@ -72,6 +86,8 @@ export interface FakeGatewayHandlers {
|
|
|
72
86
|
actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
|
|
73
87
|
/** Island requests, by `"{screen}/{key}"`. */
|
|
74
88
|
islands?: Record<string, (request: RecordedIslandRequest) => FakeResponse>;
|
|
89
|
+
/** The app strings (POST /strings). */
|
|
90
|
+
strings?: (request: RecordedStringsRequest) => FakeResponse;
|
|
75
91
|
/** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
|
|
76
92
|
other?: (request: RecordedRequest) => FakeResponse | undefined;
|
|
77
93
|
}
|
|
@@ -84,6 +100,7 @@ export interface FakeGateway {
|
|
|
84
100
|
screenRequests(): RecordedScreenRequest[];
|
|
85
101
|
islandRequests(): RecordedIslandRequest[];
|
|
86
102
|
actionRequests(): RecordedActionRequest[];
|
|
103
|
+
stringsRequests(): RecordedStringsRequest[];
|
|
87
104
|
}
|
|
88
105
|
|
|
89
106
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -202,7 +219,21 @@ function asActionRequest(request: RecordedRequest): RecordedActionRequest | unde
|
|
|
202
219
|
};
|
|
203
220
|
}
|
|
204
221
|
|
|
222
|
+
function asStringsRequest(request: RecordedRequest): RecordedStringsRequest | undefined {
|
|
223
|
+
const path = request.url.split("?")[0] ?? "";
|
|
224
|
+
if (!/\/strings\/?$/.test(path) || !isRecord(request.body)) {
|
|
225
|
+
return undefined;
|
|
226
|
+
}
|
|
227
|
+
return { ...request, client: clientContext(request.body.client) };
|
|
228
|
+
}
|
|
229
|
+
|
|
205
230
|
function respond(response: FakeResponse): Response {
|
|
231
|
+
if (response.stream) {
|
|
232
|
+
return new Response(response.stream(), {
|
|
233
|
+
status: response.status,
|
|
234
|
+
headers: { "content-type": "application/jsonl" },
|
|
235
|
+
});
|
|
236
|
+
}
|
|
206
237
|
return new Response(JSON.stringify(response.body), {
|
|
207
238
|
status: response.status,
|
|
208
239
|
headers: { "content-type": "application/json" },
|
|
@@ -241,6 +272,10 @@ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGatew
|
|
|
241
272
|
if (actionRequest && actionHandler) {
|
|
242
273
|
return respond(actionHandler(actionRequest));
|
|
243
274
|
}
|
|
275
|
+
const stringsRequest = asStringsRequest(request);
|
|
276
|
+
if (stringsRequest && handlers.strings) {
|
|
277
|
+
return respond(handlers.strings(stringsRequest));
|
|
278
|
+
}
|
|
244
279
|
throw new Error(
|
|
245
280
|
`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`,
|
|
246
281
|
);
|
|
@@ -251,6 +286,7 @@ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGatew
|
|
|
251
286
|
screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
|
|
252
287
|
islandRequests: () => requests.flatMap((r) => asIslandRequest(r) ?? []),
|
|
253
288
|
actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
|
|
289
|
+
stringsRequests: () => requests.flatMap((r) => asStringsRequest(r) ?? []),
|
|
254
290
|
};
|
|
255
291
|
}
|
|
256
292
|
|
|
@@ -277,16 +313,24 @@ export function installFakeGateway(handlers: FakeGatewayHandlers = {}): Installe
|
|
|
277
313
|
|
|
278
314
|
// ---- Responses ----
|
|
279
315
|
|
|
316
|
+
/** More of an island's entry: its next page's cursor (spec §4.5), and its strings (spec §4.7). */
|
|
317
|
+
export interface IslandOptions {
|
|
318
|
+
next?: string;
|
|
319
|
+
strings?: Strings;
|
|
320
|
+
}
|
|
321
|
+
|
|
280
322
|
/**
|
|
281
323
|
* An island in a screen or island response, typed by its generated descriptor. Give `next` for an
|
|
282
|
-
* island with more pages (spec §4.5)
|
|
324
|
+
* island with more pages (spec §4.5), or `{ next, strings }`.
|
|
283
325
|
*/
|
|
284
|
-
export function island<P>(
|
|
326
|
+
export function island<P, S>(
|
|
285
327
|
key: string,
|
|
286
|
-
descriptor: IslandDescriptor<P>,
|
|
328
|
+
descriptor: IslandDescriptor<P, S>,
|
|
287
329
|
props: P,
|
|
288
|
-
|
|
330
|
+
more?: string | IslandOptions,
|
|
289
331
|
): IslandEntry<P> {
|
|
332
|
+
const { next, strings } =
|
|
333
|
+
typeof more === "string" ? { next: more, strings: undefined } : (more ?? {});
|
|
290
334
|
return {
|
|
291
335
|
key,
|
|
292
336
|
type: descriptor.type,
|
|
@@ -294,9 +338,16 @@ export function island<P>(
|
|
|
294
338
|
status: "ok",
|
|
295
339
|
props,
|
|
296
340
|
...(next !== undefined && { next }),
|
|
341
|
+
...(strings !== undefined && { strings }),
|
|
297
342
|
};
|
|
298
343
|
}
|
|
299
344
|
|
|
345
|
+
/** The language a screen is in, and its plural rules (spec §4.7). */
|
|
346
|
+
export interface ScreenLanguage {
|
|
347
|
+
locale: string;
|
|
348
|
+
plurals: PluralRules;
|
|
349
|
+
}
|
|
350
|
+
|
|
300
351
|
/** An island that failed on the composer (spec §3.4). */
|
|
301
352
|
export function islandError(
|
|
302
353
|
key: string,
|
|
@@ -312,23 +363,121 @@ export function islandError(
|
|
|
312
363
|
};
|
|
313
364
|
}
|
|
314
365
|
|
|
315
|
-
/** A screen
|
|
366
|
+
/** A screen, all of it at once: its stream's head with every island, then its end (spec §4.6). */
|
|
316
367
|
export function screen(
|
|
317
368
|
id: string,
|
|
318
369
|
islands: IslandEntry<unknown>[],
|
|
319
370
|
meta?: ScreenMeta,
|
|
371
|
+
language?: ScreenLanguage,
|
|
320
372
|
): FakeResponse {
|
|
373
|
+
const head = {
|
|
374
|
+
kind: "head",
|
|
375
|
+
protocol: PROTOCOL_VERSION,
|
|
376
|
+
screen: id,
|
|
377
|
+
islands,
|
|
378
|
+
...(meta && { meta }),
|
|
379
|
+
...language,
|
|
380
|
+
};
|
|
321
381
|
return {
|
|
322
382
|
status: 200,
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
383
|
+
stream: () => linesStream([head, { kind: "end" }]),
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** An island still being resolved, in a streamed screen's head (spec §4.6). */
|
|
388
|
+
export function pending(key: string, descriptor: IslandDescriptor<unknown>): PendingIsland {
|
|
389
|
+
return {
|
|
390
|
+
key,
|
|
391
|
+
type: descriptor.type,
|
|
392
|
+
version: descriptor.version,
|
|
393
|
+
status: "pending",
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** A streamed screen whose lines a test sends, so it controls when each island arrives. */
|
|
398
|
+
export interface StreamedScreen extends FakeResponse {
|
|
399
|
+
/** Sends a pending island's entry. */
|
|
400
|
+
send(entry: IslandEntry<unknown>): void;
|
|
401
|
+
/** Sends the end line. */
|
|
402
|
+
end(): void;
|
|
403
|
+
/** Ends the stream without its end line, as a dropped connection would. */
|
|
404
|
+
cut(): void;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* A screen whose head is sent at once, with its islands resolved or `pending(…)`, and whose other
|
|
409
|
+
* lines are sent when the test says (spec §4.6). Answer one request with it.
|
|
410
|
+
*
|
|
411
|
+
* const results = streamedScreen("search", [island("box", BoxV1, {}), pending("results", ResultsV1)]);
|
|
412
|
+
* // render, check the placeholder, then:
|
|
413
|
+
* results.send(island("results", ResultsV1, { items: [] }));
|
|
414
|
+
* results.end();
|
|
415
|
+
*/
|
|
416
|
+
export function streamedScreen(
|
|
417
|
+
id: string,
|
|
418
|
+
islands: (IslandEntry<unknown> | PendingIsland)[],
|
|
419
|
+
meta?: ScreenMeta,
|
|
420
|
+
language?: ScreenLanguage,
|
|
421
|
+
): StreamedScreen {
|
|
422
|
+
const encoder = new TextEncoder();
|
|
423
|
+
const queued: string[] = [];
|
|
424
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
425
|
+
let closed = false;
|
|
426
|
+
const write = (line: unknown) => {
|
|
427
|
+
const text = `${JSON.stringify(line)}\n`;
|
|
428
|
+
if (controller) {
|
|
429
|
+
controller.enqueue(encoder.encode(text));
|
|
430
|
+
} else {
|
|
431
|
+
queued.push(text);
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
const close = () => {
|
|
435
|
+
closed = true;
|
|
436
|
+
controller?.close();
|
|
437
|
+
};
|
|
438
|
+
write({
|
|
439
|
+
kind: "head",
|
|
440
|
+
protocol: PROTOCOL_VERSION,
|
|
441
|
+
screen: id,
|
|
442
|
+
islands,
|
|
443
|
+
...(meta && { meta }),
|
|
444
|
+
...language,
|
|
445
|
+
});
|
|
446
|
+
return {
|
|
447
|
+
status: 200,
|
|
448
|
+
stream: () =>
|
|
449
|
+
new ReadableStream<Uint8Array>({
|
|
450
|
+
start(c) {
|
|
451
|
+
controller = c;
|
|
452
|
+
for (const text of queued.splice(0)) {
|
|
453
|
+
c.enqueue(encoder.encode(text));
|
|
454
|
+
}
|
|
455
|
+
if (closed) {
|
|
456
|
+
c.close();
|
|
457
|
+
}
|
|
458
|
+
},
|
|
459
|
+
}),
|
|
460
|
+
send: (entry) => write({ kind: "island", island: entry }),
|
|
461
|
+
end: () => {
|
|
462
|
+
write({ kind: "end" });
|
|
463
|
+
close();
|
|
328
464
|
},
|
|
465
|
+
cut: close,
|
|
329
466
|
};
|
|
330
467
|
}
|
|
331
468
|
|
|
469
|
+
function linesStream(lines: unknown[]): ReadableStream<Uint8Array> {
|
|
470
|
+
const encoder = new TextEncoder();
|
|
471
|
+
return new ReadableStream({
|
|
472
|
+
start(controller) {
|
|
473
|
+
for (const line of lines) {
|
|
474
|
+
controller.enqueue(encoder.encode(`${JSON.stringify(line)}\n`));
|
|
475
|
+
}
|
|
476
|
+
controller.close();
|
|
477
|
+
},
|
|
478
|
+
});
|
|
479
|
+
}
|
|
480
|
+
|
|
332
481
|
/** An island response (spec §4.5): a page of one island, or the island resolved again. */
|
|
333
482
|
export function islandResponse(screen: string, entry: IslandEntry<unknown>): FakeResponse {
|
|
334
483
|
return {
|
|
@@ -341,6 +490,23 @@ export function islandResponse(screen: string, entry: IslandEntry<unknown>): Fak
|
|
|
341
490
|
};
|
|
342
491
|
}
|
|
343
492
|
|
|
493
|
+
/** The app strings in a language (spec §4.7), with its plural rules if its counted strings need them. */
|
|
494
|
+
export function stringsResponse(
|
|
495
|
+
locale: string,
|
|
496
|
+
strings: Strings,
|
|
497
|
+
plurals: PluralRules = {},
|
|
498
|
+
): FakeResponse {
|
|
499
|
+
return {
|
|
500
|
+
status: 200,
|
|
501
|
+
body: {
|
|
502
|
+
protocol: PROTOCOL_VERSION,
|
|
503
|
+
locale,
|
|
504
|
+
plurals,
|
|
505
|
+
strings,
|
|
506
|
+
},
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
344
510
|
/** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
|
|
345
511
|
export function screenError(status: number, code: string): FakeResponse {
|
|
346
512
|
return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
|