@client-compose/client 0.5.1 → 0.7.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
@@ -5,6 +5,7 @@
5
5
  // const gateway = createFakeGateway({
6
6
  // screens: { profile: () => screen("profile", [island("header", ProfileHeaderV1, { name: "Ada" })]) },
7
7
  // actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }) },
8
+ // islands: { "profile/posts": (r) => islandResponse("profile", island("posts", PostsV1, …, "page:3")) },
8
9
  // });
9
10
  // // pass gateway.fetch as the client's fetch, then assert on gateway.actionRequests()
10
11
  //
@@ -46,6 +47,16 @@ export interface RecordedScreenRequest extends RecordedRequest {
46
47
  islands: Capabilities;
47
48
  }
48
49
 
50
+ /** An island request (spec §4.5): a page of one island, or the island resolved again. */
51
+ export interface RecordedIslandRequest extends RecordedRequest {
52
+ screen: string;
53
+ key: string;
54
+ version: number;
55
+ params: Record<string, string>;
56
+ cursor: string | undefined;
57
+ client: ClientContext | undefined;
58
+ }
59
+
49
60
  export interface RecordedActionRequest extends RecordedRequest {
50
61
  action: string;
51
62
  version: number;
@@ -59,6 +70,8 @@ export interface FakeGatewayHandlers {
59
70
  screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
60
71
  /** By action name. */
61
72
  actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
73
+ /** Island requests, by `"{screen}/{key}"`. */
74
+ islands?: Record<string, (request: RecordedIslandRequest) => FakeResponse>;
62
75
  /** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
63
76
  other?: (request: RecordedRequest) => FakeResponse | undefined;
64
77
  }
@@ -69,6 +82,7 @@ export interface FakeGateway {
69
82
  /** Every request received, in order. */
70
83
  requests: RecordedRequest[];
71
84
  screenRequests(): RecordedScreenRequest[];
85
+ islandRequests(): RecordedIslandRequest[];
72
86
  actionRequests(): RecordedActionRequest[];
73
87
  }
74
88
 
@@ -76,14 +90,18 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
76
90
  typeof value === "object" && value !== null && !Array.isArray(value);
77
91
 
78
92
  function strings(value: unknown): Record<string, string> {
79
- if (!isRecord(value)) return {};
93
+ if (!isRecord(value)) {
94
+ return {};
95
+ }
80
96
  return Object.fromEntries(
81
97
  Object.entries(value).filter((e): e is [string, string] => typeof e[1] === "string"),
82
98
  );
83
99
  }
84
100
 
85
101
  function capabilities(value: unknown): Capabilities {
86
- if (!isRecord(value)) return {};
102
+ if (!isRecord(value)) {
103
+ return {};
104
+ }
87
105
  return Object.fromEntries(
88
106
  Object.entries(value).map(([type, versions]) => [
89
107
  type,
@@ -97,13 +115,20 @@ function clientContext(value: unknown): ClientContext | undefined {
97
115
  !isRecord(value) ||
98
116
  typeof value.platform !== "string" ||
99
117
  typeof value.appVersion !== "string"
100
- )
118
+ ) {
101
119
  return undefined;
102
- return { ...value, platform: value.platform, appVersion: value.appVersion } as ClientContext;
120
+ }
121
+ return {
122
+ ...value,
123
+ platform: value.platform,
124
+ appVersion: value.appVersion,
125
+ } as ClientContext;
103
126
  }
104
127
 
105
128
  function parseBody(body: unknown): unknown {
106
- if (typeof body !== "string") return body;
129
+ if (typeof body !== "string") {
130
+ return body;
131
+ }
107
132
  try {
108
133
  return JSON.parse(body) as unknown;
109
134
  } catch {
@@ -112,7 +137,9 @@ function parseBody(body: unknown): unknown {
112
137
  }
113
138
 
114
139
  function urlOf(input: RequestInfo | URL): string {
115
- if (typeof input === "string") return input;
140
+ if (typeof input === "string") {
141
+ return input;
142
+ }
116
143
  return input instanceof URL ? input.href : input.url;
117
144
  }
118
145
 
@@ -122,10 +149,16 @@ function headersOf(init: HeadersInit | undefined): Record<string, string> {
122
149
  return out;
123
150
  }
124
151
 
152
+ function isIslandURL(url: string): boolean {
153
+ return /\/screens\/[^/]+\/islands\/[^/?]+/.test(url);
154
+ }
155
+
125
156
  function asScreenRequest(request: RecordedRequest): RecordedScreenRequest | undefined {
126
157
  const { body } = request;
127
- if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string")
158
+ const isScreen = request.url.includes("/screens/") && !isIslandURL(request.url);
159
+ if (!isScreen || !isRecord(body) || typeof body.screen !== "string") {
128
160
  return undefined;
161
+ }
129
162
  return {
130
163
  ...request,
131
164
  screen: body.screen,
@@ -135,10 +168,30 @@ function asScreenRequest(request: RecordedRequest): RecordedScreenRequest | unde
135
168
  };
136
169
  }
137
170
 
171
+ function asIslandRequest(request: RecordedRequest): RecordedIslandRequest | undefined {
172
+ const { body } = request;
173
+ if (!isIslandURL(request.url) || !isRecord(body)) {
174
+ return undefined;
175
+ }
176
+ if (typeof body.screen !== "string" || typeof body.key !== "string") {
177
+ return undefined;
178
+ }
179
+ return {
180
+ ...request,
181
+ screen: body.screen,
182
+ key: body.key,
183
+ version: typeof body.version === "number" ? body.version : 0,
184
+ params: strings(body.params),
185
+ cursor: typeof body.cursor === "string" ? body.cursor : undefined,
186
+ client: clientContext(body.client),
187
+ };
188
+ }
189
+
138
190
  function asActionRequest(request: RecordedRequest): RecordedActionRequest | undefined {
139
191
  const { body } = request;
140
- if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string")
192
+ if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string") {
141
193
  return undefined;
194
+ }
142
195
  return {
143
196
  ...request,
144
197
  action: body.action,
@@ -169,13 +222,25 @@ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGatew
169
222
  requests.push(request);
170
223
 
171
224
  const other = handlers.other?.(request);
172
- if (other) return respond(other);
225
+ if (other) {
226
+ return respond(other);
227
+ }
228
+ const islandRequest = asIslandRequest(request);
229
+ const islandHandler =
230
+ islandRequest && handlers.islands?.[`${islandRequest.screen}/${islandRequest.key}`];
231
+ if (islandRequest && islandHandler) {
232
+ return respond(islandHandler(islandRequest));
233
+ }
173
234
  const screenRequest = asScreenRequest(request);
174
235
  const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
175
- if (screenRequest && screenHandler) return respond(screenHandler(screenRequest));
236
+ if (screenRequest && screenHandler) {
237
+ return respond(screenHandler(screenRequest));
238
+ }
176
239
  const actionRequest = asActionRequest(request);
177
240
  const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
178
- if (actionRequest && actionHandler) return respond(actionHandler(actionRequest));
241
+ if (actionRequest && actionHandler) {
242
+ return respond(actionHandler(actionRequest));
243
+ }
179
244
  throw new Error(
180
245
  `fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`,
181
246
  );
@@ -184,6 +249,7 @@ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGatew
184
249
  fetch: fakeFetch,
185
250
  requests,
186
251
  screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
252
+ islandRequests: () => requests.flatMap((r) => asIslandRequest(r) ?? []),
187
253
  actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
188
254
  };
189
255
  }
@@ -211,9 +277,24 @@ export function installFakeGateway(handlers: FakeGatewayHandlers = {}): Installe
211
277
 
212
278
  // ---- Responses ----
213
279
 
214
- /** An island in a screen response, typed by its generated descriptor. */
215
- export function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P> {
216
- return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
280
+ /**
281
+ * 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).
283
+ */
284
+ export function island<P>(
285
+ key: string,
286
+ descriptor: IslandDescriptor<P>,
287
+ props: P,
288
+ next?: string,
289
+ ): IslandEntry<P> {
290
+ return {
291
+ key,
292
+ type: descriptor.type,
293
+ version: descriptor.version,
294
+ status: "ok",
295
+ props,
296
+ ...(next !== undefined && { next }),
297
+ };
217
298
  }
218
299
 
219
300
  /** An island that failed on the composer (spec §3.4). */
@@ -222,7 +303,13 @@ export function islandError(
222
303
  descriptor: IslandDescriptor<unknown>,
223
304
  error: IslandError,
224
305
  ): IslandEntry {
225
- return { key, type: descriptor.type, version: descriptor.version, status: "error", error };
306
+ return {
307
+ key,
308
+ type: descriptor.type,
309
+ version: descriptor.version,
310
+ status: "error",
311
+ error,
312
+ };
226
313
  }
227
314
 
228
315
  /** A screen response. */
@@ -233,7 +320,24 @@ export function screen(
233
320
  ): FakeResponse {
234
321
  return {
235
322
  status: 200,
236
- body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) },
323
+ body: {
324
+ protocol: PROTOCOL_VERSION,
325
+ screen: id,
326
+ islands,
327
+ ...(meta && { meta }),
328
+ },
329
+ };
330
+ }
331
+
332
+ /** An island response (spec §4.5): a page of one island, or the island resolved again. */
333
+ export function islandResponse(screen: string, entry: IslandEntry<unknown>): FakeResponse {
334
+ return {
335
+ status: 200,
336
+ body: {
337
+ protocol: PROTOCOL_VERSION,
338
+ screen,
339
+ island: entry,
340
+ },
237
341
  };
238
342
  }
239
343
 
@@ -248,8 +352,20 @@ export function actionOk<I, R, E extends DeclaredError>(
248
352
  result: R,
249
353
  effects?: ActionEffects,
250
354
  ): FakeResponse {
251
- const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
252
- return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
355
+ const base = {
356
+ protocol: PROTOCOL_VERSION,
357
+ action: action.action,
358
+ version: action.version,
359
+ };
360
+ return {
361
+ status: 200,
362
+ body: {
363
+ ...base,
364
+ status: "ok",
365
+ result,
366
+ ...(effects && { effects }),
367
+ },
368
+ };
253
369
  }
254
370
 
255
371
  /** An action that ended with one of its declared errors. */
@@ -257,8 +373,19 @@ export function actionError<I, R, E extends DeclaredError>(
257
373
  action: ActionDescriptor<I, R, E>,
258
374
  error: E,
259
375
  ): FakeResponse {
260
- const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
261
- return { status: 200, body: { ...base, status: "error", error } };
376
+ const base = {
377
+ protocol: PROTOCOL_VERSION,
378
+ action: action.action,
379
+ version: action.version,
380
+ };
381
+ return {
382
+ status: 200,
383
+ body: {
384
+ ...base,
385
+ status: "error",
386
+ error,
387
+ },
388
+ };
262
389
  }
263
390
 
264
391
  /** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */