@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/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
- /** A response for the fake composer to send. */
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: unknown;
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
- next?: string,
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 response. */
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
- body: {
324
- protocol: PROTOCOL_VERSION,
325
- screen: id,
326
- islands,
327
- ...(meta && { meta }),
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 } } };