@client-compose/client 0.3.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/README.md CHANGED
@@ -66,6 +66,25 @@ Props aren't validated at runtime; generate types with
66
66
  [`@client-compose/codegen`](https://www.npmjs.com/package/@client-compose/codegen) to keep them in
67
67
  sync with the composer.
68
68
 
69
+ ## Testing
70
+
71
+ `@client-compose/client/testing` is a fake composer for tests, with no test framework of its own.
72
+ `createFakeGateway` answers screen and action requests from handlers and records them; pass its
73
+ `fetch` to the client. Build responses with `island`, `islandError`, `screen`, `screenError`,
74
+ `actionOk`, `actionError` and `actionFailure`, typed by the generated descriptors.
75
+
76
+ ```ts
77
+ import { actionError, createFakeGateway } from "@client-compose/client/testing";
78
+
79
+ const gateway = createFakeGateway({
80
+ actions: { follow: () => actionError(FollowUserV1, { code: "blocked" }) },
81
+ });
82
+ const res = await callAction(baseUrl, FollowUserV1, { userId }, { client, fetch: gateway.fetch });
83
+ assert.equal(gateway.actionRequests()[0].input.userId, userId);
84
+ ```
85
+
86
+ For React, `@client-compose/react/testing` adds a provider config built around it.
87
+
69
88
  ## Links
70
89
 
71
90
  - [Protocol spec](https://github.com/client-compose/client-compose/blob/main/spec/PROTOCOL.md)
@@ -0,0 +1,61 @@
1
+ import { type ActionDescriptor, type ActionEffects, type Capabilities, type ClientContext, type DeclaredError, type IslandDescriptor, type IslandEntry, type IslandError, type ScreenMeta } from "@client-compose/protocol";
2
+ /** A response for the fake composer to send. */
3
+ export interface FakeResponse {
4
+ status: number;
5
+ body: unknown;
6
+ }
7
+ /** A request the fake composer received. */
8
+ export interface RecordedRequest {
9
+ url: string;
10
+ method: string;
11
+ /** Header names are lower case. */
12
+ headers: Record<string, string>;
13
+ /** The JSON body, parsed; or the raw body when it isn't JSON (e.g. a multipart upload). */
14
+ body: unknown;
15
+ }
16
+ export interface RecordedScreenRequest extends RecordedRequest {
17
+ screen: string;
18
+ params: Record<string, string>;
19
+ client: ClientContext | undefined;
20
+ islands: Capabilities;
21
+ }
22
+ export interface RecordedActionRequest extends RecordedRequest {
23
+ action: string;
24
+ version: number;
25
+ input: unknown;
26
+ client: ClientContext | undefined;
27
+ idempotencyKey: string | undefined;
28
+ }
29
+ export interface FakeGatewayHandlers {
30
+ /** By screen id. */
31
+ screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
32
+ /** By action name. */
33
+ actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
34
+ /** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
35
+ other?: (request: RecordedRequest) => FakeResponse | undefined;
36
+ }
37
+ export interface FakeGateway {
38
+ /** Use as the client's `fetch`. Requests without a handler reject, failing the test. */
39
+ fetch: typeof fetch;
40
+ /** Every request received, in order. */
41
+ requests: RecordedRequest[];
42
+ screenRequests(): RecordedScreenRequest[];
43
+ actionRequests(): RecordedActionRequest[];
44
+ }
45
+ /** A composer whose screens and actions are the handlers given. */
46
+ export declare function createFakeGateway(handlers?: FakeGatewayHandlers): FakeGateway;
47
+ /** An island in a screen response, typed by its generated descriptor. */
48
+ export declare function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P>;
49
+ /** An island that failed on the composer (spec §3.4). */
50
+ export declare function islandError(key: string, descriptor: IslandDescriptor<unknown>, error: IslandError): IslandEntry;
51
+ /** A screen response. */
52
+ export declare function screen(id: string, islands: IslandEntry<unknown>[], meta?: ScreenMeta): FakeResponse;
53
+ /** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
54
+ export declare function screenError(status: number, code: string): FakeResponse;
55
+ /** An action that succeeded, with its result typed by the descriptor. */
56
+ export declare function actionOk<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, result: R, effects?: ActionEffects): FakeResponse;
57
+ /** An action that ended with one of its declared errors. */
58
+ export declare function actionError<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, error: E): FakeResponse;
59
+ /** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
60
+ export declare function actionFailure(status: number, code: string): FakeResponse;
61
+ //# sourceMappingURL=testing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.d.ts","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,143 @@
1
+ // Test helpers for apps built on Client Compose: a fake composer that answers screen and action
2
+ // requests from handlers and records them, and builders for the responses it returns. No test
3
+ // framework of its own, so it works with node:test, Jest, Vitest, Playwright and others.
4
+ //
5
+ // const gateway = createFakeGateway({
6
+ // screens: { profile: () => screen("profile", [island("header", ProfileHeaderV1, { name: "Ada" })]) },
7
+ // actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }) },
8
+ // });
9
+ // // pass gateway.fetch as the client's fetch, then assert on gateway.actionRequests()
10
+ import { PROTOCOL_VERSION, } from "@client-compose/protocol";
11
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
12
+ function strings(value) {
13
+ if (!isRecord(value))
14
+ return {};
15
+ return Object.fromEntries(Object.entries(value).filter((e) => typeof e[1] === "string"));
16
+ }
17
+ function capabilities(value) {
18
+ if (!isRecord(value))
19
+ return {};
20
+ return Object.fromEntries(Object.entries(value).map(([type, versions]) => [
21
+ type,
22
+ Array.isArray(versions) ? versions.filter((v) => typeof v === "number") : [],
23
+ ]));
24
+ }
25
+ function clientContext(value) {
26
+ if (!isRecord(value) || typeof value.platform !== "string" || typeof value.appVersion !== "string")
27
+ return undefined;
28
+ return { ...value, platform: value.platform, appVersion: value.appVersion };
29
+ }
30
+ function parseBody(body) {
31
+ if (typeof body !== "string")
32
+ return body;
33
+ try {
34
+ return JSON.parse(body);
35
+ }
36
+ catch {
37
+ return body;
38
+ }
39
+ }
40
+ function urlOf(input) {
41
+ if (typeof input === "string")
42
+ return input;
43
+ return input instanceof URL ? input.href : input.url;
44
+ }
45
+ function headersOf(init) {
46
+ const out = {};
47
+ new Headers(init).forEach((value, name) => (out[name] = value));
48
+ return out;
49
+ }
50
+ function asScreenRequest(request) {
51
+ const { body } = request;
52
+ if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string")
53
+ return undefined;
54
+ return {
55
+ ...request,
56
+ screen: body.screen,
57
+ params: strings(body.params),
58
+ client: clientContext(body.client),
59
+ islands: capabilities(body.islands),
60
+ };
61
+ }
62
+ function asActionRequest(request) {
63
+ const { body } = request;
64
+ if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string")
65
+ return undefined;
66
+ return {
67
+ ...request,
68
+ action: body.action,
69
+ version: typeof body.version === "number" ? body.version : 0,
70
+ input: body.input,
71
+ client: clientContext(body.client),
72
+ idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined,
73
+ };
74
+ }
75
+ function respond(response) {
76
+ return new Response(JSON.stringify(response.body), {
77
+ status: response.status,
78
+ headers: { "content-type": "application/json" },
79
+ });
80
+ }
81
+ /** A composer whose screens and actions are the handlers given. */
82
+ export function createFakeGateway(handlers = {}) {
83
+ const requests = [];
84
+ async function fakeFetch(input, init) {
85
+ const request = {
86
+ url: urlOf(input),
87
+ method: init?.method ?? "GET",
88
+ headers: headersOf(init?.headers),
89
+ body: parseBody(init?.body),
90
+ };
91
+ requests.push(request);
92
+ const other = handlers.other?.(request);
93
+ if (other)
94
+ return respond(other);
95
+ const screenRequest = asScreenRequest(request);
96
+ const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
97
+ if (screenRequest && screenHandler)
98
+ return respond(screenHandler(screenRequest));
99
+ const actionRequest = asActionRequest(request);
100
+ const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
101
+ if (actionRequest && actionHandler)
102
+ return respond(actionHandler(actionRequest));
103
+ throw new Error(`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`);
104
+ }
105
+ return {
106
+ fetch: fakeFetch,
107
+ requests,
108
+ screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
109
+ actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
110
+ };
111
+ }
112
+ // ---- Responses ----
113
+ /** An island in a screen response, typed by its generated descriptor. */
114
+ export function island(key, descriptor, props) {
115
+ return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
116
+ }
117
+ /** An island that failed on the composer (spec §3.4). */
118
+ export function islandError(key, descriptor, error) {
119
+ return { key, type: descriptor.type, version: descriptor.version, status: "error", error };
120
+ }
121
+ /** A screen response. */
122
+ export function screen(id, islands, meta) {
123
+ return { status: 200, body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) } };
124
+ }
125
+ /** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
126
+ export function screenError(status, code) {
127
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
128
+ }
129
+ /** An action that succeeded, with its result typed by the descriptor. */
130
+ export function actionOk(action, result, effects) {
131
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
132
+ return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
133
+ }
134
+ /** An action that ended with one of its declared errors. */
135
+ export function actionError(action, error) {
136
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
137
+ return { status: 200, body: { ...base, status: "error", error } };
138
+ }
139
+ /** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
140
+ export function actionFailure(status, code) {
141
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
142
+ }
143
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"testing.js","sourceRoot":"","sources":["../src/testing.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@client-compose/client",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Framework-agnostic Client Compose client: island registry, screen requests and render planning.",
5
5
  "keywords": [
6
6
  "client-compose",
@@ -26,6 +26,10 @@
26
26
  "types": "./dist/index.d.ts",
27
27
  "default": "./dist/index.js"
28
28
  },
29
+ "./testing": {
30
+ "types": "./dist/testing.d.ts",
31
+ "default": "./dist/testing.js"
32
+ },
29
33
  "./package.json": "./package.json"
30
34
  },
31
35
  "files": [
package/src/testing.ts ADDED
@@ -0,0 +1,219 @@
1
+ // Test helpers for apps built on Client Compose: a fake composer that answers screen and action
2
+ // requests from handlers and records them, and builders for the responses it returns. No test
3
+ // framework of its own, so it works with node:test, Jest, Vitest, Playwright and others.
4
+ //
5
+ // const gateway = createFakeGateway({
6
+ // screens: { profile: () => screen("profile", [island("header", ProfileHeaderV1, { name: "Ada" })]) },
7
+ // actions: { follow: () => actionOk(FollowUserV1, { followers: 8 }) },
8
+ // });
9
+ // // pass gateway.fetch as the client's fetch, then assert on gateway.actionRequests()
10
+
11
+ import {
12
+ PROTOCOL_VERSION,
13
+ type ActionDescriptor,
14
+ type ActionEffects,
15
+ type Capabilities,
16
+ type ClientContext,
17
+ type DeclaredError,
18
+ type IslandDescriptor,
19
+ type IslandEntry,
20
+ type IslandError,
21
+ type ScreenMeta,
22
+ } from "@client-compose/protocol";
23
+
24
+ /** A response for the fake composer to send. */
25
+ export interface FakeResponse {
26
+ status: number;
27
+ body: unknown;
28
+ }
29
+
30
+ /** A request the fake composer received. */
31
+ export interface RecordedRequest {
32
+ url: string;
33
+ method: string;
34
+ /** Header names are lower case. */
35
+ headers: Record<string, string>;
36
+ /** The JSON body, parsed; or the raw body when it isn't JSON (e.g. a multipart upload). */
37
+ body: unknown;
38
+ }
39
+
40
+ export interface RecordedScreenRequest extends RecordedRequest {
41
+ screen: string;
42
+ params: Record<string, string>;
43
+ client: ClientContext | undefined;
44
+ islands: Capabilities;
45
+ }
46
+
47
+ export interface RecordedActionRequest extends RecordedRequest {
48
+ action: string;
49
+ version: number;
50
+ input: unknown;
51
+ client: ClientContext | undefined;
52
+ idempotencyKey: string | undefined;
53
+ }
54
+
55
+ export interface FakeGatewayHandlers {
56
+ /** By screen id. */
57
+ screens?: Record<string, (request: RecordedScreenRequest) => FakeResponse>;
58
+ /** By action name. */
59
+ actions?: Record<string, (request: RecordedActionRequest) => FakeResponse>;
60
+ /** Anything else, e.g. uploads to storage: return undefined to leave a request to the others. */
61
+ other?: (request: RecordedRequest) => FakeResponse | undefined;
62
+ }
63
+
64
+ export interface FakeGateway {
65
+ /** Use as the client's `fetch`. Requests without a handler reject, failing the test. */
66
+ fetch: typeof fetch;
67
+ /** Every request received, in order. */
68
+ requests: RecordedRequest[];
69
+ screenRequests(): RecordedScreenRequest[];
70
+ actionRequests(): RecordedActionRequest[];
71
+ }
72
+
73
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
74
+ typeof value === "object" && value !== null && !Array.isArray(value);
75
+
76
+ function strings(value: unknown): Record<string, string> {
77
+ if (!isRecord(value)) return {};
78
+ return Object.fromEntries(Object.entries(value).filter((e): e is [string, string] => typeof e[1] === "string"));
79
+ }
80
+
81
+ function capabilities(value: unknown): Capabilities {
82
+ if (!isRecord(value)) return {};
83
+ return Object.fromEntries(
84
+ Object.entries(value).map(([type, versions]) => [
85
+ type,
86
+ Array.isArray(versions) ? versions.filter((v): v is number => typeof v === "number") : [],
87
+ ]),
88
+ );
89
+ }
90
+
91
+ function clientContext(value: unknown): ClientContext | undefined {
92
+ if (!isRecord(value) || typeof value.platform !== "string" || typeof value.appVersion !== "string") return undefined;
93
+ return { ...value, platform: value.platform, appVersion: value.appVersion } as ClientContext;
94
+ }
95
+
96
+ function parseBody(body: unknown): unknown {
97
+ if (typeof body !== "string") return body;
98
+ try {
99
+ return JSON.parse(body) as unknown;
100
+ } catch {
101
+ return body;
102
+ }
103
+ }
104
+
105
+ function urlOf(input: RequestInfo | URL): string {
106
+ if (typeof input === "string") return input;
107
+ return input instanceof URL ? input.href : input.url;
108
+ }
109
+
110
+ function headersOf(init: HeadersInit | undefined): Record<string, string> {
111
+ const out: Record<string, string> = {};
112
+ new Headers(init).forEach((value, name) => (out[name] = value));
113
+ return out;
114
+ }
115
+
116
+ function asScreenRequest(request: RecordedRequest): RecordedScreenRequest | undefined {
117
+ const { body } = request;
118
+ if (!request.url.includes("/screens/") || !isRecord(body) || typeof body.screen !== "string") return undefined;
119
+ return {
120
+ ...request,
121
+ screen: body.screen,
122
+ params: strings(body.params),
123
+ client: clientContext(body.client),
124
+ islands: capabilities(body.islands),
125
+ };
126
+ }
127
+
128
+ function asActionRequest(request: RecordedRequest): RecordedActionRequest | undefined {
129
+ const { body } = request;
130
+ if (!request.url.includes("/actions/") || !isRecord(body) || typeof body.action !== "string") return undefined;
131
+ return {
132
+ ...request,
133
+ action: body.action,
134
+ version: typeof body.version === "number" ? body.version : 0,
135
+ input: body.input,
136
+ client: clientContext(body.client),
137
+ idempotencyKey: typeof body.idempotencyKey === "string" ? body.idempotencyKey : undefined,
138
+ };
139
+ }
140
+
141
+ function respond(response: FakeResponse): Response {
142
+ return new Response(JSON.stringify(response.body), {
143
+ status: response.status,
144
+ headers: { "content-type": "application/json" },
145
+ });
146
+ }
147
+
148
+ /** A composer whose screens and actions are the handlers given. */
149
+ export function createFakeGateway(handlers: FakeGatewayHandlers = {}): FakeGateway {
150
+ const requests: RecordedRequest[] = [];
151
+ async function fakeFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
152
+ const request: RecordedRequest = {
153
+ url: urlOf(input),
154
+ method: init?.method ?? "GET",
155
+ headers: headersOf(init?.headers),
156
+ body: parseBody(init?.body),
157
+ };
158
+ requests.push(request);
159
+
160
+ const other = handlers.other?.(request);
161
+ if (other) return respond(other);
162
+ const screenRequest = asScreenRequest(request);
163
+ const screenHandler = screenRequest && handlers.screens?.[screenRequest.screen];
164
+ if (screenRequest && screenHandler) return respond(screenHandler(screenRequest));
165
+ const actionRequest = asActionRequest(request);
166
+ const actionHandler = actionRequest && handlers.actions?.[actionRequest.action];
167
+ if (actionRequest && actionHandler) return respond(actionHandler(actionRequest));
168
+ throw new Error(`fake gateway: no handler for ${request.method} ${request.url} ${JSON.stringify(request.body)}`);
169
+ }
170
+ return {
171
+ fetch: fakeFetch,
172
+ requests,
173
+ screenRequests: () => requests.flatMap((r) => asScreenRequest(r) ?? []),
174
+ actionRequests: () => requests.flatMap((r) => asActionRequest(r) ?? []),
175
+ };
176
+ }
177
+
178
+ // ---- Responses ----
179
+
180
+ /** An island in a screen response, typed by its generated descriptor. */
181
+ export function island<P>(key: string, descriptor: IslandDescriptor<P>, props: P): IslandEntry<P> {
182
+ return { key, type: descriptor.type, version: descriptor.version, status: "ok", props };
183
+ }
184
+
185
+ /** An island that failed on the composer (spec §3.4). */
186
+ export function islandError(key: string, descriptor: IslandDescriptor<unknown>, error: IslandError): IslandEntry {
187
+ return { key, type: descriptor.type, version: descriptor.version, status: "error", error };
188
+ }
189
+
190
+ /** A screen response. */
191
+ export function screen(id: string, islands: IslandEntry<unknown>[], meta?: ScreenMeta): FakeResponse {
192
+ return { status: 200, body: { protocol: PROTOCOL_VERSION, screen: id, islands, ...(meta && { meta }) } };
193
+ }
194
+
195
+ /** A screen request that fails, e.g. `screenError(404, "not_found")` or `screenError(401, "unauthenticated")`. */
196
+ export function screenError(status: number, code: string): FakeResponse {
197
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
198
+ }
199
+
200
+ /** An action that succeeded, with its result typed by the descriptor. */
201
+ export function actionOk<I, R, E extends DeclaredError>(
202
+ action: ActionDescriptor<I, R, E>,
203
+ result: R,
204
+ effects?: ActionEffects,
205
+ ): FakeResponse {
206
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
207
+ return { status: 200, body: { ...base, status: "ok", result, ...(effects && { effects }) } };
208
+ }
209
+
210
+ /** An action that ended with one of its declared errors. */
211
+ export function actionError<I, R, E extends DeclaredError>(action: ActionDescriptor<I, R, E>, error: E): FakeResponse {
212
+ const base = { protocol: PROTOCOL_VERSION, action: action.action, version: action.version };
213
+ return { status: 200, body: { ...base, status: "error", error } };
214
+ }
215
+
216
+ /** An action the composer didn't perform, e.g. `actionFailure(401, "unauthenticated")` (spec §5.4). */
217
+ export function actionFailure(status: number, code: string): FakeResponse {
218
+ return { status, body: { protocol: PROTOCOL_VERSION, error: { code } } };
219
+ }