Skip to content

Commit 63cdca3

Browse files
authored
Merge pull request #690 from RhysSullivan/codex/real-protocol-testing-first-pass
Add real protocol testing fixtures
2 parents b87fecc + ac6b02a commit 63cdca3

25 files changed

Lines changed: 702 additions & 197 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
---
2+
name: wrdn-effect-raw-fetch-boundary
3+
description: Route HTTP through Effect boundaries instead of raw fetch. Use when lint flags executor/no-raw-fetch or when adding networked protocol/provider code.
4+
allowed-tools: Read Grep Glob Bash
5+
---
6+
7+
HTTP in core SDK and protocol plugins should go through Effect services so tests
8+
can replace real networks with local protocol fixtures or mock `HttpClient`
9+
layers.
10+
11+
## Fix Shape
12+
13+
- Prefer `effect/unstable/http` `HttpClient` and `HttpClientRequest` for
14+
ordinary HTTP calls.
15+
- Accept a `Layer.Layer<HttpClient.HttpClient>` option on plugin/provider code
16+
when callers need to inject a test client.
17+
- In tests, use real local servers plus `FetchHttpClient.layer` or a captured
18+
`HttpClient` service from the test layer.
19+
- Do not add fetch-shaped abstractions in SDK or plugin seams. If a third-party
20+
library truly only accepts `fetch`, keep the adapter in the owning package,
21+
name the forced boundary explicitly, and delegate internally to Effect
22+
`HttpClient`.
23+
- Do not type new protocol/plugin seams as `typeof globalThis.fetch`; keep the
24+
ambient runtime boundary out of domain and test APIs.
25+
- Do not patch `globalThis.fetch`. Replace those tests with a local server,
26+
`HttpClient` layer, or the approved Effect-backed adapter.
27+
- Do not add a broad allowlist entry unless the file is a platform entrypoint
28+
or a temporary migration target.
29+
30+
## Approved Boundaries
31+
32+
- Worker/handler objects whose public API must expose a `fetch` method.
33+
- Test calls to a worker or Miniflare binding's `.fetch(...)` method.
34+
- Small adapters for libraries that only accept `fetch`, if the implementation
35+
delegates to Effect `HttpClient`.
36+
- Browser UI event handlers may remain raw only until app-side boundaries are
37+
classified; prefer SDK/client APIs where available.
38+
39+
## Bad
40+
41+
```ts
42+
const response = await fetch(url);
43+
```
44+
45+
```ts
46+
const fetchImpl = options.fetch ?? globalThis.fetch;
47+
```
48+
49+
## Good
50+
51+
```ts
52+
const client = yield * HttpClient.HttpClient;
53+
const response = yield * client.execute(HttpClientRequest.get(url));
54+
```
55+
56+
```ts
57+
const plugin = graphqlPlugin({ httpClientLayer: testHttpClientLayer });
58+
```

‎.oxlintrc.jsonc‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"executor/no-manual-tag-check": "error",
1818
"executor/no-promise-catch": "error",
1919
"executor/no-promise-reject": "error",
20+
"executor/no-raw-fetch": "error",
2021
"executor/no-redundant-primitive-cast": "error",
2122
"executor/no-redundant-error-factory": "error",
2223
"executor/no-ts-nocheck": "error",

‎bun.lock‎

Lines changed: 57 additions & 7 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎notes/real-protocol-testing-plan.md‎

Lines changed: 22 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ Testing services belong behind explicit `./testing` subpath exports:
4646

4747
The plugin packages own the protocol details. Core SDK testing owns only boring
4848
Executor fixtures such as memory adapters, memory secrets, auto-accept
49-
elicitation, and fetch-shaped adapters that delegate to Effect HTTP.
49+
elicitation, and other Effect-native test primitives.
5050

