History

Merge pull request #690 from RhysSullivan/codex/real-protocol-testing-first-pass

Add real protocol testing fixtures

Rhys Sullivan committed 6 months ago

Commit 63cdca32 parents b87fecc ac6b02a25 files · +702 −197Browse files

Unified · Split

25 files
  1. .agents/skills/wrdn-effect-raw-fetch-boundary/SKILL.md +58 −0
  2. .oxlintrc.jsonc +1 −0
  3. bun.lock +57 −7
  4. notes/real-protocol-testing-plan.md +22 −25
  5. packages/core/sdk/package.json +8 −1
  6. packages/core/sdk/src/testing.ts +32 −1
  7. packages/core/sdk/tsup.config.ts +1 −0
  8. packages/plugins/graphql/package.json +11 −2
  9. packages/plugins/graphql/src/sdk/introspect.ts +2 −1
  10. packages/plugins/graphql/src/sdk/plugin.test.ts +118 −111
  11. packages/plugins/graphql/src/testing/index.ts +195 −0
  12. packages/plugins/graphql/tsup.config.ts +2 −1
  13. packages/plugins/graphql/vitest.config.ts +5 −0
  14. packages/plugins/mcp/package.json +8 −1
  15. packages/plugins/mcp/src/sdk/elicitation.test.ts +1 −1
  16. packages/plugins/mcp/src/sdk/plugin.test.ts +1 −1
  17. packages/plugins/mcp/src/testing/index.ts +1 −0
  18. packages/plugins/mcp/src/testing/server.ts +0 −6
  19. packages/plugins/mcp/tsup.config.ts +1 −0
  20. packages/plugins/openapi/package.json +8 −1
  21. packages/plugins/openapi/src/sdk/plugin.test.ts +5 −38
  22. packages/plugins/openapi/src/testing/index.ts +86 −0
  23. packages/plugins/openapi/tsup.config.ts +1 −0
  24. scripts/oxlint-plugin-executor.js +2 −0
  25. scripts/oxlint-plugin-executor/rules/no-raw-fetch.js +76 −0
