@gjermundgaraba/effect-actions 0.10.0 → 0.12.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 +103 -43
- package/dist/Action.d.mts +31 -118
- package/dist/Action.mjs +15 -53
- package/dist/ActionCli.d.mts +60 -28
- package/dist/ActionCli.mjs +54 -48
- package/dist/ActionHttp.d.mts +18 -67
- package/dist/ActionHttp.mjs +20 -70
- package/dist/ActionMcp.d.mts +25 -31
- package/dist/ActionMcp.mjs +140 -74
- package/dist/ActionRpc.d.mts +147 -0
- package/dist/ActionRpc.mjs +113 -0
- package/dist/ActionToolkit.d.mts +5 -8
- package/dist/ActionToolkit.mjs +3 -3
- package/dist/Authentication.d.mts +15 -15
- package/dist/Authentication.mjs +42 -96
- package/dist/Testing.d.mts +32 -25
- package/dist/Testing.mjs +45 -30
- package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
- package/dist/authentication/provider.mjs +12 -0
- package/dist/authentication/refusal.mjs +36 -0
- package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
- package/dist/cli/commands.mjs +302 -0
- package/dist/{internal → contract}/call.d.mts +2 -2
- package/dist/contract/call.mjs +10 -0
- package/dist/{internal → contract}/errors.d.mts +13 -10
- package/dist/{internal → contract}/errors.mjs +9 -14
- package/dist/{internal → contract}/implementation.d.mts +37 -21
- package/dist/{internal → contract}/implementation.mjs +18 -49
- package/dist/contract/media.d.mts +13 -0
- package/dist/contract/media.mjs +18 -0
- package/dist/contract/rules.d.mts +25 -0
- package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
- package/dist/{internal → http}/client.d.mts +7 -9
- package/dist/http/client.mjs +17 -0
- package/dist/http/declared.mjs +20 -0
- package/dist/http/schema-errors.mjs +9 -0
- package/dist/mcp/protocol.d.mts +17 -0
- package/dist/mcp/protocol.mjs +149 -0
- package/dist/stdio/console.mjs +64 -0
- package/dist/{internal → testing}/memory.d.mts +1 -1
- package/dist/{internal → testing}/memory.mjs +5 -17
- package/dist/toolkit/tools.mjs +29 -0
- package/docs/Action-failures.md +47 -0
- package/docs/Action-in-process.md +93 -0
- package/docs/Action.md +206 -424
- package/docs/ActionCli-failures.md +102 -0
- package/docs/ActionCli.md +206 -292
- package/docs/ActionHttp-failures.md +83 -0
- package/docs/ActionHttp.md +202 -281
- package/docs/ActionMcp-failures.md +31 -0
- package/docs/ActionMcp.md +229 -193
- package/docs/ActionRpc-failures.md +21 -0
- package/docs/ActionRpc.md +178 -0
- package/docs/ActionToolkit-failures.md +15 -0
- package/docs/ActionToolkit.md +100 -117
- package/docs/Authentication-advanced.md +139 -0
- package/docs/Authentication-failures.md +99 -0
- package/docs/Authentication.md +142 -392
- package/docs/CONTEXT.md +39 -23
- package/docs/README.md +71 -35
- package/docs/Testing-failures.md +20 -0
- package/docs/Testing.md +186 -151
- package/docs/guarantees.md +35 -95
- package/docs/setup-failures.md +34 -0
- package/docs/setup.md +88 -87
- package/examples/README.md +68 -0
- package/examples/assistant/README.md +92 -0
- package/examples/assistant/src/agent.ts +92 -0
- package/examples/assistant/src/cli.ts +34 -0
- package/examples/assistant/src/commands.ts +25 -0
- package/examples/assistant/src/identity/Identity.ts +33 -0
- package/examples/assistant/src/identity/authorize.ts +24 -0
- package/examples/assistant/src/identity/handlers.ts +10 -0
- package/examples/assistant/src/identity/members.ts +9 -0
- package/examples/assistant/src/identity/permissions.ts +23 -0
- package/examples/assistant/src/scripted.ts +23 -0
- package/examples/assistant/src/tasks/Tasks.ts +63 -0
- package/examples/assistant/src/tasks/handlers.ts +25 -0
- package/examples/assistant/src/tasks/store.ts +96 -0
- package/examples/assistant/src/toolkit.ts +30 -0
- package/examples/assistant/test/commands.test.ts +57 -0
- package/examples/assistant/test/identity/identity.test.ts +43 -0
- package/examples/assistant/test/tasks/store.test.ts +48 -0
- package/examples/assistant/test/tasks/tasks.test.ts +75 -0
- package/examples/assistant/test/toolkit.test.ts +121 -0
- package/examples/kitchen-sink/README.md +317 -0
- package/examples/kitchen-sink/src/Actions.ts +19 -0
- package/examples/kitchen-sink/src/Api.ts +24 -0
- package/examples/kitchen-sink/src/Rpc.ts +12 -0
- package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
- package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
- package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
- package/examples/kitchen-sink/src/admin/grants.ts +22 -0
- package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
- package/examples/kitchen-sink/src/admin/keys.ts +8 -0
- package/examples/kitchen-sink/src/agent.ts +47 -0
- package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
- package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
- package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
- package/examples/kitchen-sink/src/assistant/handlers.ts +146 -0
- package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
- package/examples/kitchen-sink/src/attachments/download.ts +49 -0
- package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
- package/examples/kitchen-sink/src/attachments/store.ts +58 -0
- package/examples/kitchen-sink/src/browser.ts +33 -0
- package/examples/kitchen-sink/src/cli.ts +85 -0
- package/examples/kitchen-sink/src/config.ts +18 -0
- package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
- package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
- package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
- package/examples/kitchen-sink/src/identity/directory.ts +52 -0
- package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
- package/examples/kitchen-sink/src/identity/members.ts +13 -0
- package/examples/kitchen-sink/src/identity/quota.ts +62 -0
- package/examples/kitchen-sink/src/issues/Issues.ts +109 -0
- package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
- package/examples/kitchen-sink/src/issues/guide.ts +37 -0
- package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
- package/examples/kitchen-sink/src/issues/store.ts +96 -0
- package/examples/kitchen-sink/src/issues/write-limit.ts +41 -0
- package/examples/kitchen-sink/src/memory.ts +30 -0
- package/examples/kitchen-sink/src/ops.ts +42 -0
- package/examples/kitchen-sink/src/request-policy.ts +63 -0
- package/examples/kitchen-sink/src/routes.ts +120 -0
- package/examples/kitchen-sink/src/sdk.ts +46 -0
- package/examples/kitchen-sink/src/seed.ts +65 -0
- package/examples/kitchen-sink/src/server.ts +62 -0
- package/examples/kitchen-sink/src/stdio.ts +34 -0
- package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
- package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
- package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
- package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
- package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
- package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
- package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
- package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
- package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
- package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
- package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
- package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
- package/examples/kitchen-sink/test/routes.test.ts +123 -0
- package/examples/kitchen-sink/test/support/scripted.ts +40 -0
- package/examples/live-rpc/README.md +107 -0
- package/examples/live-rpc/src/Rpc.ts +8 -0
- package/examples/live-rpc/src/browser.ts +98 -0
- package/examples/live-rpc/src/chat/Chat.ts +75 -0
- package/examples/live-rpc/src/chat/authorize.ts +16 -0
- package/examples/live-rpc/src/chat/handlers.ts +50 -0
- package/examples/live-rpc/src/chat/room.ts +23 -0
- package/examples/live-rpc/src/chat/store.ts +54 -0
- package/examples/live-rpc/src/client.ts +37 -0
- package/examples/live-rpc/src/identity/Identity.ts +19 -0
- package/examples/live-rpc/src/identity/members.ts +8 -0
- package/examples/live-rpc/src/identity/verifier.ts +23 -0
- package/examples/live-rpc/src/routes.ts +22 -0
- package/examples/live-rpc/src/server.ts +43 -0
- package/examples/live-rpc/test/browser.test.ts +105 -0
- package/examples/live-rpc/test/chat/chat.test.ts +69 -0
- package/examples/live-rpc/test/routes.test.ts +99 -0
- package/examples/live-rpc/test/server.test.ts +75 -0
- package/examples/mcp-tools/README.md +136 -0
- package/examples/mcp-tools/src/instructions.ts +12 -0
- package/examples/mcp-tools/src/printers/Printers.ts +119 -0
- package/examples/mcp-tools/src/printers/fake.ts +115 -0
- package/examples/mcp-tools/src/printers/farm.ts +136 -0
- package/examples/mcp-tools/src/printers/handlers.ts +29 -0
- package/examples/mcp-tools/src/routes.ts +52 -0
- package/examples/mcp-tools/src/server.ts +30 -0
- package/examples/mcp-tools/src/stdio.ts +26 -0
- package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
- package/examples/mcp-tools/test/routes.test.ts +202 -0
- package/examples/notes-service/README.md +108 -0
- package/examples/notes-service/src/Api.ts +13 -0
- package/examples/notes-service/src/browser.ts +41 -0
- package/examples/notes-service/src/cli.ts +22 -0
- package/examples/notes-service/src/commands.ts +89 -0
- package/examples/notes-service/src/identity/Identity.ts +31 -0
- package/examples/notes-service/src/identity/authorize.ts +18 -0
- package/examples/notes-service/src/identity/handlers.ts +10 -0
- package/examples/notes-service/src/identity/users.ts +9 -0
- package/examples/notes-service/src/identity/verifier.ts +36 -0
- package/examples/notes-service/src/notes/Notes.ts +91 -0
- package/examples/notes-service/src/notes/handlers.ts +33 -0
- package/examples/notes-service/src/notes/store.ts +73 -0
- package/examples/notes-service/src/routes.ts +45 -0
- package/examples/notes-service/src/server.ts +26 -0
- package/examples/notes-service/src/status/Status.ts +10 -0
- package/examples/notes-service/src/status/handlers.ts +14 -0
- package/examples/notes-service/src/stdio.ts +25 -0
- package/examples/notes-service/test/commands.test.ts +134 -0
- package/examples/notes-service/test/notes/notes.test.ts +103 -0
- package/examples/notes-service/test/routes.test.ts +84 -0
- package/examples/quickstart/README.md +31 -0
- package/examples/quickstart/src/Api.ts +5 -0
- package/examples/quickstart/src/client.ts +13 -0
- package/examples/quickstart/src/greeting/Greeting.ts +12 -0
- package/examples/quickstart/src/greeting/handlers.ts +6 -0
- package/examples/quickstart/src/routes.ts +11 -0
- package/examples/quickstart/src/server.ts +16 -0
- package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
- package/package.json +32 -14
- package/dist/internal/actions.d.mts +0 -9
- package/dist/internal/authentication.mjs +0 -11
- package/dist/internal/call.mjs +0 -12
- package/dist/internal/cli.mjs +0 -328
- package/dist/internal/client.mjs +0 -25
- package/dist/internal/console.mjs +0 -77
- package/dist/internal/declared.d.mts +0 -13
- package/dist/internal/declared.mjs +0 -34
- package/dist/internal/mcp.d.mts +0 -11
- package/dist/internal/mcp.mjs +0 -52
- package/dist/internal/refusal.mjs +0 -67
- package/dist/internal/schema-errors.mjs +0 -20
- package/dist/internal/tools.mjs +0 -44
package/docs/Testing.md
CHANGED
|
@@ -1,174 +1,209 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
Two ways to test, and neither opens a port. `Action.client` calls an implementation in process:
|
|
4
|
+
what its handlers and its `authorize` do. `Testing.layer` answers the real routes in memory,
|
|
5
|
+
through Effect's own `HttpClient`: what a surface adds, such as authentication, statuses,
|
|
6
|
+
challenges and tool results as sent. The examples put a domain's tests in `test/<domain>/` and
|
|
7
|
+
tests of the surfaces beside them, in `it.effect` from `@effect/vitest`, which supplies the
|
|
8
|
+
`Scope` that `Action.client` and `ActionRpc.client` require, a `TestClock` and a `TestConsole`
|
|
9
|
+
(both imported from `effect/testing`).
|
|
10
|
+
|
|
11
|
+
Read this page alone, in one tool call: pages read together are cut. When something fails,
|
|
12
|
+
search [Testing-failures.md](Testing-failures.md). Reading order: [README.md](README.md).
|
|
6
13
|
|
|
7
14
|
## API
|
|
8
15
|
|
|
9
16
|
Import `@gjermundgaraba/effect-actions/Testing`.
|
|
10
17
|
|
|
11
|
-
| API | Purpose
|
|
12
|
-
| --------------------------------------- |
|
|
13
|
-
| `layer(routes)` | A `Layer<HttpClient>` answering requests with `routes` in memory.
|
|
14
|
-
| `layer(handler)` | A `Layer<HttpClient>` answering requests with a web handler the test serves.
|
|
15
|
-
| `mcpClient(actions, options?)` | An Effect of a client calling each action's tool, like `ActionHttp.client`.
|
|
16
|
-
| `mcpRequest(method, params?, options?)` | One request
|
|
17
|
-
| `McpCallError` |
|
|
18
|
-
|
|
19
|
-
| Option | Meaning
|
|
20
|
-
| ------------------------------ |
|
|
21
|
-
| `url` | The endpoint
|
|
22
|
-
| `mcpClient`: `transformClient` | Wraps the native `HttpClient`, as `ActionHttp.client` takes it:
|
|
23
|
-
| `mcpRequest`: `headers` | Request headers.
|
|
24
|
-
|
|
25
|
-
Exported types: `McpClient<Actions>`,
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
const client = yield* ActionHttp.client(Http);
|
|
48
|
-
const greeting = yield* client.greet({ name: "Ada" });
|
|
49
|
-
|
|
50
|
-
// The same action as a tool of `/mcp`, called like the client's method.
|
|
51
|
-
const mcp = yield* Testing.mcpClient([Greet]);
|
|
52
|
-
const called = yield* mcp.greet({ name: "Ada" });
|
|
53
|
-
|
|
54
|
-
return { greeting, called };
|
|
55
|
-
});
|
|
56
|
-
|
|
57
|
-
// The routes answer in memory for the program's scope, and are released after it.
|
|
58
|
-
console.log(await Effect.runPromise(program.pipe(Effect.provide(Testing.layer(routes)))));
|
|
18
|
+
| API | Purpose |
|
|
19
|
+
| --------------------------------------- | ----------------------------------------------------------------------------- |
|
|
20
|
+
| `layer(routes)` | A `Layer<HttpClient>` answering requests with `routes` in memory. |
|
|
21
|
+
| `layer(handler)` | A `Layer<HttpClient>` answering requests with a web handler the test serves. |
|
|
22
|
+
| `mcpClient(actions, options?)` | An Effect of a client calling each action's tool, like `ActionHttp.client`. |
|
|
23
|
+
| `mcpRequest(method, params?, options?)` | One MCP request, as the native `HttpClientRequest` the test sends. |
|
|
24
|
+
| `McpCallError` | A client method's failure for an answer it cannot decode; `message` holds it. |
|
|
25
|
+
|
|
26
|
+
| Option | Meaning |
|
|
27
|
+
| ------------------------------ | ------------------------------------------------------------------------------- |
|
|
28
|
+
| `url` | The endpoint; default `/mcp`, the `ActionMcp.layerHttp` default. |
|
|
29
|
+
| `mcpClient`: `transformClient` | Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token. |
|
|
30
|
+
| `mcpRequest`: `headers` | Request headers. |
|
|
31
|
+
|
|
32
|
+
Exported types: `McpClient<Actions>`, `McpClientOptions`, `McpRequestOptions`, and `McpParams`,
|
|
33
|
+
the `params` of `mcpRequest`.
|
|
34
|
+
|
|
35
|
+
## Test an implementation in process
|
|
36
|
+
|
|
37
|
+
Acquire `Action.client(implementation)` and call its methods
|
|
38
|
+
([Action-in-process.md](Action-in-process.md#call-actions-in-process)). A call runs the decoding,
|
|
39
|
+
the `authorize` and the handler a server runs, but no authentication and no middleware. Provide
|
|
40
|
+
the startup services around the test, as an entrypoint does, and what a request would carry, its
|
|
41
|
+
caller first, around each call:
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
|
|
45
|
+
|
|
46
|
+
it.effect("keeps each user's notes their own", () =>
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const client = yield* Action.client(notes);
|
|
49
|
+
const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
|
|
50
|
+
|
|
51
|
+
expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
|
|
52
|
+
}).pipe(Effect.provide(NoteStore.layerMemory)),
|
|
53
|
+
);
|
|
59
54
|
```
|
|
60
55
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
What an implementation does, with no transport between: `Action.client` gives the methods
|
|
64
|
-
`ActionHttp.client` gives, and each call runs the authorizer and the handler, with every
|
|
65
|
-
check a surface makes on its input, its success and its failure ([Action.md](Action.md#clients)). Each
|
|
66
|
-
call takes its caller, so one test has several, and an action no binding holds, such as a
|
|
67
|
-
tool, has a method like any other.
|
|
68
|
-
|
|
69
|
-
```ts example=in-process.ts
|
|
70
|
-
import { Effect } from "effect";
|
|
71
|
-
import * as Action from "@gjermundgaraba/effect-actions/Action";
|
|
72
|
-
import { actors, CurrentActor } from "./authorization.js";
|
|
73
|
-
import { userActions } from "./handlers.js";
|
|
74
|
-
import { Users } from "./users.js";
|
|
56
|
+
See [notes-service/test/notes/notes.test.ts](../examples/notes-service/test/notes/notes.test.ts).
|
|
75
57
|
|
|
76
|
-
|
|
77
|
-
|
|
58
|
+
- Replace the edge, not the service under test:
|
|
59
|
+
[mcp-tools/test/printers/printers.test.ts](../examples/mcp-tools/test/printers/printers.test.ts)
|
|
60
|
+
runs the real upstream service over a fake `HttpClient` Layer.
|
|
61
|
+
- `it.effect` runs on a test clock: fork a call that waits, `TestClock.adjust`, then join it.
|
|
62
|
+
- A stdio server runs the same implementations: test them here. A subprocess test adds only what
|
|
63
|
+
the process adds, stdout carrying nothing but the protocol.
|
|
64
|
+
- A Toolkit and a CLI need no `Testing` export: call `toolkit.handle` or script a
|
|
65
|
+
`LanguageModel` ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)),
|
|
66
|
+
and run commands with `Command.runWith`
|
|
67
|
+
([assistant/test/commands.test.ts](../examples/assistant/test/commands.test.ts)).
|
|
78
68
|
|
|
79
|
-
|
|
69
|
+
## Test routes in memory
|
|
80
70
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const users = yield* Action.client(userActions);
|
|
71
|
+
Export the routes still requiring what an entrypoint chooses, give the test its own, and provide
|
|
72
|
+
the layer to each test:
|
|
84
73
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const renamed = yield* users.renameUser({ id: "1", name: "Bea" }).pipe(asAlice);
|
|
88
|
-
const refused = yield* Effect.flip(users.renameUser({ id: "1", name: "Cy" }).pipe(asReader));
|
|
89
|
-
const { changes } = yield* users.listChanges().pipe(asReader); // not an HTTP route
|
|
90
|
-
|
|
91
|
-
return { renamed, refused, changes };
|
|
92
|
-
});
|
|
93
|
-
|
|
94
|
-
// The builders are released with the program's scope, before the services they captured.
|
|
95
|
-
console.log(
|
|
96
|
-
await Effect.runPromise(program.pipe(Effect.scoped, Effect.provide(Users.layerMemory))),
|
|
97
|
-
);
|
|
74
|
+
```ts
|
|
75
|
+
const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
|
|
98
76
|
```
|
|
99
77
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
test
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
78
|
+
Under it, `ActionHttp.client(Http)` needs no `baseUrl`, and `HttpClient.get("/healthz")` reaches
|
|
79
|
+
the host's own routes. Each test builds the routes anew, so each starts from an empty store. See
|
|
80
|
+
[notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts); the smallest
|
|
81
|
+
test is
|
|
82
|
+
[quickstart/test/greeting/greeting.test.ts](../examples/quickstart/test/greeting/greeting.test.ts).
|
|
83
|
+
|
|
84
|
+
- A service the test reads too goes around `layer` with `Layer.provideMerge`, so handlers and
|
|
85
|
+
test share one instance
|
|
86
|
+
([kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)).
|
|
87
|
+
- Two `layer`s are two servers: a second one runs every builder again, and they share only what
|
|
88
|
+
is provided around both. For two ports, that is `Action.layer(implementations)` and the
|
|
89
|
+
services ([kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts),
|
|
90
|
+
[Action-in-process.md](Action-in-process.md#share-one-build-across-servers)).
|
|
91
|
+
- The client is `layer`'s own. A fake upstream is another `HttpClient`: provide it to the service
|
|
92
|
+
that calls out ([mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts)).
|
|
93
|
+
- Every request on `layer`'s client reaches the routes, whatever its host: a client given a
|
|
94
|
+
`baseUrl`, a remote `ActionCli` command's included, runs in memory unchanged.
|
|
95
|
+
- Requests run in the test's context: a `ConfigProvider` or the test clock around the test
|
|
96
|
+
reaches middleware and handlers.
|
|
97
|
+
- To send raw web `Request`s too, build one `HttpRouter.toWebHandler` and dispose it yourself;
|
|
98
|
+
`Testing.layer(web.handler)` gives the typed clients the same handler
|
|
99
|
+
([kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts)).
|
|
100
|
+
|
|
101
|
+
## Test as a signed-in caller
|
|
102
|
+
|
|
103
|
+
The routes keep their real authentication. Serve them behind their provider
|
|
104
|
+
([Authentication.md](Authentication.md#verify-credentials)), and give each client its caller's
|
|
105
|
+
credential:
|
|
106
|
+
|
|
107
|
+
```ts
|
|
121
108
|
const as = (token: string) => ({
|
|
122
109
|
transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
|
|
123
110
|
});
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Both clients take it: `ActionHttp.client(Http, as("reader"))` and
|
|
114
|
+
`Testing.mcpClient([GetStatus, ...Notes], as("ada"))`.
|
|
115
|
+
|
|
116
|
+
- Never provide the identity to `layer`, around it or to the routes. It is a type error: only a
|
|
117
|
+
provider of their own descriptor satisfies them.
|
|
118
|
+
- To change what a credential verifies as, replace the service the verifier reads (`servedWith`
|
|
119
|
+
in
|
|
120
|
+
[kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)),
|
|
121
|
+
or provide a test verifier: another `Authentication.layer` of the same descriptor.
|
|
122
|
+
|
|
123
|
+
A typed client decodes a refusal to its error. To assert on the answer as sent, build the request
|
|
124
|
+
(`HttpClientRequest.post`, `bearerToken`, `bodyJsonUnsafe`) and send it with `HttpClient.execute`,
|
|
125
|
+
which succeeds whatever the status. Read `response.status` and
|
|
126
|
+
`response.headers["www-authenticate"]`, the challenge, as that file does. The status and body of
|
|
127
|
+
each case are the table in [guarantees.md](guarantees.md#wire-behavior).
|
|
128
|
+
|
|
129
|
+
## Test MCP tools
|
|
130
|
+
|
|
131
|
+
`Testing.mcpClient(actions, options?)` gives one method per action, calling its tool and decoding
|
|
132
|
+
the result as `ActionHttp.client` decodes a route's, an image included. Pass the contract values
|
|
133
|
+
the endpoint serves, such as `implementation.actions`.
|
|
134
|
+
|
|
135
|
+
A method fails with the action's declared errors and the built-in ones as decoded values, and with
|
|
136
|
+
`McpCallError` for any other answer, a result without its structured content included;
|
|
137
|
+
structured content that does not decode as the success is a `SchemaError`, and a request that
|
|
138
|
+
fails Effect's `HttpClientError`. Only a tool's result carries the action's own errors: an HTTP
|
|
139
|
+
answer other than 200 decodes as a refusal alone, so an error a verifier declares, typed over
|
|
140
|
+
`ActionHttp.client`, is an `McpCallError` here
|
|
141
|
+
([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)).
|
|
142
|
+
|
|
143
|
+
A method decodes a refusal alike whether the endpoint answered a 401 or a 403 or returned a tool
|
|
144
|
+
result in a 200 ([guarantees.md](guarantees.md#wire-behavior)): to assert which, send the call
|
|
145
|
+
yourself and read its status.
|
|
146
|
+
|
|
147
|
+
For that, and for what the client's types will not send or its decoding hides (`tools/list`, an
|
|
148
|
+
`Origin` header, a refusal's challenge, a malformed call), build the request with
|
|
149
|
+
`Testing.mcpRequest`. It returns a native `HttpClientRequest`:
|
|
150
|
+
`yield* HttpClient.execute(Testing.mcpRequest("tools/call", { name: "whoAmI", arguments: {} }))`
|
|
151
|
+
succeeds with the response whatever its status, to read `response.status`, `response.headers`
|
|
152
|
+
and `yield* response.json`
|
|
153
|
+
([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)
|
|
154
|
+
asserts a 401 so, and
|
|
155
|
+
[mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts) reads `tools/list`).
|
|
156
|
+
For a web handler or `fetch`, pass an absolute `url`: `HttpClientRequest.toWebResult(request)` is
|
|
157
|
+
then a `Result` holding the web `Request`, taken with `Result.getOrThrow`.
|
|
158
|
+
|
|
159
|
+
## Test rpcs
|
|
160
|
+
|
|
161
|
+
`Testing.layer` answers HTTP requests and cannot upgrade a WebSocket. Export the rpc layer without
|
|
162
|
+
its protocol ([ActionRpc.md](ActionRpc.md#serve-the-rpcs)), and give it Effect's HTTP protocol in
|
|
163
|
+
the test, on both sides:
|
|
164
|
+
|
|
165
|
+
```ts
|
|
166
|
+
const server = rpc.pipe(
|
|
167
|
+
Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
|
|
168
|
+
Layer.provide(RpcSerialization.layerJson),
|
|
169
|
+
);
|
|
124
170
|
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
171
|
+
const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
|
|
172
|
+
Layer.provide(RpcSerialization.layerJson),
|
|
173
|
+
Layer.provide(Testing.layer(server)),
|
|
174
|
+
Layer.provide(MessageStore.layerMemory),
|
|
175
|
+
);
|
|
129
176
|
|
|
130
|
-
|
|
131
|
-
|
|
177
|
+
const as = (token: string) => RpcClient.withHeaders({ authorization: `Bearer ${token}` });
|
|
178
|
+
```
|
|
132
179
|
|
|
133
|
-
|
|
180
|
+
Authentication is per message, so each call carries its credential and one client serves every
|
|
181
|
+
caller: `client.postMessage({ text }).pipe(as("ada"))`. See
|
|
182
|
+
[live-rpc/test/routes.test.ts](../examples/live-rpc/test/routes.test.ts);
|
|
183
|
+
[live-rpc/test/browser.test.ts](../examples/live-rpc/test/browser.test.ts) tests a page's client
|
|
184
|
+
the same way. To call JSON routes in the same test, merge both route layers into the one
|
|
185
|
+
`Testing.layer` and give it to the protocol with `Layer.provideMerge`, which keeps its `HttpClient`
|
|
186
|
+
for the test
|
|
187
|
+
([kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)).
|
|
188
|
+
Effect's `RpcTest.makeClient` is no substitute: it asks for handlers of the
|
|
189
|
+
binding's group, which `ActionRpc.layer` does not provide (`Handler<"…">` stays required).
|
|
190
|
+
|
|
191
|
+
## Assert on errors
|
|
192
|
+
|
|
193
|
+
Take the failure with `Effect.flip(call)`, which alone asserts only that the call failed, then
|
|
194
|
+
assert its class and its `message` separately:
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
expect(refusal).toBeInstanceOf(Action.Forbidden);
|
|
198
|
+
expect(refusal.message).toBe("Requires notes:write.");
|
|
134
199
|
```
|
|
135
200
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
- The client is `layer`'s own: the program's other HTTP clients get none of its requests, and it none of theirs, whether a `FetchHttpClient` is built before or after it or a `FetchHttpClient.Fetch` is provided around the program.
|
|
146
|
-
- `layer(handler)` gives the same client, answered by a web handler the test serves, such as `HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices))).handler` shared with tests that send it `Request`s: the routes are built once, by the handler, for every `layer` given it. The web handler needs the routes' platform services, which `HttpServer.layerServices` provides and `layer(routes)` supplies itself. The test owns the handler: `layer` neither builds nor disposes it, so the test calls `dispose` when it is done.
|
|
147
|
-
- A client method sends one stateless `tools/call` for the action's tool, at the protocol version `ActionMcp.layerHttp` serves, 2026-07-28, in both the header and `_meta`. It encodes the input with the action's schema, and decodes the success from `structuredContent`, as `ActionHttp.client` does for a route. It reads a JSON or an event-stream response.
|
|
148
|
-
- A declared error, the action's own or a built-in one, is a typed failure of its decoded value: an `isError` result from the tool, or a 401 or 403 from the endpoint's authentication or an authorizer, whose body is the same JSON. Match it with `Effect.catchTag`, exactly as on the HTTP client.
|
|
149
|
-
- Any other answer fails with `McpCallError`, whose `message` holds it: another status, the native server's own text (invalid arguments, a defect), no reply, a result without `structuredContent`, or a JSON-RPC error (an unknown tool). Match it with `Effect.catchTag("McpCallError", ...)`.
|
|
150
|
-
- The input is typed, so a malformed call cannot be sent through a client method. To assert on a malformed call, another MCP method or the response itself, such as a refusal's status and `WWW-Authenticate` challenge, send `mcpRequest(method, params, options)`.
|
|
151
|
-
- `mcpRequest` is one stateless request as a client method sends it, a native `HttpClientRequest` the test sends itself: with `mcp-name` from `params.uri` for `resources/read` and from `params.name` otherwise, and the client metadata in `_meta`. A `_meta` in `params`, such as a `progressToken`, is merged over the client metadata, and the protocol version is always the request's own.
|
|
152
|
-
- Under `layer`, send it with `HttpClient.execute(Testing.mcpRequest("tools/list"))`, which succeeds with the response whatever its status.
|
|
153
|
-
- A Promise test sends it as a web `Request`, to a web handler or to `fetch`, for a web `Response` back: `handler(Result.getOrThrow(HttpClientRequest.toWebResult(Testing.mcpRequest(method, params, { url: "http://localhost/mcp" }))))`. The `url` is absolute, the handler's origin or a listening server's, since a web `Request` has no `HttpClient` to resolve a relative one.
|
|
154
|
-
- A tool call the typed client would not send is `mcpRequest("tools/call", { name, arguments })`, its result read from the response's JSON: `result.structuredContent`, or `result.isError` and `result.content`.
|
|
155
|
-
- To assert on a result's exact bytes, such as a size bound, parse the text of an `mcpRequest`'s JSON response: `JSON.stringify` of the parsed message reproduces what the server wrote, less its final newline, so `JSON.stringify(message.result)` is the result as encoded.
|
|
156
|
-
- A request under `layer` carries the `Host` header of its URL, `localhost` for a relative one, unless it sets its own, so middleware checking the host answers as it would over the network.
|
|
157
|
-
- Add `Authorization` through `transformClient`, the same options for `mcpClient` and `ActionHttp.client`: one client per caller, `const alice = { transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken("alice")) }`. `mcpRequest` takes `headers`.
|
|
158
|
-
- A client names each tool by its action and holds no connection. Duplicate action names throw `Duplicate action: <name>`.
|
|
159
|
-
- Test what an implementation does in process, with `Action.client`: its authorizer, its handlers, and the checks every surface makes on input, success and failure, with several callers, an action no binding holds included. Test what a surface adds under `layer`: authentication, statuses, headers, bodies as sent and MCP results. The two call the same methods. Cover each surface the application exposes.
|
|
160
|
-
|
|
161
|
-
## Failure modes
|
|
162
|
-
|
|
163
|
-
- Fails with `Action.Unauthenticated`: the call reached authentication without a valid credential. Give the client a `transformClient` adding it, such as `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`.
|
|
164
|
-
- `MCP tools/call "<name>" returned an error: Invalid parameters for tool ...`: the endpoint serves a different contract under that name than the one passed. Call it with the served contract value.
|
|
165
|
-
- 404 from a client method: the action's implementation was not passed to any `ActionHttp.layer` call of its binding in the served routes, or `baseUrl` adds a path the routes do not have. Include `ActionHttp.layer(Http, implementations)` in the routes.
|
|
166
|
-
- The program reads a service the handlers never changed: it was provided inside the routes and again to the program, two instances. Provide it once, around `layer`, with `Layer.provideMerge`.
|
|
167
|
-
- `Type 'Provider<CurrentActor, "example.Login">' is not assignable to type 'never'` where the test runs, such as at `Effect.runPromise`: the routes serve a protected action and no provider of its descriptor is provided to them. Provide it to the routes, `ActionHttp.layer(Http, app).pipe(Layer.provide(authenticate))`, or a test verifier of the same descriptor. A caller provided around `layer` does not satisfy it ([Signed-in callers](#signed-in-callers)).
|
|
168
|
-
- `Type 'CurrentActor' is not assignable to type 'never'` where the test runs: a public action's handler reads the identity, which no route provides it. Make the action protected.
|
|
169
|
-
- A protected route answers 401 where the test expects a 415 or a 400: the request carries no credential that verifies, and authentication runs first. Send a token with requests about something else.
|
|
170
|
-
- `Argument of type 'Layer<…, FileSystem | Generator | HttpPlatform | HttpRouter | Path>' is not assignable` at `HttpRouter.toWebHandler(routes)`, then `No overload matches this call` at `layer(web.handler)`, its last overload naming `Layer<unknown, unknown, unknown>`, or `Expected 2 arguments, but got 1` at `web.handler(request)`: the web handler's routes lack their platform services. Provide them: `HttpRouter.toWebHandler(routes.pipe(Layer.provide(HttpServer.layerServices)))`.
|
|
171
|
-
- A builder or handler finds no file where one exists, failing with `NotFound: FileSystem.<method> (<path>)`, or a file route answers 500: nothing provided a `FileSystem` around `layer`, so the routes read a no-op one. Provide `NodeFileSystem.layer` or `NodeServices.layer` around it, with `Layer.provideMerge` where the program reads files too.
|
|
172
|
-
- `MCP tools/call "<name>" answered 404`: the endpoint is not at `/mcp`. Pass its `url`.
|
|
173
|
-
- `HttpClientRequest.toWebResult` or `toWeb` of an `mcpRequest` fails with `UrlError`: its `url` is the relative default. Pass an absolute `url`.
|
|
174
|
-
- An `HttpClientError` whose reason is `InvalidUrlError`, from a client method or a sent `mcpRequest` on an `HttpClient` that prepends no base URL: a relative `url` resolves under `layer`, and under a client that prepends one, such as `NodeHttpServer.layerTest`'s. Pass an absolute `url`.
|
|
201
|
+
`toEqual` never compares a tagged error's `message`, which is not an enumerable property:
|
|
202
|
+
`expect(error).toEqual(new Action.Forbidden({ message: "anything" }))` passes for every message,
|
|
203
|
+
and so does `toStrictEqual`. Other fields are compared, so `toEqual(new NoteNotFound({ id }))`
|
|
204
|
+
asserts the `id`, and `toEqual(new Action.Forbidden({ scopes: ["issues:write"] }))` still needs the
|
|
205
|
+
message asserted beside it. The message is all a model reads of a tool's failure.
|
|
206
|
+
|
|
207
|
+
A Toolkit raises neither a refusal nor a declared error: each is a result the model reads, so
|
|
208
|
+
assert `isFailure` and `result.message`
|
|
209
|
+
([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)).
|