5151
Use `TestLayer` / `TestLayers` naming for realistic local test services.
5252
Reserve `LiveLayer` / `LiveLayers` for production wiring. These servers are
@@ -124,7 +124,6 @@ Suggested file layout:
124124
```txt
125125
packages/core/sdk/src/testing.ts
126126
packages/core/sdk/src/testing/
127-
fetch.ts
128127
memory-secrets.ts
129128
130129
packages/plugins/openapi/src/testing/
@@ -191,9 +190,6 @@ Useful additions:
191190
in-test secret provider definitions.
192191
- `makeExecutorTestLayer(options)`, if repeated `createExecutor(makeTestConfig)`
193192
setup becomes noisy.
194-
- `makeFetchFromHttpClient` or `FetchTestAdapter`, for third-party APIs that
195-
require a fetch-shaped callback while still routing requests through Effect's
196-
HTTP client.
197193
- Small request/response capture primitives built on `Ref`, only if multiple
198194
plugins need the same shape.
199195

@@ -243,8 +239,8 @@ Work:
243239
- Support a fresh `McpServer` factory per session, matching the current helper.
244240
- Keep malformed/non-MCP HTTP endpoints as explicit test layers for probe
245241
behavior.
246-
- Replace fetch-injected probe tests with real local HTTP servers and the
247-
Effect-backed fetch adapter.
242+
- Replace fetch-injected probe tests with real local HTTP servers and
243+
Effect-native HTTP boundaries.
248244

249245
Representative scenarios:
250246

@@ -261,10 +257,11 @@ Representative scenarios:
261257
GraphQL should gain a real executable schema server. This is the place where a
262258
new dependency may be justified.
263259

264-
Preferred first dependency is the core `graphql` package if it is enough:
265-
define a schema, execute introspection and operations with `graphql(...)`, and
266-
serve the standard JSON-over-HTTP GraphQL endpoint through Effect/node HTTP.
267-
Use `graphql-yoga` only if we need a fuller server feature set later.
260+
Use `graphql-yoga` for the test server. The extra dependency is justified
261+
because the fixture needs to exercise a real JSON-over-HTTP GraphQL endpoint,
262+
not only direct `graphql(...)` execution. Keep Vitest configured to inline the
263+
Yoga/GraphQL dependency chain so executable schemas and server execution share
264+
one GraphQL module realm.
268265

269266
Work:
270267

@@ -292,19 +289,19 @@ Representative scenarios:
292289
## Fetch Boundary
293290

294291
The target rule is: product and plugin code should not call raw `fetch`
295-
directly. HTTP should go through Effect HTTP or a small approved adapter that
296-
turns an Effect `HttpClient` into a fetch-shaped function for libraries that
297-
force that API.
292+
directly. HTTP should go through Effect HTTP. If a third-party library truly
293+
forces a fetch-shaped callback, the adapter should live at that owning package's
294+
boundary rather than in shared SDK testing.
298295

299296
Boundary files may exist, but they should be explicit and scarce. Examples:
300297

301-
- a SDK adapter for `oauth4webapi` custom fetch
298+
- a core OAuth adapter for `oauth4webapi` custom fetch, if that library forces it
302299
- a MCP transport adapter if the upstream MCP SDK requires fetch-shaped input
303300
- platform entrypoints that must implement a `fetch(request, env, ctx)` method
304301
- test harnesses that intentionally call a Worker binding's `fetch` method
305302

306-
Everything else should use Effect HTTP, Executor client APIs, or the approved
307-
adapter.
303+
Everything else should use Effect HTTP, Executor client APIs, or an explicit
304+
package-local boundary.
308305

309306
Lint plan:
310307

@@ -319,17 +316,17 @@ Lint plan:
319316
- Start with a narrow allowlist for known boundary files and remove entries as
320317
migrations land.
321318
- Include error messages that tell the author which Effect HTTP or approved
322-
adapter to use.
319+
package-local boundary to use.
323320

324-
The lint rule should land after the first adapter exists. Otherwise every fix
325-
has to invent its own local escape hatch.
321+
The lint rule can land once the core test primitives and at least one real
322+
protocol fixture exist. Fetch-shaped adapters should be added only at forced
323+
library boundaries during migration.
326324

327325
## Migration Order
328326

329327
1. Add `./testing` exports and SDK test primitives.
330328
- Expose `@executor-js/sdk/testing`.
331329
- Add memory secrets test support.
332-
- Add the Effect HTTP to fetch-shaped adapter.
333330
- Keep `makeTestConfig` available from the root SDK.
334331

335332
2. Build the GraphQL vertical slice.
@@ -344,8 +341,8 @@ has to invent its own local escape hatch.
344341
- Migrate current tests to import from the new public testing subpaths.
345342

346343
4. Replace global fetch patching.
347-
- Convert core OAuth discovery/helper tests to real local servers or the
348-
Effect-backed fetch adapter.
344+
- Convert core OAuth discovery/helper tests to real local servers or
345+
package-local Effect HTTP boundaries.
349346
- Convert OpenAPI OAuth tests to the OAuth test server.
350347
- Convert MCP probe tests to real local HTTP server layers.
351348
- Convert Google Discovery tests using the same boundary pattern.
@@ -403,8 +400,8 @@ Turbo. Do not use `bun test`.
403400

404401
- Should `@executor-js/sdk/testing` be a new subpath around the existing
405402
`src/testing.ts`, or should `src/testing.ts` become `src/testing/index.ts`?
406-
- Should GraphQL use only the `graphql` package, or should we adopt
407-
`graphql-yoga` once tests need more HTTP behavior?
403+
- How much GraphQL HTTP behavior should live in the shared fixture versus
404+
scenario-specific schemas in individual tests?
408405
- Which app-level raw fetch calls should remain approved platform boundaries
409406
versus being migrated to Effect HTTP?
410407
- Should the raw fetch lint rule become repo-wide immediately with allowlists,

‎packages/core/sdk/package.json‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
".": "./src/index.ts",
2020
"./core": "./src/index.ts",
2121
"./promise": "./src/promise.ts",
22-
"./client": "./src/client.ts"
22+
"./client": "./src/client.ts",
23+
"./testing": "./src/testing.ts"
2324
},
2425
"publishConfig": {
2526
"access": "public",
@@ -41,6 +42,12 @@
4142
"types": "./dist/client.d.ts",
4243
"default": "./dist/client.js"
4344
}
45+
},
46+
"./testing": {
47+
"import": {
48+
"types": "./dist/testing.d.ts",
49+
"default": "./dist/testing.js"
50+
}
4451
}
4552
}
4653
},

‎packages/core/sdk/src/testing.ts‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory";
22

3+
import { Effect } from "effect";
4+
35
import { makeInMemoryBlobStore } from "./blob";
46
import type { ExecutorConfig } from "./executor";
57
import { collectSchemas } from "./executor";
68
import { ScopeId } from "./ids";
7-
import type { AnyPlugin } from "./plugin";
9+
import { definePlugin, type AnyPlugin } from "./plugin";
810
import { Scope } from "./scope";
11+
import type { SecretProvider } from "./secrets";
912

1013
// ---------------------------------------------------------------------------
1114
// makeTestConfig — build an ExecutorConfig backed by in-memory adapter +
@@ -42,3 +45,31 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = []>
4245
onElicitation: "accept-all",
4346
};
4447
};
48+
49+
export const memorySecretsPlugin = definePlugin(() => {
50+
const store = new Map<string, string>();
51+
52+
const provider: SecretProvider = {
53+
key: "memory",
54+
writable: true,
55+
get: (id, scope) => Effect.sync(() => store.get(`${scope}\u0000${id}`) ?? null),
56+
set: (id, value, scope) =>
57+
Effect.sync(() => {
58+
store.set(`${scope}\u0000${id}`, value);
59+
}),
60+
delete: (id, scope) => Effect.sync(() => store.delete(`${scope}\u0000${id}`)),
61+
list: () =>
62+
Effect.sync(() =>
63+
Array.from(store.keys()).map((key) => {
64+
const name = key.split("\u0000", 2)[1] ?? key;
65+
return { id: name, name };
66+
}),
67+
),
68+
};
69+
70+
return {
71+
id: "memory-secrets" as const,
72+
storage: () => ({}),
73+
secretProviders: [provider],
74+
};
75+
});

‎packages/core/sdk/tsup.config.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export default defineConfig({
55
index: "src/promise.ts",
66
core: "src/index.ts",
77
client: "src/client.ts",
8+
testing: "src/testing.ts",
89
},
910
format: ["esm"],
1011
dts: false,

‎packages/plugins/graphql/package.json‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@
2121
"./api": "./src/api/index.ts",
2222
"./react": "./src/react/index.ts",
2323
"./presets": "./src/sdk/presets.ts",
24-
"./client": "./src/react/plugin-client.tsx"
24+
"./client": "./src/react/plugin-client.tsx",
25+
"./testing": "./src/testing/index.ts"
2526
},
2627
"publishConfig": {
2728
"access": "public",
@@ -37,6 +38,12 @@
3738
"types": "./dist/sdk/index.d.ts",
3839
"default": "./dist/core.js"
3940
}
41+
},
42+
"./testing": {
43+
"import": {
44+
"types": "./dist/testing/index.d.ts",
45+
"default": "./dist/testing.js"
46+
}
4047
}
4148
}
4249
},
@@ -51,7 +58,9 @@
5158
"@effect/platform-node": "catalog:",
5259
"@executor-js/config": "workspace:*",
5360
"@executor-js/sdk": "workspace:*",
54-
"effect": "catalog:"
61+
"effect": "catalog:",
62+
"graphql": "^16.12.0",
63+
"graphql-yoga": "^5.17.0"
5564
},
5665
"devDependencies": {
5766
"@effect/atom-react": "catalog:",

‎packages/plugins/graphql/src/sdk/introspect.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* (
220220
);
221221

222222
if (response.status !== 200) {
223+
const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("<unreadable>")));
223224
return yield* new GraphqlIntrospectionError({
224-
message: `Introspection failed with status ${response.status}`,
225+
message: `Introspection failed with status ${response.status}: ${body.slice(0, 1_000)}`,
225226
});
226227
}
227228

0 commit comments

Comments
 (0)