.agents/skills/wrdn-effect-raw-fetch-boundary/SKILL.md+58−0
@@ -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+1−0
@@ -17,6 +17,7 @@ · 16 unmodified lines
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+57−7
@@ -601,6 +601,8 @@ · 600 unmodified lines
601601 "@executor-js/config": "workspace:*",
602602 "@executor-js/sdk": "workspace:*",
603603 "effect": "catalog:",
604+ "graphql": "^16.12.0",
605+ "graphql-yoga": "^5.17.0",
604606 },
605607 "devDependencies": {
606608 "@effect/atom-react": "catalog:",
@@ -1157,6 +1159,12 @@ · 550 unmodified lines
11571159
11581160 "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="],
11591161
1162+ "@envelop/core": ["@envelop/core@5.5.1", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@envelop/types": "^5.2.1", "@whatwg-node/promise-helpers": "^1.2.4", "tslib": "^2.5.0" } }, "sha512-3DQg8sFskDo386TkL5j12jyRAdip/8yzK3x7YGbZBgobZ4aKXrvDU0GppU0SnmrpQnNaiTUsxBs9LKkwQ/eyvw=="],
1163+
1164+ "@envelop/instrumentation": ["@envelop/instrumentation@1.0.0", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.2.1", "tslib": "^2.5.0" } }, "sha512-cxgkB66RQB95H3X27jlnxCRNTmPuSTgmBAq6/4n2Dtv4hsk4yz8FadA1ggmd0uZzvKqWD6CR+WFgTjhDqg7eyw=="],
1165+
1166+ "@envelop/types": ["@envelop/types@5.2.1", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.5.0" } }, "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg=="],
1167+
11601168 "@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
11611169
11621170 "@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
@@ -1277,6 +1285,8 @@ · 114 unmodified lines
12771285
12781286 "@executor-js/vite-plugin": ["@executor-js/vite-plugin@workspace:packages/core/vite-plugin"],
12791287
1288+ "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="],
1289+
12801290 "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="],
12811291
12821292 "@floating-ui/dom": ["@floating-ui/dom@1.7.6", "", { "dependencies": { "@floating-ui/core": "^1.7.5", "@floating-ui/utils": "^0.2.11" } }, "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ=="],
@@ -1289,6 +1299,22 @@ · 6 unmodified lines
12891299
12901300 "@giscus/react": ["@giscus/react@3.1.0", "", { "dependencies": { "giscus": "^1.6.0" }, "peerDependencies": { "react": "^16 || ^17 || ^18 || ^19", "react-dom": "^16 || ^17 || ^18 || ^19" } }, "sha512-0TCO2TvL43+oOdyVVGHDItwxD1UMKP2ZYpT6gXmhFOqfAJtZxTzJ9hkn34iAF/b6YzyJ4Um89QIt9z/ajmAEeg=="],
12911301
1302+ "@graphql-tools/executor": ["@graphql-tools/executor@1.5.3", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "@graphql-typed-document-node/core": "^3.2.0", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mgBFC0bsrZPZLu9EnydpMnAuQ8Iiq0CEbUcsmvXsm2/iYektGHDN/+bmb7hicA6dWZtdPfklYJmr21WD0GnOfA=="],
1303+
1304+ "@graphql-tools/merge": ["@graphql-tools/merge@9.1.9", "", { "dependencies": { "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q=="],
1305+
1306+ "@graphql-tools/schema": ["@graphql-tools/schema@10.0.33", "", { "dependencies": { "@graphql-tools/merge": "^9.1.9", "@graphql-tools/utils": "^11.1.0", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ=="],
1307+
1308+ "@graphql-tools/utils": ["@graphql-tools/utils@10.11.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-iBFR9GXIs0gCD+yc3hoNswViL1O5josI33dUqiNStFI/MHLCEPduasceAcazRH77YONKNiviHBV8f7OgcT4o2Q=="],
1309+
1310+ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
1311+
1312+ "@graphql-yoga/logger": ["@graphql-yoga/logger@2.0.1", "", { "dependencies": { "tslib": "^2.8.1" } }, "sha512-Nv0BoDGLMg9QBKy9cIswQ3/6aKaKjlTh87x3GiBg2Z4RrjyrM48DvOOK0pJh1C1At+b0mUIM67cwZcFTDLN4sA=="],
1313+
1314+ "@graphql-yoga/subscription": ["@graphql-yoga/subscription@5.0.5", "", { "dependencies": { "@graphql-yoga/typed-event-target": "^3.0.2", "@repeaterjs/repeater": "^3.0.4", "@whatwg-node/events": "^0.1.0", "tslib": "^2.8.1" } }, "sha512-oCMWOqFs6QV96/NZRt/ZhTQvzjkGB4YohBOpKM4jH/lDT4qb7Lex/aGCxpi/JD9njw3zBBtMqxbaC22+tFHVvw=="],
1315+
1316+ "@graphql-yoga/typed-event-target": ["@graphql-yoga/typed-event-target@3.0.2", "", { "dependencies": { "@repeaterjs/repeater": "^3.0.4", "tslib": "^2.8.1" } }, "sha512-ZpJxMqB+Qfe3rp6uszCQoag4nSw42icURnBRfFYSOmTgEeOe4rD0vYlbA8spvCu2TlCesNTlEN9BLWtQqLxabA=="],
1317+
12921318 "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="],
12931319
12941320 "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="],
@@ -1939,6 +1965,8 @@ · 644 unmodified lines
19391965
19401966 "@reduxjs/toolkit": ["@reduxjs/toolkit@2.11.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@standard-schema/utils": "^0.3.0", "immer": "^11.0.0", "redux": "^5.0.1", "redux-thunk": "^3.1.0", "reselect": "^5.1.0" }, "peerDependencies": { "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" }, "optionalPeers": ["react", "react-redux"] }, "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ=="],
19411967
1968+ "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="],
1969+
19421970 "@rhyssul/portless": ["@rhyssul/portless@0.13.3", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-oPvwXJIIkRg2i1CUEUsz8sAdFSf0Fzzv+NmaacAsi6ZZTHxDOofbO6fGInVnbESIFOu4k8a/rXOZRF0aqLAUgw=="],
19431971
19441972 "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.0-rc.15", "", { "os": "android", "cpu": "arm64" }, "sha512-YYe6aWruPZDtHNpwu7+qAHEMbQ/yRl6atqb/AhznLTnD3UY99Q1jE7ihLSahNWkF4EqRPVC4SiR4O0UkLK02tA=="],
@@ -2337,6 +2365,18 @@ · 392 unmodified lines
23372365
23382366 "@vitest/utils": ["@vitest/utils@4.1.5", "", { "dependencies": { "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", "tinyrainbow": "^3.1.0" } }, "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug=="],
23392367
2368+ "@whatwg-node/disposablestack": ["@whatwg-node/disposablestack@0.0.6", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.6.3" } }, "sha512-LOtTn+JgJvX8WfBVJtF08TGrdjuFzGJc4mkP8EdDI8ADbvO7kiexYep1o8dwnt0okb0jYclCDXF13xU7Ge4zSw=="],
2369+
2370+ "@whatwg-node/events": ["@whatwg-node/events@0.1.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-ApcWxkrs1WmEMS2CaLLFUEem/49erT3sxIVjpzU5f6zmVcnijtDSrhoK2zVobOIikZJdH63jdAXOrvjf6eOUNQ=="],
2371+
2372+ "@whatwg-node/fetch": ["@whatwg-node/fetch@0.10.13", "", { "dependencies": { "@whatwg-node/node-fetch": "^0.8.3", "urlpattern-polyfill": "^10.0.0" } }, "sha512-b4PhJ+zYj4357zwk4TTuF2nEe0vVtOrwdsrNo5hL+u1ojXNhh1FgJ6pg1jzDlwlT4oBdzfSwaBwMCtFCsIWg8Q=="],
2373+
2374+ "@whatwg-node/node-fetch": ["@whatwg-node/node-fetch@0.8.5", "", { "dependencies": { "@fastify/busboy": "^3.1.1", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-4xzCl/zphPqlp9tASLVeUhB5+WJHbuWGYpfoC2q1qh5dw0AqZBW7L27V5roxYWijPxj4sspRAAoOH3d2ztaHUQ=="],
2375+
2376+ "@whatwg-node/promise-helpers": ["@whatwg-node/promise-helpers@1.3.2", "", { "dependencies": { "tslib": "^2.6.3" } }, "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA=="],
2377+
2378+ "@whatwg-node/server": ["@whatwg-node/server@0.10.18", "", { "dependencies": { "@envelop/instrumentation": "^1.0.0", "@whatwg-node/disposablestack": "^0.0.6", "@whatwg-node/fetch": "^0.10.13", "@whatwg-node/promise-helpers": "^1.3.2", "tslib": "^2.6.3" } }, "sha512-kMwLlxUbduttIgaPdSkmEarFpP+mSY8FEm+QWMBRJwxOHWkri+cxd8KZHO9EMrB9vgUuz+5WEaCawaL5wGVoXg=="],
2379+
23402380 "@workos-inc/node": ["@workos-inc/node@8.13.0", "", { "dependencies": { "eventemitter3": "^5.0.4" } }, "sha512-NgQKHpwh8AbT4KvAsW91Y+4f4jja2IvFPQ5atcy5NUxUMVRgXzRFEee3erawfXrTmiCVqJjd9PljHySKBXmHKQ=="],
23412381
23422382 "@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="],
@@ -2637,6 +2677,8 @@ · 294 unmodified lines
26372677
26382678 "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="],
26392679
2680+ "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="],
2681+
26402682 "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
26412683
26422684 "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="],
@@ -3083,6 +3125,10 @@ · 440 unmodified lines
30833125
30843126 "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
30853127
3128+ "graphql": ["graphql@16.14.0", "", {}, "sha512-BBvQ/406p+4CZbTpCbVPSxfzrZrbnuWSP1ELYgyS6B+hNeKzgrdB4JczCa5VZUBQrDa9hUngm0KnexY6pJRN5Q=="],
3129+
3130+ "graphql-yoga": ["graphql-yoga@5.21.0", "", { "dependencies": { "@envelop/core": "^5.5.1", "@envelop/instrumentation": "^1.0.0", "@graphql-tools/executor": "^1.5.0", "@graphql-tools/schema": "^10.0.11", "@graphql-tools/utils": "^10.11.0", "@graphql-yoga/logger": "^2.0.1", "@graphql-yoga/subscription": "^5.0.5", "@whatwg-node/fetch": "^0.10.6", "@whatwg-node/promise-helpers": "^1.3.2", "@whatwg-node/server": "^0.10.14", "lru-cache": "^10.0.0", "tslib": "^2.8.1" }, "peerDependencies": { "graphql": "^15.2.0 || ^16.0.0" } }, "sha512-PS37UoDihx8209RRl1ogttzWevNYDnGvP7beHkwHzUpUdfZTHsVRTVe1ysGXre1EjwUAePbpez302YSrq70Ngw=="],
3131+
30863132 "h3": ["h3@1.15.11", "", { "dependencies": { "cookie-es": "^1.2.3", "crossws": "^0.3.5", "defu": "^6.1.6", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.4", "radix3": "^1.1.2", "ufo": "^1.6.3", "uncrypto": "^0.1.3" } }, "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg=="],
30873133
30883134 "h3-v2": ["h3@2.0.1-rc.20", "", { "dependencies": { "rou3": "^0.8.1", "srvx": "^0.11.13" }, "peerDependencies": { "crossws": "^0.4.1" }, "optionalPeers": ["crossws"], "bin": { "h3": "bin/h3.mjs" } }, "sha512-28ljodXuUp0fZovdiSRq4G9OgrxCztrJe5VdYzXAB7ueRvI7pIUqLU14Xi3XqdYJ/khXjfpUOOD2EQa6CmBgsg=="],
@@ -3431,7 +3477,7 @@ · 342 unmodified lines
34313477
34323478 "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="],
34333479
3434− "lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
3480+ "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
34353481
34363482 "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="],
34373483
@@ -4417,6 +4463,8 @@ · 979 unmodified lines
44174463
44184464 "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="],
44194465
4466+ "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="],
4467+
44204468 "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="],
44214469
44224470 "use-merge-value": ["use-merge-value@1.2.0", "", { "peerDependencies": { "react": ">= 16.x" } }, "sha512-DXgG0kkgJN45TcyoXL49vJnn55LehnrmoHc7MbKi+QDBvr8dsesqws8UlyIWGHMR+JXgxc1nvY+jDGMlycsUcw=="],
@@ -4645,6 +4693,12 @@ · 222 unmodified lines
46454693
46464694 "@executor-js/storage-postgres/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
46474695
4696+ "@graphql-tools/executor/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="],
4697+
4698+ "@graphql-tools/merge/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="],
4699+
4700+ "@graphql-tools/schema/@graphql-tools/utils": ["@graphql-tools/utils@11.1.0", "", { "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", "@whatwg-node/promise-helpers": "^1.0.0", "cross-inspect": "1.0.1", "tslib": "^2.4.0" }, "peerDependencies": { "graphql": "^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag=="],
4701+
46484702 "@inquirer/core/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="],
46494703
46504704 "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
@@ -4679,8 +4733,6 @@ · 28 unmodified lines
46794733
46804734 "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="],
46814735
4682− "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
4683−
46844736 "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-exporter-base": ["@opentelemetry/otlp-exporter-base@0.214.0", "", { "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/otlp-transformer": "0.214.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg=="],
46854737
46864738 "@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.214.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.214.0", "@opentelemetry/core": "2.6.1", "@opentelemetry/resources": "2.6.1", "@opentelemetry/sdk-logs": "0.214.0", "@opentelemetry/sdk-metrics": "2.6.1", "@opentelemetry/sdk-trace-base": "2.6.1", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w=="],
@@ -4885,8 +4937,6 @@ · 198 unmodified lines
48854937
48864938 "cacache/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
48874939
4888− "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
4889−
48904940 "cacache/p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="],
48914941
48924942 "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="],
@@ -5027,8 +5077,6 @@ · 134 unmodified lines
50275077
50285078 "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
50295079
5030− "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
5031−
50325080 "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
50335081
50345082 "posthog-js/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.208.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg=="],
@@ -5101,6 +5149,8 @@ · 66 unmodified lines
51015149
51025150 "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="],
51035151
5152+ "unstorage/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="],
5153+
51045154 "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
51055155
51065156 "wrangler/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="],
notes/real-protocol-testing-plan.md+22−25
@@ -46,7 +46,7 @@ Testing services belong behind explicit `./testing` subpath exports: · 45 unmodified lines
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: · 71 unmodified lines
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: · 60 unmodified lines
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: · 43 unmodified lines
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: · 10 unmodified lines
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: · 21 unmodified lines
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: · 8 unmodified lines
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. · 8 unmodified lines
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`. · 51 unmodified lines
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+8−1
@@ -19,7 +19,8 @@ · 18 unmodified lines
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 @@ · 15 unmodified lines
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+32−1
@@ -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[] = []> · 30 unmodified lines
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+1−0
@@ -5,6 +5,7 @@ export default defineConfig({ · 4 unmodified lines
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+11−2
@@ -21,7 +21,8 @@ · 20 unmodified lines
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 @@ · 9 unmodified lines
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 @@ · 8 unmodified lines
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+2−1
@@ -220,8 +220,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 219 unmodified lines
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
packages/plugins/graphql/src/sdk/plugin.test.ts+118−111
@@ -1,26 +1,24 @@
11 import { describe, it, expect } from "@effect/vitest";
2−import { Data, Effect } from "effect";
2+import { Effect } from "effect";
33
44 import {
55 ConnectionId,
66 CreateConnectionInput,
77 createExecutor,
8− definePlugin,
98 makeTestConfig,
109 Scope,
1110 ScopeId,
1211 SecretId,
13− type SecretProvider,
1412 TokenMaterial,
1513 } from "@executor-js/sdk";
14+import { memorySecretsPlugin } from "@executor-js/sdk/testing";
1615
1716 import { graphqlPlugin } from "./plugin";
1817 import type { IntrospectionResult } from "./introspect";
18+import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing";
1919
2020 const TEST_SCOPE = "test-scope";
2121
22−class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{}> {}
23−
2422 // ---------------------------------------------------------------------------
2523 // Mock introspection response
2624 // ---------------------------------------------------------------------------
@@ -92,31 +90,119 @@ const introspectionResult: IntrospectionResult = { · 65 unmodified lines
9290
9391 const introspectionJson = JSON.stringify({ data: introspectionResult });
9492
95−const makeMemorySecretsPlugin = () => {
96− const store = new Map<string, string>();
97− const provider: SecretProvider = {
98− key: "memory",
99− writable: true,
100− get: (id, scope) => Effect.sync(() => store.get(`${scope}\u0000${id}`) ?? null),
101− set: (id, value, scope) =>
102− Effect.sync(() => {
103− store.set(`${scope}\u0000${id}`, value);
104− }),
105− delete: (id, scope) => Effect.sync(() => store.delete(`${scope}\u0000${id}`)),
106− list: () =>
107− Effect.sync(() =>
108− Array.from(store.keys()).map((key) => {
109− const name = key.split("\u0000", 2)[1] ?? key;
110− return { id: name, name };
93+describe("graphqlPlugin real protocol server", () => {
94+ const serveGreetingServer = serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() });
95+
96+ it.effect("adds a source by introspecting the live GraphQL endpoint", () =>
97+ Effect.gen(function* () {
98+ const server = yield* serveGreetingServer;
99+ const executor = yield* createExecutor(
100+ makeTestConfig({ plugins: [graphqlPlugin()] as const }),
101+ );
102+
103+ const result = yield* executor.graphql.addSource({
104+ endpoint: server.endpoint,
105+ scope: TEST_SCOPE,
106+ namespace: "live_graph",
107+ });
108+
109+ expect(result).toEqual({ toolCount: 2, namespace: "live_graph" });
110+
111+ const tools = yield* executor.tools.list();
112+ expect(tools.map((tool) => tool.id)).toEqual(
113+ expect.arrayContaining(["live_graph.query.hello", "live_graph.mutation.setGreeting"]),
114+ );
115+
116+ const requests = yield* server.requests;
117+ expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe(true);
118+ }),
119+ );
120+
121+ it.effect("invokes a live query with headers and query params", () =>
122+ Effect.gen(function* () {
123+ const server = yield* serveGreetingServer;
124+ const executor = yield* createExecutor(
125+ makeTestConfig({ plugins: [graphqlPlugin()] as const }),
126+ );
127+
128+ yield* executor.graphql.addSource({
129+ endpoint: server.endpoint,
130+ scope: TEST_SCOPE,
131+ namespace: "live_invoke",
132+ headers: { "x-static": "abc" },
133+ queryParams: { token: "qp-token" },
134+ });
135+ yield* server.clearRequests;
136+
137+ const result = yield* executor.tools.invoke("live_invoke.query.hello", {
138+ name: "Ada",
139+ });
140+
141+ expect(result).toEqual({
142+ status: 200,
143+ data: { hello: "Hello Ada" },
144+ errors: null,
145+ });
146+
147+ const requests = yield* server.requests;
148+ expect(requests.length).toBe(1);
149+ expect(requests[0]?.headers["x-static"]).toBe("abc");
150+ expect(new URL(requests[0]!.url).searchParams.get("token")).toBe("qp-token");
151+ expect(requests[0]?.payload.variables).toEqual({ name: "Ada" });
152+ }),
153+ );
154+
155+ it.effect("invokes OAuth-backed sources with a bearer token", () =>
156+ Effect.gen(function* () {
157+ const server = yield* serveGreetingServer;
158+ const executor = yield* createExecutor(
159+ makeTestConfig({
160+ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const,
111161 }),
112− ),
113− };
114− return definePlugin(() => ({
115− id: "memory-secrets" as const,
116− storage: () => ({}),
117− secretProviders: [provider],
118− }));
119−};
162+ );
163+
164+ const connectionId = ConnectionId.make("graphql-oauth2-test");
165+ yield* executor.connections.create(
166+ new CreateConnectionInput({
167+ id: connectionId,
168+ scope: ScopeId.make(TEST_SCOPE),
169+ provider: "oauth2",
170+ identityLabel: "GraphQL Test",
171+ accessToken: new TokenMaterial({
172+ secretId: SecretId.make(`${connectionId}.access_token`),
173+ name: "GraphQL Access Token",
174+ value: "secret-token",
175+ }),
176+ refreshToken: null,
177+ expiresAt: null,
178+ oauthScope: null,
179+ providerState: null,
180+ }),
181+ );
182+
183+ yield* executor.graphql.addSource({
184+ endpoint: server.endpoint,
185+ scope: TEST_SCOPE,
186+ namespace: "oauth_graph",
187+ auth: { kind: "oauth2", connectionId },
188+ });
189+ yield* server.clearRequests;
190+
191+ const result = yield* executor.tools.invoke("oauth_graph.query.hello", {
192+ name: "Ada",
193+ });
194+
195+ expect(result).toEqual({
196+ status: 200,
197+ data: { hello: "Hello Ada" },
198+ errors: null,
199+ });
200+
201+ const requests = yield* server.requests;
202+ expect(requests[0]?.headers.authorization).toBe("Bearer secret-token");
203+ }),
204+ );
205+});
120206
121207 describe("graphqlPlugin", () => {
122208 it.effect("registers tools from introspection JSON", () =>
@@ -256,85 +342,6 @@ describe("graphqlPlugin", () => { · 133 unmodified lines
256342 }),
257343 );
258344
259− it.effect("invokes OAuth-backed sources with a bearer token", () =>
260− Effect.gen(function* () {
261− const http = yield* Effect.promise(() => import("node:http"));
262− let authorizationHeader: string | null = null;
263− const server = yield* Effect.acquireRelease(
264− Effect.callback<
265− {
266− readonly server: ReturnType<typeof http.createServer>;
267− readonly port: number;
268− },
269− Error | TestServerAddressError
270− >((resume) => {
271− const server = http.createServer((req, res) => {
272− authorizationHeader = req.headers.authorization ?? null;
273− res.setHeader("content-type", "application/json");
274− res.end(JSON.stringify({ data: { hello: "Hello Ada" } }));
275− });
276− server.listen(0, "127.0.0.1", () => {
277− const address = server.address();
278− if (!address || typeof address === "string") {
279− resume(Effect.fail(new TestServerAddressError()));
280− return;
281− }
282− resume(Effect.succeed({ server, port: address.port }));
283− });
284− server.once("error", (error) => resume(Effect.fail(error)));
285− }),
286− ({ server }) =>
287− Effect.callback<void>((resume) => {
288− server.close(() => resume(Effect.void));
289− }).pipe(Effect.ignore),
290− );
291−
292− const executor = yield* createExecutor(
293− makeTestConfig({
294− plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const,
295− }),
296− );
297−
298− const connectionId = ConnectionId.make("graphql-oauth2-test");
299− yield* executor.connections.create(
300− new CreateConnectionInput({
301− id: connectionId,
302− scope: ScopeId.make(TEST_SCOPE),
303− provider: "oauth2",
304− identityLabel: "GraphQL Test",
305− accessToken: new TokenMaterial({
306− secretId: SecretId.make(`${connectionId}.access_token`),
307− name: "GraphQL Access Token",
308− value: "secret-token",
309− }),
310− refreshToken: null,
311− expiresAt: null,
312− oauthScope: null,
313− providerState: null,
314− }),
315− );
316−
317− yield* executor.graphql.addSource({
318− endpoint: `http://127.0.0.1:${server.port}/graphql`,
319− scope: TEST_SCOPE,
320− introspectionJson,
321− namespace: "oauth_graph",
322− auth: { kind: "oauth2", connectionId },
323− });
324−
325− const result = yield* executor.tools.invoke("oauth_graph.query.hello", {
326− name: "Ada",
327− });
328−
329− expect(result).toEqual({
330− status: 200,
331− data: { hello: "Hello Ada" },
332− errors: null,
333− });
334− expect(authorizationHeader).toBe("Bearer secret-token");
335− }),
336− );
337−
338345 it.effect("static graphql.addSource delegates to extension", () =>
339346 Effect.gen(function* () {
340347 const executor = yield* createExecutor(
@@ -506,7 +513,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines
506513 Effect.gen(function* () {
507514 const executor = yield* createExecutor(
508515 makeTestConfig({
509− plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const,
516+ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const,
510517 }),
511518 );
512519
@@ -543,7 +550,7 @@ describe("graphqlPlugin", () => { · 30 unmodified lines
543550 Effect.gen(function* () {
544551 const executor = yield* createExecutor(
545552 makeTestConfig({
546− plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const,
553+ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const,
547554 }),
548555 );
549556
@@ -579,7 +586,7 @@ describe("graphqlPlugin", () => { · 29 unmodified lines
579586 Effect.gen(function* () {
580587 const executor = yield* createExecutor(
581588 makeTestConfig({
582− plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const,
589+ plugins: [memorySecretsPlugin(), graphqlPlugin()] as const,
583590 }),
584591 );
585592
packages/plugins/graphql/src/testing/index.ts+195−0
@@ -0,0 +1,195 @@
1+import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
2+
3+import { Context, Data, Effect, Layer, Ref, Schema as EffectSchema, Scope } from "effect";
4+import { GraphQLNonNull, GraphQLObjectType, GraphQLSchema, GraphQLString } from "graphql";
5+import { createYoga, type GraphQLParams, type YogaInitialContext } from "graphql-yoga";
6+
7+const GraphqlRequestPayload = EffectSchema.Struct({
8+ query: EffectSchema.optional(EffectSchema.String),
9+ variables: EffectSchema.optional(EffectSchema.Record(EffectSchema.String, EffectSchema.Unknown)),
10+ operationName: EffectSchema.optional(EffectSchema.NullOr(EffectSchema.String)),
11+});
12+
13+type GraphqlRequestPayload = typeof GraphqlRequestPayload.Type;
14+
15+export interface GraphqlTestRequest {
16+ readonly url: string;
17+ readonly method: string;
18+ readonly path: string;
19+ readonly headers: Readonly<Record<string, string>>;
20+ readonly payload: GraphqlRequestPayload;
21+}
22+
23+export interface GraphqlTestContext {
24+ readonly request: GraphqlTestRequest;
25+}
26+
27+export interface GraphqlTestServerOptions {
28+ readonly schema: GraphQLSchema;
29+ readonly path?: string;
30+}
31+
32+export interface GraphqlTestServerShape {
33+ readonly endpoint: string;
34+ readonly schema: GraphQLSchema;
35+ readonly requests: Effect.Effect<readonly GraphqlTestRequest[]>;
36+ readonly clearRequests: Effect.Effect<void>;
37+}
38+
39+class GraphqlTestServerAddressError extends Data.TaggedError("GraphqlTestServerAddressError")<{
40+ readonly address: unknown;
41+}> {}
42+
43+class GraphqlTestServerListenError extends Data.TaggedError("GraphqlTestServerListenError")<{
44+ readonly cause: unknown;
45+}> {}
46+
47+const headersFromRequest = (
48+ headers: Headers | IncomingHttpHeaders,
49+): Readonly<Record<string, string>> => {
50+ if ("entries" in headers && typeof headers.entries === "function") {
51+ return Object.fromEntries(headers.entries());
52+ }
53+ return Object.fromEntries(
54+ Object.entries(headers).flatMap(([name, value]) => {
55+ if (value === undefined) return [];
56+ return [[name, Array.isArray(value) ? value.join(", ") : value]];
57+ }),
58+ );
59+};
60+
61+const payloadFromParams = (params: GraphQLParams): GraphqlRequestPayload => ({
62+ query: params.query,
63+ variables:
64+ typeof params.variables === "object" && params.variables !== null
65+ ? params.variables
66+ : undefined,
67+ operationName: params.operationName ?? null,
68+});
69+
70+const captureRequest = (
71+ initial: YogaInitialContext,
72+ requests: Ref.Ref<readonly GraphqlTestRequest[]>,
73+) => {
74+ const url = new URL(initial.request.url);
75+ const captured: GraphqlTestRequest = {
76+ url: initial.request.url,
77+ method: initial.request.method,
78+ path: url.pathname,
79+ headers: headersFromRequest(initial.request.headers),
80+ payload: payloadFromParams(initial.params),
81+ };
82+ return Effect.runPromise(
83+ Ref.update(requests, (all) => [...all, captured]).pipe(Effect.as(captured)),
84+ );
85+};
86+
87+const closeServer = (server: Server): Effect.Effect<void> =>
88+ Effect.callback<void>((resume) => {
89+ server.close(() => resume(Effect.void));
90+ });
91+
92+export const serveGraphqlTestServer = (
93+ options: GraphqlTestServerOptions,
94+): Effect.Effect<
95+ GraphqlTestServerShape,
96+ GraphqlTestServerAddressError | GraphqlTestServerListenError,
97+ Scope.Scope
98+> =>
99+ Effect.gen(function* () {
100+ const requests = yield* Ref.make<readonly GraphqlTestRequest[]>([]);
101+ const path = options.path ?? "/graphql";
102+
103+ const yoga = createYoga<Record<string, never>, GraphqlTestContext>({
104+ schema: options.schema,
105+ graphqlEndpoint: path,
106+ graphiql: false,
107+ landingPage: false,
108+ logging: false,
109+ maskedErrors: false,
110+ context: (initial) =>
111+ captureRequest(initial, requests).then((request) => ({
112+ request,
113+ })),
114+ });
115+ const server = createServer(yoga);
116+
117+ const port = yield* Effect.acquireRelease(
118+ Effect.callback<number, GraphqlTestServerAddressError | GraphqlTestServerListenError>(
119+ (resume) => {
120+ const onError = (cause: unknown) =>
121+ resume(Effect.fail(new GraphqlTestServerListenError({ cause })));
122+ server.once("error", onError);
123+ server.listen(0, "127.0.0.1", () => {
124+ server.off("error", onError);
125+ const address = server.address();
126+ if (!address || typeof address === "string") {
127+ resume(Effect.fail(new GraphqlTestServerAddressError({ address })));
128+ return;
129+ }
130+ resume(Effect.succeed(address.port));
131+ });
132+ },
133+ ),
134+ () => closeServer(server),
135+ );
136+
137+ return {
138+ endpoint: `http://127.0.0.1:${port}${path}`,
139+ schema: options.schema,
140+ requests: Ref.get(requests),
141+ clearRequests: Ref.set(requests, []),
142+ };
143+ });
144+
145+export class GraphqlTestServer extends Context.Service<GraphqlTestServer, GraphqlTestServerShape>()(
146+ "@executor-js/plugin-graphql/testing/GraphqlTestServer",
147+) {
148+ static readonly layer = (options: GraphqlTestServerOptions) =>
149+ Layer.effect(GraphqlTestServer, serveGraphqlTestServer(options));
150+}
151+
152+const stringArgument = (
153+ args: Readonly<Record<string, unknown>>,
154+ key: string,
155+ fallback: string,
156+): string => {
157+ const value = args[key];
158+ return typeof value === "string" ? value : fallback;
159+};
160+
161+export const makeGreetingGraphqlSchema = (): GraphQLSchema => {
162+ const Query = new GraphQLObjectType<unknown, GraphqlTestContext>({
163+ name: "Query",
164+ fields: {
165+ hello: {
166+ type: GraphQLString,
167+ description: "Say hello",
168+ args: {
169+ name: { type: GraphQLString },
170+ },
171+ resolve: (_source, args) => `Hello ${stringArgument(args, "name", "world")}`,
172+ },
173+ },
174+ });
175+
176+ const Mutation = new GraphQLObjectType<unknown, GraphqlTestContext>({
177+ name: "Mutation",
178+ fields: {
179+ setGreeting: {
180+ type: GraphQLString,
181+ description: "Set greeting message",
182+ args: {
183+ message: { type: new GraphQLNonNull(GraphQLString) },
184+ },
185+ resolve: (_source, args) => stringArgument(args, "message", ""),
186+ },
187+ },
188+ });
189+
190+ return new GraphQLSchema({ query: Query, mutation: Mutation });
191+};
192+
193+export const TestLayers = {
194+ greeting: () => GraphqlTestServer.layer({ schema: makeGreetingGraphqlSchema() }),
195+};
packages/plugins/graphql/tsup.config.ts+2−1
@@ -4,10 +4,11 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ testing: "src/testing/index.ts",
78 },
89 format: ["esm"],
910 dts: false,
1011 sourcemap: true,
1112 clean: true,
12− external: [/^@executor-js\//, /^effect/, /^@effect\//],
13+ external: [/^@executor-js\//, /^effect/, /^@effect\//, /^graphql/, /^graphql-yoga/],
1314 });
packages/plugins/graphql/vitest.config.ts+5−0
@@ -3,5 +3,10 @@ import { defineConfig } from "vitest/config"; · 2 unmodified lines
33 export default defineConfig({
44 test: {
55 include: ["src/**/*.test.ts"],
6+ server: {
7+ deps: {
8+ inline: ["graphql", "graphql-yoga", "@graphql-tools", "@envelop", "@whatwg-node"],
9+ },
10+ },
611 },
712 });
packages/plugins/mcp/package.json+8−1
@@ -21,7 +21,8 @@ · 20 unmodified lines
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 @@ · 9 unmodified lines
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 },
packages/plugins/mcp/src/sdk/elicitation.test.ts+1−1
@@ -12,7 +12,7 @@ import { · 11 unmodified lines
1212 } from "@executor-js/sdk";
1313
1414 import { mcpPlugin } from "./plugin";
15−import { serveMcpServer } from "./test-utils";
15+import { serveMcpServer } from "../testing";
1616
1717 // ---------------------------------------------------------------------------
1818 // Test MCP server on a real HTTP port
packages/plugins/mcp/src/sdk/plugin.test.ts+1−1
@@ -18,7 +18,7 @@ import { · 17 unmodified lines
1818
1919 import { mcpPlugin } from "./plugin";
2020 import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest";
21−import { serveMcpServer } from "./test-utils";
21+import { serveMcpServer } from "../testing";
2222
2323 // ---------------------------------------------------------------------------
2424 // Memory secrets plugin — without a writable provider in the stack,
packages/plugins/mcp/src/testing/index.ts+1−0
@@ -0,0 +1 @@
1+export { serveMcpServer, type McpTestServer } from "./server";
packages/plugins/mcp/src/testing/server.tsrenamed from packages/plugins/mcp/src/sdk/test-utils.ts+0−6
@@ -10,12 +10,6 @@ export type McpTestServer = { · 9 unmodified lines
1010 readonly sessionCount: () => number;
1111 };
1212
13−/**
14− * Spin up a real HTTP server backed by a fresh `McpServer` per session,
15− * with proper session routing via `mcp-session-id`. The factory is invoked
16− * once per new session, so tools registered inside it are isolated per
17− * connection (matching the SDK's own server/transport coupling).
18− */
1913 export const serveMcpServer = (factory: () => McpServer) =>
2014 Effect.acquireRelease(
2115 Effect.callback<McpTestServer, Error>((resume) => {
packages/plugins/mcp/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ testing: "src/testing/index.ts",
78 },
89 format: ["esm"],
910 dts: false,
packages/plugins/openapi/package.json+8−1
@@ -21,7 +21,8 @@ · 20 unmodified lines
2121 "./api": "./src/api/index.ts",
2222 "./react": "./src/react/index.ts",
2323 "./client": "./src/react/plugin-client.tsx",
24− "./presets": "./src/sdk/presets.ts"
24+ "./presets": "./src/sdk/presets.ts",
25+ "./testing": "./src/testing/index.ts"
2526 },
2627 "publishConfig": {
2728 "access": "public",
@@ -37,6 +38,12 @@ · 9 unmodified lines
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 },
packages/plugins/openapi/src/sdk/plugin.test.ts+5−38
@@ -13,20 +13,20 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; · 12 unmodified lines
1313 import {
1414 createExecutor,
1515 type DBAdapter,
16− definePlugin,
1716 makeTestConfig,
1817 Scope,
1918 ScopeId,
2019 SecretId,
2120 SetSecretInput,
2221 type InvokeOptions,
23− type SecretProvider,
2422 type Where,
2523 } from "@executor-js/sdk";
24+import { memorySecretsPlugin } from "@executor-js/sdk/testing";
2625
2726 const TEST_SCOPE = "test-scope";
2827 import { openApiPlugin } from "./plugin";
2928 import { OAuth2Auth, OpenApiSourceBindingInput } from "./types";
29+import { makeOpenApiTestServer } from "../testing";
3030
3131 const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
3232
@@ -53,38 +53,6 @@ const recordFindMany = (adapter: DBAdapter, calls: FindManyCall[]): DBAdapter => · 20 unmodified lines
5353 ),
5454 });
5555
56−// ---------------------------------------------------------------------------
57−// In-memory secrets provider plugin — registered alongside openapi so
58−// executor.secrets.set/get work in tests.
59−// ---------------------------------------------------------------------------
60−
61−const memoryProvider: SecretProvider = (() => {
62− const store = new Map<string, string>();
63− return {
64− key: "memory",
65− writable: true,
66− get: (id, scope) => Effect.sync(() => store.get(`${scope}\u0000${id}`) ?? null),
67− set: (id, value, scope) =>
68− Effect.sync(() => {
69− store.set(`${scope}\u0000${id}`, value);
70− }),
71− delete: (id, scope) => Effect.sync(() => store.delete(`${scope}\u0000${id}`)),
72− list: () =>
73− Effect.sync(() =>
74− Array.from(store.keys()).map((k) => {
75− const name = k.split("\u0000", 2)[1] ?? k;
76− return { id: name, name };
77− }),
78− ),
79− };
80−})();
81−
82−const memorySecretsPlugin = definePlugin(() => ({
83− id: "memory-secrets" as const,
84− storage: () => ({}),
85− secretProviders: [memoryProvider],
86−}));
87−
8856 // ---------------------------------------------------------------------------
8957 // Define a test API with Effect HttpApi
9058 // ---------------------------------------------------------------------------
@@ -186,19 +154,18 @@ const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLog · 95 unmodified lines
186154 layer(TestLayer)("OpenAPI Plugin", (it) => {
187155 it.effect("previewSpec returns metadata and header presets", () =>
188156 Effect.gen(function* () {
189− const httpClient = yield* HttpClient.HttpClient;
190− const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient);
157+ const server = yield* makeOpenApiTestServer({ spec });
191158
192159 const executor = yield* createExecutor(
193160 makeTestConfig({
194161 plugins: [
195− openApiPlugin({ httpClientLayer: clientLayer }),
162+ openApiPlugin({ httpClientLayer: server.httpClientLayer }),
196163 memorySecretsPlugin(),
197164 ] as const,
198165 }),
199166 );
200167
201− const preview = yield* executor.openapi.previewSpec(specJson);
168+ const preview = yield* executor.openapi.previewSpec(server.specJson);
202169
203170 expect(preview.operationCount).toBeGreaterThanOrEqual(2);
204171 expect(preview.servers).toBeDefined();
packages/plugins/openapi/src/testing/index.ts+86−0
@@ -0,0 +1,86 @@
1+import { Context, Data, Effect, Layer, Predicate, Schema } from "effect";
2+import { HttpClient, HttpServer } from "effect/unstable/http";
3+
4+export class OpenApiTestServerAddressError extends Data.TaggedError(
5+ "OpenApiTestServerAddressError",
6+)<{
7+ readonly address: unknown;
8+}> {}
9+
10+export class OpenApiTestServerSpecError extends Data.TaggedError("OpenApiTestServerSpecError")<{
11+ readonly cause: unknown;
12+}> {}
13+
14+export interface OpenApiTestServerOptions {
15+ readonly spec: unknown;
16+}
17+
18+export interface OpenApiTestServerShape {
19+ readonly baseUrl: string;
20+ readonly specJson: string;
21+ readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;
22+}
23+
24+const decodeJsonSpec = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
25+
26+const isJsonObject = (value: unknown): value is Readonly<Record<string, unknown>> =>
27+ typeof value === "object" && value !== null && !Array.isArray(value);
28+
29+export const openApiSpecJsonWithServer = (
30+ spec: unknown,
31+ baseUrl: string,
32+): Effect.Effect<string, OpenApiTestServerSpecError> =>
33+ Effect.gen(function* () {
34+ const parsed =
35+ typeof spec === "string"
36+ ? yield* decodeJsonSpec(spec).pipe(
37+ Effect.mapError((cause) => new OpenApiTestServerSpecError({ cause })),
38+ )
39+ : spec;
40+ const withServer = isJsonObject(parsed)
41+ ? {
42+ ...parsed,
43+ servers: [{ url: baseUrl }],
44+ }
45+ : parsed;
46+ return yield* Effect.try({
47+ try: () => JSON.stringify(withServer),
48+ catch: (cause) => new OpenApiTestServerSpecError({ cause }),
49+ });
50+ });
51+
52+export const makeOpenApiTestServer = (
53+ options: OpenApiTestServerOptions,
54+): Effect.Effect<
55+ OpenApiTestServerShape,
56+ OpenApiTestServerAddressError | OpenApiTestServerSpecError,
57+ HttpClient.HttpClient | HttpServer.HttpServer
58+> =>
59+ Effect.gen(function* () {
60+ const server = yield* HttpServer.HttpServer;
61+ const address = server.address;
62+ if (!Predicate.isTagged(address, "TcpAddress")) {
63+ return yield* new OpenApiTestServerAddressError({ address });
64+ }
65+
66+ const client = yield* HttpClient.HttpClient;
67+ const baseUrl = `http://127.0.0.1:${address.port}`;
68+ const specJson = yield* openApiSpecJsonWithServer(options.spec, baseUrl);
69+
70+ return {
71+ baseUrl,
72+ specJson,
73+ httpClientLayer: Layer.succeed(HttpClient.HttpClient, client),
74+ };
75+ });
76+
77+export class OpenApiTestServer extends Context.Service<OpenApiTestServer, OpenApiTestServerShape>()(
78+ "@executor-js/plugin-openapi/testing/OpenApiTestServer",
79+) {
80+ static readonly layer = (options: OpenApiTestServerOptions) =>
81+ Layer.effect(OpenApiTestServer, makeOpenApiTestServer(options));
82+}
83+
84+export const TestLayers = {
85+ server: OpenApiTestServer.layer,
86+};
packages/plugins/openapi/tsup.config.ts+1−0
@@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines
44 entry: {
55 index: "src/promise.ts",
66 core: "src/sdk/index.ts",
7+ testing: "src/testing/index.ts",
78 },
89 format: ["esm"],
910 dts: false,
scripts/oxlint-plugin-executor.js+2−0
@@ -12,6 +12,7 @@ import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check · 11 unmodified lines
1212 import noPromiseCatch from "./oxlint-plugin-executor/rules/no-promise-catch.js";
1313 import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js";
1414 import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js";
15+import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js";
1516 import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js";
1617 import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js";
1718 import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js";
@@ -48,6 +49,7 @@ export default { · 30 unmodified lines
4849 "no-promise-catch": noPromiseCatch,
4950 "no-promise-client-surface": noPromiseClientSurface,
5051 "no-promise-reject": noPromiseReject,
52+ "no-raw-fetch": noRawFetch,
5153 "no-raw-error-throw": noRawErrorThrow,
5254 "no-redundant-primitive-cast": noRedundantPrimitiveCast,
5355 "no-redundant-error-factory": noRedundantErrorFactory,
scripts/oxlint-plugin-executor/rules/no-raw-fetch.js+76−0
@@ -0,0 +1,76 @@
1+import {
2+ getPropertyName,
3+ isDeclarationFile,
4+ isIdentifier,
5+ toRepoRelative,
6+ unwrapExpression,
7+} from "../utils.js";
8+
9+const message =
10+ "Do not call raw fetch in core/plugin code. Route HTTP through Effect HttpClient or an explicit package-local boundary. Skill: wrdn-effect-raw-fetch-boundary.";
11+
12+const checkedPrefixes = [
13+ "packages/core/sdk/src/",
14+ "packages/plugins/graphql/src/",
15+ "packages/plugins/mcp/src/",
16+ "packages/plugins/openapi/src/",
17+];
18+
19+const temporaryAllowedFiles = new Set([
20+ "packages/core/sdk/src/oauth-discovery.ts",
21+ "packages/core/sdk/src/oauth-discovery.test.ts",
22+ "packages/core/sdk/src/oauth-helpers.test.ts",
23+ "packages/core/sdk/src/oauth-service.ts",
24+ "packages/plugins/mcp/src/sdk/probe-shape.ts",
25+ "packages/plugins/openapi/src/sdk/client-credentials-oauth.test.ts",
26+ "packages/plugins/openapi/src/sdk/multi-scope-oauth.test.ts",
27+ "packages/plugins/openapi/src/sdk/oauth-refresh.test.ts",
28+]);
29+
30+const shouldCheck = (filename) => {
31+ const normalized = toRepoRelative(filename);
32+ if (isDeclarationFile(filename)) return false;
33+ if (temporaryAllowedFiles.has(normalized)) return false;
34+ return checkedPrefixes.some((prefix) => normalized.startsWith(prefix));
35+};
36+
37+const isGlobalFetchMember = (node) => {
38+ const expression = unwrapExpression(node);
39+ if (expression?.type !== "MemberExpression") return false;
40+ const object = unwrapExpression(expression.object);
41+ const property = getPropertyName(expression.property);
42+ return (
43+ property === "fetch" &&
44+ (isIdentifier(object, "globalThis") ||
45+ isIdentifier(object, "window") ||
46+ isIdentifier(object, "self"))
47+ );
48+};
49+
50+const isBareFetchCall = (node) => isIdentifier(unwrapExpression(node), "fetch");
51+
52+export default {
53+ meta: {
54+ type: "problem",
55+ docs: {
56+ description: "Disallow raw fetch outside approved Effect HTTP boundaries.",
57+ },
58+ },
59+ create(context) {
60+ if (!shouldCheck(context.filename)) return {};
61+
62+ return {
63+ CallExpression(node) {
64+ if (isBareFetchCall(node.callee) || isGlobalFetchMember(node.callee)) {
65+ context.report({ node: node.callee, message });
66+ }
67+ },
68+ MemberExpression(node) {
69+ if (node.parent?.type === "CallExpression" && node.parent.callee === node) return;
70+ if (isGlobalFetchMember(node)) {
71+ context.report({ node, message });
72+ }
73+ },
74+ };
75+ },
76+};