@gjermundgaraba/effect-actions 0.11.0 → 0.13.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.
Files changed (215) hide show
  1. package/README.md +102 -41
  2. package/dist/Action.d.mts +31 -117
  3. package/dist/Action.mjs +14 -53
  4. package/dist/ActionCli.d.mts +60 -28
  5. package/dist/ActionCli.mjs +54 -48
  6. package/dist/ActionHttp.d.mts +18 -67
  7. package/dist/ActionHttp.mjs +20 -70
  8. package/dist/ActionMcp.d.mts +23 -29
  9. package/dist/ActionMcp.mjs +56 -114
  10. package/dist/ActionRpc.d.mts +147 -0
  11. package/dist/ActionRpc.mjs +113 -0
  12. package/dist/ActionToolkit.d.mts +28 -9
  13. package/dist/ActionToolkit.mjs +31 -4
  14. package/dist/Authentication.d.mts +15 -15
  15. package/dist/Authentication.mjs +42 -96
  16. package/dist/Testing.d.mts +30 -23
  17. package/dist/Testing.mjs +27 -44
  18. package/dist/{internal/authentication.d.mts → authentication/provider.d.mts} +41 -9
  19. package/dist/authentication/provider.mjs +12 -0
  20. package/dist/authentication/refusal.mjs +36 -0
  21. package/dist/{internal/cli.d.mts → cli/commands.d.mts} +16 -12
  22. package/dist/cli/commands.mjs +302 -0
  23. package/dist/{internal → contract}/call.d.mts +2 -2
  24. package/dist/contract/call.mjs +10 -0
  25. package/dist/{internal → contract}/errors.d.mts +13 -10
  26. package/dist/{internal → contract}/errors.mjs +9 -14
  27. package/dist/{internal → contract}/implementation.d.mts +37 -21
  28. package/dist/{internal → contract}/implementation.mjs +18 -49
  29. package/dist/contract/media.d.mts +13 -0
  30. package/dist/contract/media.mjs +18 -0
  31. package/dist/contract/rules.d.mts +25 -0
  32. package/dist/{internal/actions.mjs → contract/rules.mjs} +6 -42
  33. package/dist/{internal → http}/client.d.mts +7 -9
  34. package/dist/http/client.mjs +17 -0
  35. package/dist/http/declared.mjs +20 -0
  36. package/dist/http/schema-errors.mjs +9 -0
  37. package/dist/mcp/protocol.d.mts +18 -0
  38. package/dist/{internal/mcp.mjs → mcp/protocol.mjs} +29 -62
  39. package/dist/stdio/console.mjs +64 -0
  40. package/dist/{internal → testing}/memory.d.mts +1 -1
  41. package/dist/{internal → testing}/memory.mjs +5 -17
  42. package/dist/toolkit/tools.mjs +29 -0
  43. package/docs/Action-failures.md +47 -0
  44. package/docs/Action-in-process.md +93 -0
  45. package/docs/Action.md +211 -426
  46. package/docs/ActionCli-failures.md +104 -0
  47. package/docs/ActionCli.md +208 -293
  48. package/docs/ActionHttp-failures.md +83 -0
  49. package/docs/ActionHttp.md +205 -282
  50. package/docs/ActionMcp-failures.md +31 -0
  51. package/docs/ActionMcp.md +222 -271
  52. package/docs/ActionRpc-failures.md +21 -0
  53. package/docs/ActionRpc.md +179 -0
  54. package/docs/ActionToolkit-failures.md +15 -0
  55. package/docs/ActionToolkit.md +103 -119
  56. package/docs/Authentication-advanced.md +139 -0
  57. package/docs/Authentication-failures.md +99 -0
  58. package/docs/Authentication.md +143 -392
  59. package/docs/CONTEXT.md +40 -24
  60. package/docs/README.md +75 -35
  61. package/docs/Testing-failures.md +21 -0
  62. package/docs/Testing.md +193 -151
  63. package/docs/guarantees.md +35 -96
  64. package/docs/setup-failures.md +37 -0
  65. package/docs/setup.md +90 -92
  66. package/examples/README.md +68 -0
  67. package/examples/assistant/README.md +92 -0
  68. package/examples/assistant/src/agent.ts +92 -0
  69. package/examples/assistant/src/cli.ts +34 -0
  70. package/examples/assistant/src/commands.ts +25 -0
  71. package/examples/assistant/src/identity/Identity.ts +33 -0
  72. package/examples/assistant/src/identity/authorize.ts +24 -0
  73. package/examples/assistant/src/identity/handlers.ts +10 -0
  74. package/examples/assistant/src/identity/members.ts +9 -0
  75. package/examples/assistant/src/identity/permissions.ts +23 -0
  76. package/examples/assistant/src/scripted.ts +23 -0
  77. package/examples/assistant/src/tasks/Tasks.ts +63 -0
  78. package/examples/assistant/src/tasks/handlers.ts +25 -0
  79. package/examples/assistant/src/tasks/store.ts +96 -0
  80. package/examples/assistant/src/toolkit.ts +30 -0
  81. package/examples/assistant/test/commands.test.ts +57 -0
  82. package/examples/assistant/test/identity/identity.test.ts +43 -0
  83. package/examples/assistant/test/tasks/store.test.ts +48 -0
  84. package/examples/assistant/test/tasks/tasks.test.ts +75 -0
  85. package/examples/assistant/test/toolkit.test.ts +121 -0
  86. package/examples/kitchen-sink/README.md +318 -0
  87. package/examples/kitchen-sink/src/Actions.ts +19 -0
  88. package/examples/kitchen-sink/src/Api.ts +24 -0
  89. package/examples/kitchen-sink/src/Rpc.ts +12 -0
  90. package/examples/kitchen-sink/src/admin/Admin.ts +59 -0
  91. package/examples/kitchen-sink/src/admin/authenticate.ts +18 -0
  92. package/examples/kitchen-sink/src/admin/authorize.ts +27 -0
  93. package/examples/kitchen-sink/src/admin/grants.ts +22 -0
  94. package/examples/kitchen-sink/src/admin/handlers.ts +21 -0
  95. package/examples/kitchen-sink/src/admin/keys.ts +8 -0
  96. package/examples/kitchen-sink/src/agent.ts +47 -0
  97. package/examples/kitchen-sink/src/assistant/Assistant.ts +82 -0
  98. package/examples/kitchen-sink/src/assistant/conversations.ts +71 -0
  99. package/examples/kitchen-sink/src/assistant/demo-model.ts +33 -0
  100. package/examples/kitchen-sink/src/assistant/handlers.ts +126 -0
  101. package/examples/kitchen-sink/src/attachments/Attachments.ts +48 -0
  102. package/examples/kitchen-sink/src/attachments/download.ts +49 -0
  103. package/examples/kitchen-sink/src/attachments/handlers.ts +38 -0
  104. package/examples/kitchen-sink/src/attachments/store.ts +58 -0
  105. package/examples/kitchen-sink/src/browser.ts +33 -0
  106. package/examples/kitchen-sink/src/cli.ts +85 -0
  107. package/examples/kitchen-sink/src/config.ts +18 -0
  108. package/examples/kitchen-sink/src/identity/Identity.ts +61 -0
  109. package/examples/kitchen-sink/src/identity/authenticate.ts +87 -0
  110. package/examples/kitchen-sink/src/identity/authorize.ts +16 -0
  111. package/examples/kitchen-sink/src/identity/directory.ts +52 -0
  112. package/examples/kitchen-sink/src/identity/handlers.ts +9 -0
  113. package/examples/kitchen-sink/src/identity/members.ts +13 -0
  114. package/examples/kitchen-sink/src/identity/quota.ts +62 -0
  115. package/examples/kitchen-sink/src/issues/Issues.ts +111 -0
  116. package/examples/kitchen-sink/src/issues/assistant.ts +23 -0
  117. package/examples/kitchen-sink/src/issues/guide.ts +37 -0
  118. package/examples/kitchen-sink/src/issues/handlers.ts +37 -0
  119. package/examples/kitchen-sink/src/issues/store.ts +96 -0
  120. package/examples/kitchen-sink/src/issues/write-limit.ts +42 -0
  121. package/examples/kitchen-sink/src/memory.ts +30 -0
  122. package/examples/kitchen-sink/src/ops.ts +42 -0
  123. package/examples/kitchen-sink/src/request-policy.ts +63 -0
  124. package/examples/kitchen-sink/src/routes.ts +120 -0
  125. package/examples/kitchen-sink/src/sdk.ts +46 -0
  126. package/examples/kitchen-sink/src/seed.ts +65 -0
  127. package/examples/kitchen-sink/src/server.ts +62 -0
  128. package/examples/kitchen-sink/src/stdio.ts +34 -0
  129. package/examples/kitchen-sink/src/workspaces/Workspaces.ts +28 -0
  130. package/examples/kitchen-sink/src/workspaces/handlers.ts +23 -0
  131. package/examples/kitchen-sink/src/workspaces/registry.ts +46 -0
  132. package/examples/kitchen-sink/src/workspaces/tenant.ts +17 -0
  133. package/examples/kitchen-sink/test/admin/admin.test.ts +67 -0
  134. package/examples/kitchen-sink/test/assistant/assistant.test.ts +435 -0
  135. package/examples/kitchen-sink/test/attachments/attachments.test.ts +161 -0
  136. package/examples/kitchen-sink/test/identity/identity.test.ts +137 -0
  137. package/examples/kitchen-sink/test/identity/session.test.ts +110 -0
  138. package/examples/kitchen-sink/test/issues/assistant.test.ts +75 -0
  139. package/examples/kitchen-sink/test/issues/issues.test.ts +135 -0
  140. package/examples/kitchen-sink/test/request-policy.test.ts +125 -0
  141. package/examples/kitchen-sink/test/routes.test.ts +123 -0
  142. package/examples/kitchen-sink/test/support/scripted.ts +40 -0
  143. package/examples/live-rpc/README.md +107 -0
  144. package/examples/live-rpc/src/Rpc.ts +8 -0
  145. package/examples/live-rpc/src/browser.ts +98 -0
  146. package/examples/live-rpc/src/chat/Chat.ts +77 -0
  147. package/examples/live-rpc/src/chat/authorize.ts +16 -0
  148. package/examples/live-rpc/src/chat/handlers.ts +50 -0
  149. package/examples/live-rpc/src/chat/room.ts +23 -0
  150. package/examples/live-rpc/src/chat/store.ts +54 -0
  151. package/examples/live-rpc/src/client.ts +37 -0
  152. package/examples/live-rpc/src/identity/Identity.ts +19 -0
  153. package/examples/live-rpc/src/identity/members.ts +8 -0
  154. package/examples/live-rpc/src/identity/verifier.ts +23 -0
  155. package/examples/live-rpc/src/routes.ts +22 -0
  156. package/examples/live-rpc/src/server.ts +43 -0
  157. package/examples/live-rpc/test/browser.test.ts +105 -0
  158. package/examples/live-rpc/test/chat/chat.test.ts +69 -0
  159. package/examples/live-rpc/test/routes.test.ts +99 -0
  160. package/examples/live-rpc/test/server.test.ts +75 -0
  161. package/examples/mcp-tools/README.md +141 -0
  162. package/examples/mcp-tools/src/instructions.ts +12 -0
  163. package/examples/mcp-tools/src/printers/Printers.ts +121 -0
  164. package/examples/mcp-tools/src/printers/fake.ts +115 -0
  165. package/examples/mcp-tools/src/printers/farm.ts +136 -0
  166. package/examples/mcp-tools/src/printers/handlers.ts +29 -0
  167. package/examples/mcp-tools/src/routes.ts +52 -0
  168. package/examples/mcp-tools/src/server.ts +31 -0
  169. package/examples/mcp-tools/src/stdio.ts +26 -0
  170. package/examples/mcp-tools/test/printers/printers.test.ts +189 -0
  171. package/examples/mcp-tools/test/routes.test.ts +202 -0
  172. package/examples/notes-service/README.md +111 -0
  173. package/examples/notes-service/src/Api.ts +13 -0
  174. package/examples/notes-service/src/browser.ts +41 -0
  175. package/examples/notes-service/src/cli.ts +22 -0
  176. package/examples/notes-service/src/commands.ts +137 -0
  177. package/examples/notes-service/src/identity/Identity.ts +31 -0
  178. package/examples/notes-service/src/identity/authorize.ts +18 -0
  179. package/examples/notes-service/src/identity/handlers.ts +10 -0
  180. package/examples/notes-service/src/identity/users.ts +9 -0
  181. package/examples/notes-service/src/identity/verifier.ts +36 -0
  182. package/examples/notes-service/src/notes/Notes.ts +119 -0
  183. package/examples/notes-service/src/notes/handlers.ts +39 -0
  184. package/examples/notes-service/src/notes/store.ts +84 -0
  185. package/examples/notes-service/src/routes.ts +45 -0
  186. package/examples/notes-service/src/server.ts +26 -0
  187. package/examples/notes-service/src/status/Status.ts +10 -0
  188. package/examples/notes-service/src/status/handlers.ts +14 -0
  189. package/examples/notes-service/src/stdio.ts +25 -0
  190. package/examples/notes-service/test/commands.test.ts +288 -0
  191. package/examples/notes-service/test/notes/notes.test.ts +103 -0
  192. package/examples/notes-service/test/routes.test.ts +84 -0
  193. package/examples/quickstart/README.md +31 -0
  194. package/examples/quickstart/src/Api.ts +5 -0
  195. package/examples/quickstart/src/client.ts +13 -0
  196. package/examples/quickstart/src/greeting/Greeting.ts +12 -0
  197. package/examples/quickstart/src/greeting/handlers.ts +6 -0
  198. package/examples/quickstart/src/routes.ts +11 -0
  199. package/examples/quickstart/src/server.ts +16 -0
  200. package/examples/quickstart/test/greeting/greeting.test.ts +18 -0
  201. package/package.json +27 -9
  202. package/dist/internal/actions.d.mts +0 -9
  203. package/dist/internal/authentication.mjs +0 -11
  204. package/dist/internal/call.mjs +0 -12
  205. package/dist/internal/cli.mjs +0 -328
  206. package/dist/internal/client.mjs +0 -25
  207. package/dist/internal/console.mjs +0 -77
  208. package/dist/internal/declared.d.mts +0 -13
  209. package/dist/internal/declared.mjs +0 -34
  210. package/dist/internal/mcp.d.mts +0 -14
  211. package/dist/internal/media.d.mts +0 -8
  212. package/dist/internal/media.mjs +0 -26
  213. package/dist/internal/refusal.mjs +0 -67
  214. package/dist/internal/schema-errors.mjs +0 -20
  215. package/dist/internal/tools.mjs +0 -44
package/docs/Testing.md CHANGED
@@ -1,174 +1,216 @@
1
1
  # Testing
2
2
 
3
- In-memory calls against served layers, through Effect's own `HttpClient`. Needs no extra
4
- dependency and opens no port. It tests what the wire does: routes and tools, authentication,
5
- statuses and codecs as sent. What an implementation does, its authorization and its handlers, is tested in process with `Action.client` ([Implementations](#implementations)).
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; if its output says it was truncated, read it again alone.
12
+ When something fails, search [Testing-failures.md](Testing-failures.md). Reading order:
13
+ [README.md](README.md).
6
14
 
7
15
  ## API
8
16
 
9
17
  Import `@gjermundgaraba/effect-actions/Testing`.
10
18
 
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 an `ActionMcp` endpoint serves, as the native request the test sends. |
17
- | `McpCallError` | What a client method fails with for an answer it cannot decode; `message` holds it. |
18
-
19
- | Option | Meaning |
20
- | ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
21
- | `url` | The endpoint, resolved by the `HttpClient` (relative under `layer`, or one prepending a base URL); default `/mcp`, the `ActionMcp.layerHttp` default. |
22
- | `mcpClient`: `transformClient` | Wraps the native `HttpClient`, as `ActionHttp.client` takes it: `HttpClient.mapRequest(HttpClientRequest.bearerToken(token))`. |
23
- | `mcpRequest`: `headers` | Request headers. |
24
-
25
- Exported types: `McpClient<Actions>`, a client's type; `McpClientOptions` of `mcpClient`; `McpRequestOptions` of `mcpRequest`, and `McpParams`, its `params`.
26
-
27
- `mcpClient(actions, options)` builds a client on the `HttpClient` in context, one method per
28
- action, called as a client method is: `mcp.getUser({ id })`. For every tool one implementation
29
- serves, pass its actions: `Testing.mcpClient(userActions.actions)`. A method succeeds with the
30
- action's decoded success. It fails with the action's declared errors and the built-in ones as
31
- decoded values, `SchemaError`, `HttpClientError`, or `McpCallError` for any other answer.
32
- Only a tool result carries the action's own errors; any other response decodes only as a
33
- refusal, as authentication and an authorizer send. The input may be left out when `{}` is a valid
34
- input, sending the input `{}` decodes to, and a given input is sent as given, as for a client's
35
- method.
36
-
37
- ## Canonical
38
-
39
- ```ts example=testing.ts
40
- import { Effect } from "effect";
41
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
42
- import * as Testing from "@gjermundgaraba/effect-actions/Testing";
43
- import { Greet, Http } from "./quickstart.js";
44
- import { routes } from "./quickstart-server.js";
45
-
46
- const program = Effect.gen(function* () {
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)))));
19
+ | API | Purpose |
20
+ | --------------------------------------- | ----------------------------------------------------------------------------- |
21
+ | `layer(routes)` | A `Layer<HttpClient>` answering requests with `routes` in memory. |
22
+ | `layer(handler)` | A `Layer<HttpClient>` answering requests with a web handler the test serves. |
23
+ | `mcpClient(actions, options?)` | An Effect of a client calling each action's tool, like `ActionHttp.client`. |
24
+ | `mcpRequest(method, params?, options?)` | One MCP request, as the native `HttpClientRequest` the test sends. |
25
+ | `McpCallError` | A client method's failure for an answer it cannot decode; `message` holds it. |
26
+
27
+ | Option | Meaning |
28
+ | ------------------------------ | ------------------------------------------------------------------------------- |
29
+ | `url` | The endpoint; default `/mcp`, the `ActionMcp.layerHttp` default. |
30
+ | `mcpClient`: `transformClient` | Wraps the native `HttpClient`, as `ActionHttp.client` takes it: a bearer token. |
31
+ | `mcpRequest`: `headers` | Request headers. |
32
+
33
+ Exported types: `McpClient<Actions>`, `McpClientOptions`, `McpRequestOptions`, and `McpParams`,
34
+ the `params` of `mcpRequest`. A helper passing on a tool's `arguments` types them `Schema.Json`,
35
+ and whole `params` `McpParams`, never `unknown`.
36
+
37
+ ## Test an implementation in process
38
+
39
+ Acquire `Action.client(implementation)` and call its methods
40
+ ([Action-in-process.md](Action-in-process.md#call-actions-in-process)). A call runs the decoding,
41
+ the `authorize` and the handler a server runs, but no authentication and no middleware. Provide
42
+ the startup services around the test, as an entrypoint does, and what a request would carry, its
43
+ caller first, around each call:
44
+
45
+ ```ts
46
+ const as = (user: keyof typeof users) => Effect.provideService(CurrentUser, users[user]);
47
+
48
+ it.effect("keeps each user's notes their own", () =>
49
+ Effect.gen(function* () {
50
+ const client = yield* Action.client(notes);
51
+ const note = yield* client.createNote({ title: "Plan", body: "Draft" }).pipe(as("ada"));
52
+
53
+ expect(yield* client.listNotes().pipe(as("grace"))).toEqual({ notes: [] });
54
+ }).pipe(Effect.provide(NoteStore.layerMemory)),
55
+ );
59
56
  ```
60
57
 
61
- ### Implementations
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";
75
-
76
- // Each call names its caller, as authentication names one per request.
77
- const asAlice = Effect.provideService(CurrentActor, actors.alice);
58
+ See [notes-service/test/notes/notes.test.ts](../examples/notes-service/test/notes/notes.test.ts).
78
59
 
79
- const asReader = Effect.provideService(CurrentActor, actors.reader);
60
+ - Replace the edge, not the service under test:
61
+ [mcp-tools/test/printers/printers.test.ts](../examples/mcp-tools/test/printers/printers.test.ts)
62
+ runs the real upstream service over a fake `HttpClient` Layer.
63
+ - `it.effect` runs on a test clock: fork a call that waits, `TestClock.adjust`, then join it.
64
+ - A stdio server runs the same implementations: test them here. A subprocess test adds only what
65
+ the process adds, stdout carrying nothing but the protocol.
66
+ - A Toolkit and a CLI need no `Testing` export: call `toolkit.handle` or script a
67
+ `LanguageModel` ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)),
68
+ and run commands with `Command.runWith`
69
+ ([assistant/test/commands.test.ts](../examples/assistant/test/commands.test.ts)).
80
70
 
81
- const program = Effect.gen(function* () {
82
- // Acquired once, as a layer is built: the builders run here, not per call.
83
- const users = yield* Action.client(userActions);
71
+ ## Test routes in memory
84
72
 
85
- // The methods of `ActionHttp.client(Http)`, with no transport between: each call decodes
86
- // its input, runs `authorize`, then the handler, and checks the success or the failure.
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
73
+ Export the routes still requiring what an entrypoint chooses, give the test its own, and provide
74
+ the layer to each test:
90
75
 
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
- );
76
+ ```ts
77
+ const served = Testing.layer(routes).pipe(Layer.provide(NoteStore.layerMemory));
98
78
  ```
99
79
 
100
- ### Signed-in callers
101
-
102
- Protected routes on the wire: serve them behind their authentication, the real provider or a
103
- test verifier of the same descriptor, and give each client its caller's credential. One `layer`
104
- serves every caller; the test program shares what the routes read, such as `Users`.
105
-
106
- ```ts example=testing-caller.ts
107
- import { Effect, Layer } from "effect";
108
- import { HttpClient, HttpClientRequest } from "effect/http";
109
- import * as ActionHttp from "@gjermundgaraba/effect-actions/ActionHttp";
110
- import * as Testing from "@gjermundgaraba/effect-actions/Testing";
111
- import { authenticate } from "./authentication.js";
112
- import { Http } from "./binding.js";
113
- import { userActions } from "./handlers.js";
114
- import { Users } from "./users.js";
115
-
116
- // The routes behind their real authentication; the caller is the token each client sends.
117
- const routes = Testing.layer(
118
- ActionHttp.layer(Http, userActions).pipe(Layer.provide(authenticate)),
119
- ).pipe(Layer.provideMerge(Users.layerMemory));
120
-
80
+ Under it, `ActionHttp.client(Http)` needs no `baseUrl`, and `HttpClient.get("/healthz")` reaches
81
+ the host's own routes. Each test builds the routes anew, so each starts from an empty store. See
82
+ [notes-service/test/routes.test.ts](../examples/notes-service/test/routes.test.ts); the smallest
83
+ test is
84
+ [quickstart/test/greeting/greeting.test.ts](../examples/quickstart/test/greeting/greeting.test.ts).
85
+
86
+ - A service the test reads too goes around `layer` with `Layer.provideMerge`, so handlers and
87
+ test share one instance
88
+ ([kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)).
89
+ - Two `layer`s are two servers: a second one runs every builder again, and they share only what
90
+ is provided around both. For two ports, that is `Action.layer(implementations)` and the
91
+ services ([kitchen-sink/test/routes.test.ts](../examples/kitchen-sink/test/routes.test.ts),
92
+ [Action-in-process.md](Action-in-process.md#share-one-build-across-servers)).
93
+ - The client is `layer`'s own. A fake upstream is another `HttpClient`: provide it to the service
94
+ that calls out ([mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts)).
95
+ - Every request on `layer`'s client reaches the routes, whatever its host: a client given a
96
+ `baseUrl`, a remote `ActionCli` command's included, runs in memory unchanged.
97
+ - Requests run in the test's context: a `ConfigProvider` or the test clock around the test
98
+ reaches middleware and handlers.
99
+ - To send raw web `Request`s too, build one `HttpRouter.toWebHandler` and dispose it yourself;
100
+ `Testing.layer(web.handler)` gives the typed clients the same handler
101
+ ([kitchen-sink/test/request-policy.test.ts](../examples/kitchen-sink/test/request-policy.test.ts)).
102
+
103
+ ## Test as a signed-in caller
104
+
105
+ The routes keep their real authentication. Serve them behind their provider
106
+ ([Authentication.md](Authentication.md#verify-credentials)), and give each client its caller's
107
+ credential:
108
+
109
+ ```ts
121
110
  const as = (token: string) => ({
122
111
  transformClient: HttpClient.mapRequest(HttpClientRequest.bearerToken(token)),
123
112
  });
113
+ ```
124
114
 
125
- const program = Effect.gen(function* () {
126
- const reader = yield* ActionHttp.client(Http, as("reader"));
127
- const refused = yield* Effect.flip(reader.renameUser({ id: "1", name: "Bea" })); // Forbidden
128
- const users = yield* Users;
115
+ Both clients take it: `ActionHttp.client(Http, as("reader"))` and
116
+ `Testing.mcpClient([GetStatus, ...Notes], as("ada"))`.
117
+
118
+ - Never provide the identity to `layer`, around it or to the routes. It is a type error: only a
119
+ provider of their own descriptor satisfies them.
120
+ - To change what a credential verifies as, replace the service the verifier reads (`servedWith`
121
+ in
122
+ [kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)),
123
+ or provide a test verifier: another `Authentication.layer` of the same descriptor.
124
+
125
+ A typed client decodes a refusal to its error. To assert on the answer as sent, build the request
126
+ (`HttpClientRequest.post`, `bearerToken`, `bodyJsonUnsafe`) and send it with `HttpClient.execute`,
127
+ which succeeds whatever the status. Read `response.status` and
128
+ `response.headers["www-authenticate"]`, the challenge, as that file does. The status and body of
129
+ each case are the table in [guarantees.md](guarantees.md#wire-behavior).
130
+
131
+ ## Test MCP tools
132
+
133
+ `Testing.mcpClient(actions, options?)` gives one method per action, calling its tool and decoding
134
+ the result as `ActionHttp.client` decodes a route's, an image included. Pass the contract values
135
+ the endpoint serves, such as `implementation.actions`.
136
+
137
+ A method fails with the action's declared errors and the built-in ones as decoded values, and with
138
+ `McpCallError` for any other answer, a result without its structured content included;
139
+ structured content that does not decode as the success is a `SchemaError`, and a request that
140
+ fails Effect's `HttpClientError`. Only a tool's result carries the action's own errors: an HTTP
141
+ answer other than 200 decodes as a refusal alone, so an error a verifier declares, typed over
142
+ `ActionHttp.client`, is an `McpCallError` here
143
+ ([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)).
144
+
145
+ A method decodes a refusal alike whether the endpoint answered a 401 or a 403 or returned a tool
146
+ result in a 200 ([guarantees.md](guarantees.md#wire-behavior)): to assert which, send the call
147
+ yourself and read its status.
148
+
149
+ For that, and for what the client's types will not send or its decoding hides (`tools/list`, an
150
+ `Origin` header, a refusal's challenge, a malformed call), build the request with
151
+ `Testing.mcpRequest`. It returns a native `HttpClientRequest`:
152
+ `yield* HttpClient.execute(Testing.mcpRequest("tools/call", { name: "whoAmI", arguments: {} }))`
153
+ succeeds with the response whatever its status, to read `response.status`, `response.headers`
154
+ and `yield* response.json`
155
+ ([kitchen-sink/test/identity/identity.test.ts](../examples/kitchen-sink/test/identity/identity.test.ts)
156
+ asserts a 401 so, and
157
+ [mcp-tools/test/routes.test.ts](../examples/mcp-tools/test/routes.test.ts) reads `tools/list`).
158
+ For a web handler or `fetch`, pass an absolute `url`: `HttpClientRequest.toWebResult(request)` is
159
+ then a `Result` holding the web `Request`, taken with `Result.getOrThrow`.
160
+
161
+ ## Test rpcs
162
+
163
+ `Testing.layer` answers HTTP requests and cannot upgrade a WebSocket. Export the rpc layer without
164
+ its protocol ([ActionRpc.md](ActionRpc.md#serve-the-rpcs)), and give it Effect's HTTP protocol in
165
+ the test, on both sides:
166
+
167
+ ```ts
168
+ const server = rpc.pipe(
169
+ Layer.provide(RpcServer.layerProtocolHttp({ path: "/rpc" })),
170
+ Layer.provide(RpcSerialization.layerJson),
171
+ );
129
172
 
130
- return { refused, unchanged: yield* users.get("acme", "1") };
131
- });
173
+ const served = RpcClient.layerProtocolHttp({ url: "/rpc" }).pipe(
174
+ Layer.provide(RpcSerialization.layerJson),
175
+ Layer.provide(Testing.layer(server)),
176
+ Layer.provide(MessageStore.layerMemory),
177
+ );
178
+
179
+ const as = (token: string) => RpcClient.withHeaders({ authorization: `Bearer ${token}` });
180
+ ```
132
181
 
133
- console.log(await Effect.runPromise(program.pipe(Effect.provide(routes))));
182
+ Authentication is per message, so each call carries its credential and one client serves every
183
+ caller: `client.postMessage({ text }).pipe(as("ada"))`. See
184
+ [live-rpc/test/routes.test.ts](../examples/live-rpc/test/routes.test.ts);
185
+ [live-rpc/test/browser.test.ts](../examples/live-rpc/test/browser.test.ts) tests a page's client
186
+ the same way. To call JSON routes in the same test, merge both route layers into the one
187
+ `Testing.layer` and give it to the protocol with `Layer.provideMerge`, which keeps its `HttpClient`
188
+ for the test
189
+ ([kitchen-sink/test/identity/session.test.ts](../examples/kitchen-sink/test/identity/session.test.ts)).
190
+ Effect's `RpcTest.makeClient` is no substitute: it asks for handlers of the
191
+ binding's group, which `ActionRpc.layer` does not provide (`Handler<"…">` stays required).
192
+
193
+ ## Assert on errors
194
+
195
+ Take the failure with `Effect.flip(call)`, which alone asserts only that the call failed, then
196
+ assert its class and its `message` separately:
197
+
198
+ ```ts
199
+ expect(refusal).toBeInstanceOf(Action.Forbidden);
200
+ expect(refusal.message).toBe("Requires notes:write.");
134
201
  ```
135
202
 
136
- A verifier needing infrastructure in production is replaced, for a test, by another `layer` of the
137
- same descriptor: `Authentication.layer(Login, (token) => ...)`.
138
- Any provider of that descriptor satisfies the routes; nothing else does.
139
-
140
- ## Rules
141
-
142
- - `layer(routes)` builds the routes with request logging off, and releases them with the layer's scope. What the routes still require is the layer's, as under `HttpRouter.serve`: their builders' services, and any per-request service no middleware of theirs provides, including one a global middleware reads. Provide them around it, with `Layer.provideMerge` where the program reads them too, so the handlers and the program share one instance. A per-request service provided there, such as a tenant, reaches every request. A protected action's identity is not such a service: its routes require their authentication provider, which nothing provided around `layer` replaces ([Signed-in callers](#signed-in-callers)). Each `layer` builds the routes anew, builders included, unless `Action.layer` built them above it. Requests run in the context the layer is built in, as under `HttpRouter.serve`: a `TestClock` or a reference provided around the program reaches middleware and handlers.
143
- - `layer` never requires the platform services `FileSystem`, `Path`, `HttpPlatform` and `Etag.Generator`, but they follow the same rule: at build and per request, the routes get the ones provided around `layer`, and `HttpServer.layerServices`' defaults for the rest, whose `FileSystem` is a no-op. The default `HttpPlatform` reads files through the `FileSystem` provided around `layer`.
144
- - A relative URL resolves against `http://localhost`, once any `baseUrl` a client adds is applied, so `ActionHttp.client(Http)` needs no `baseUrl` under `layer`, and one given is kept. Every request on this client is answered by the routes, whatever its host, so the native `HttpApiClient` and a remote `ActionCli` command work in memory too.
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. A media field it takes only from the blocks whose `_meta` names it, `{ "effect-actions/field": "image" }`, in order, never from the structured content, and a success that is media, or whose every field is, from its blocks alone ([ActionMcp.md](ActionMcp.md#media)): the method succeeds with the success as the handler returned it, the image's `data` a `Uint8Array`, an optional image returned as `undefined` absent, as no block tells the two apart. 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 the `structuredContent` its success is sent as, or a JSON-RPC error (an unknown tool). Match it with `Effect.catchTag("McpCallError", ...)`. A success that does not decode, such as one missing the blocks of its media fields, fails with `SchemaError`, as it does from `ActionHttp.client`.
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`.
203
+ `toEqual` never compares a tagged error's `message`, which is not an enumerable property:
204
+ `expect(error).toEqual(new Action.Forbidden({ message: "anything" }))` passes for every message,
205
+ and so does `toStrictEqual`. Other fields are compared, so `toEqual(new NoteNotFound({ id }))`
206
+ asserts the `id`, and `toEqual(new Action.Forbidden({ scopes: ["issues:write"] }))` still needs the
207
+ message asserted beside it. The message is all a model reads of a tool's failure.
208
+
209
+ `toBeInstanceOf` narrows nothing: the flipped failure stays the union of the action's declared
210
+ and built-in errors, so reading a field only one of them has, such as `retryAfterSeconds`, does
211
+ not compile. Compare with `toEqual(new RateLimited({ retryAfterSeconds: 10 }))`, or narrow with
212
+ `instanceof` first.
213
+
214
+ A Toolkit raises neither a refusal nor a declared error: each is a result the model reads, so
215
+ assert `isFailure` and `result.message`
216
+ ([assistant/test/toolkit.test.ts](../examples/assistant/test/toolkit.test.ts)).