Add real protocol testing fixtures
Unified · Split
| @@ -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 | +``` | |
| @@ -17,6 +17,7 @@ · 16 unmodified lines | ||
| 17 | 17 | "executor/no-manual-tag-check": "error", |
| 18 | 18 | "executor/no-promise-catch": "error", |
| 19 | 19 | "executor/no-promise-reject": "error", |
| 20 | + "executor/no-raw-fetch": "error", | |
| 20 | 21 | "executor/no-redundant-primitive-cast": "error", |
| 21 | 22 | "executor/no-redundant-error-factory": "error", |
| 22 | 23 | "executor/no-ts-nocheck": "error", |
| @@ -601,6 +601,8 @@ · 600 unmodified lines | ||
| 601 | 601 | "@executor-js/config": "workspace:*", |
| 602 | 602 | "@executor-js/sdk": "workspace:*", |
| 603 | 603 | "effect": "catalog:", |
| 604 | + "graphql": "^16.12.0", | |
| 605 | + "graphql-yoga": "^5.17.0", | |
| 604 | 606 | }, |
| 605 | 607 | "devDependencies": { |
| 606 | 608 | "@effect/atom-react": "catalog:", |
| @@ -1157,6 +1159,12 @@ · 550 unmodified lines | ||
| 1157 | 1159 | |
| 1158 | 1160 | "@emotion/weak-memoize": ["@emotion/weak-memoize@0.4.0", "", {}, "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg=="], |
| 1159 | 1161 | |
| 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 | + | |
| 1160 | 1168 | "@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=="], |
| 1161 | 1169 | |
| 1162 | 1170 | "@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 | ||
| 1277 | 1285 | |
| 1278 | 1286 | "@executor-js/vite-plugin": ["@executor-js/vite-plugin@workspace:packages/core/vite-plugin"], |
| 1279 | 1287 | |
| 1288 | + "@fastify/busboy": ["@fastify/busboy@3.2.0", "", {}, "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA=="], | |
| 1289 | + | |
| 1280 | 1290 | "@floating-ui/core": ["@floating-ui/core@1.7.5", "", { "dependencies": { "@floating-ui/utils": "^0.2.11" } }, "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ=="], |
| 1281 | 1291 | |
| 1282 | 1292 | "@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 | ||
| 1289 | 1299 | |
| 1290 | 1300 | "@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=="], |
| 1291 | 1301 | |
| 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 | + | |
| 1292 | 1318 | "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], |
| 1293 | 1319 | |
| 1294 | 1320 | "@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 | ||
| 1939 | 1965 | |
| 1940 | 1966 | "@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=="], |
| 1941 | 1967 | |
| 1968 | + "@repeaterjs/repeater": ["@repeaterjs/repeater@3.0.6", "", {}, "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA=="], | |
| 1969 | + | |
| 1942 | 1970 | "@rhyssul/portless": ["@rhyssul/portless@0.13.3", "", { "os": [ "linux", "win32", "darwin", ], "bin": { "portless": "dist/cli.js" } }, "sha512-oPvwXJIIkRg2i1CUEUsz8sAdFSf0Fzzv+NmaacAsi6ZZTHxDOofbO6fGInVnbESIFOu4k8a/rXOZRF0aqLAUgw=="], |
| 1943 | 1971 | |
| 1944 | 1972 | "@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 | ||
| 2337 | 2365 | |
| 2338 | 2366 | "@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=="], |
| 2339 | 2367 | |
| 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 | + | |
| 2340 | 2380 | "@workos-inc/node": ["@workos-inc/node@8.13.0", "", { "dependencies": { "eventemitter3": "^5.0.4" } }, "sha512-NgQKHpwh8AbT4KvAsW91Y+4f4jja2IvFPQ5atcy5NUxUMVRgXzRFEee3erawfXrTmiCVqJjd9PljHySKBXmHKQ=="], |
| 2341 | 2381 | |
| 2342 | 2382 | "@xmldom/xmldom": ["@xmldom/xmldom@0.8.12", "", {}, "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg=="], |
| @@ -2637,6 +2677,8 @@ · 294 unmodified lines | ||
| 2637 | 2677 | |
| 2638 | 2678 | "cross-dirname": ["cross-dirname@0.1.0", "", {}, "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q=="], |
| 2639 | 2679 | |
| 2680 | + "cross-inspect": ["cross-inspect@1.0.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A=="], | |
| 2681 | + | |
| 2640 | 2682 | "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], |
| 2641 | 2683 | |
| 2642 | 2684 | "crossws": ["crossws@0.3.5", "", { "dependencies": { "uncrypto": "^0.1.3" } }, "sha512-ojKiDvcmByhwa8YYqbQI/hg7MEU0NC03+pSdEq4ZUnZR9xXpwk7E43SMNGkn+JxJGPFtNvQ48+vV2p+P1ml5PA=="], |
| @@ -3083,6 +3125,10 @@ · 440 unmodified lines | ||
| 3083 | 3125 | |
| 3084 | 3126 | "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], |
| 3085 | 3127 | |
| 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 | + | |
| 3086 | 3132 | "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=="], |
| 3087 | 3133 | |
| 3088 | 3134 | "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 | ||
| 3431 | 3477 | |
| 3432 | 3478 | "lowercase-keys": ["lowercase-keys@2.0.0", "", {}, "sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA=="], |
| 3433 | 3479 | |
| 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=="], | |
| 3435 | 3481 | |
| 3436 | 3482 | "lru_map": ["lru_map@0.4.1", "", {}, "sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg=="], |
| 3437 | 3483 | |
| @@ -4417,6 +4463,8 @@ · 979 unmodified lines | ||
| 4417 | 4463 | |
| 4418 | 4464 | "url-join": ["url-join@5.0.0", "", {}, "sha512-n2huDr9h9yzd6exQVnH/jU5mr+Pfx08LRXXZhkLLetAMESRj+anQsTAh940iMrIetKAmry9coFuZQ2jY8/p3WA=="], |
| 4419 | 4465 | |
| 4466 | + "urlpattern-polyfill": ["urlpattern-polyfill@10.1.0", "", {}, "sha512-IGjKp/o0NL3Bso1PymYURCJxMPNAf/ILOpendP9f5B6e1rTJgdgiOvgfoT8VxCAdY+Wisb9uhGaJJf3yZ2V9nw=="], | |
| 4467 | + | |
| 4420 | 4468 | "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=="], |
| 4421 | 4469 | |
| 4422 | 4470 | "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 | ||
| 4645 | 4693 | |
| 4646 | 4694 | "@executor-js/storage-postgres/@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="], |
| 4647 | 4695 | |
| 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 | + | |
| 4648 | 4702 | "@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=="], |
| 4649 | 4703 | |
| 4650 | 4704 | "@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 | ||
| 4679 | 4733 | |
| 4680 | 4734 | "@modelcontextprotocol/sdk/jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], |
| 4681 | 4735 | |
| 4682 | − "@npmcli/agent/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], | |
| 4683 | − | |
| 4684 | 4736 | "@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=="], |
| 4685 | 4737 | |
| 4686 | 4738 | "@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 | ||
| 4885 | 4937 | |
| 4886 | 4938 | "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=="], |
| 4887 | 4939 | |
| 4888 | − "cacache/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], | |
| 4889 | − | |
| 4890 | 4940 | "cacache/p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], |
| 4891 | 4941 | |
| 4892 | 4942 | "cheerio/undici": ["undici@7.25.0", "", {}, "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ=="], |
| @@ -5027,8 +5077,6 @@ · 134 unmodified lines | ||
| 5027 | 5077 | |
| 5028 | 5078 | "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], |
| 5029 | 5079 | |
| 5030 | − "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], | |
| 5031 | − | |
| 5032 | 5080 | "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], |
| 5033 | 5081 | |
| 5034 | 5082 | "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 | ||
| 5101 | 5149 | |
| 5102 | 5150 | "unstorage/chokidar": ["chokidar@5.0.0", "", { "dependencies": { "readdirp": "^5.0.0" } }, "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw=="], |
| 5103 | 5151 | |
| 5152 | + "unstorage/lru-cache": ["lru-cache@11.3.5", "", {}, "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw=="], | |
| 5153 | + | |
| 5104 | 5154 | "whatwg-encoding/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="], |
| 5105 | 5155 | |
| 5106 | 5156 | "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=="], |
| @@ -46,7 +46,7 @@ Testing services belong behind explicit `./testing` subpath exports: · 45 unmodified lines | ||
| 46 | 46 | |
| 47 | 47 | The plugin packages own the protocol details. Core SDK testing owns only boring |
| 48 | 48 | 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. | |
| 50 | 50 | |
| 51 | 51 | Use `TestLayer` / `TestLayers` naming for realistic local test services. |
| 52 | 52 | Reserve `LiveLayer` / `LiveLayers` for production wiring. These servers are |
| @@ -124,7 +124,6 @@ Suggested file layout: · 71 unmodified lines | ||
| 124 | 124 | ```txt |
| 125 | 125 | packages/core/sdk/src/testing.ts |
| 126 | 126 | packages/core/sdk/src/testing/ |
| 127 | − fetch.ts | |
| 128 | 127 | memory-secrets.ts |
| 129 | 128 | |
| 130 | 129 | packages/plugins/openapi/src/testing/ |
| @@ -191,9 +190,6 @@ Useful additions: · 60 unmodified lines | ||
| 191 | 190 | in-test secret provider definitions. |
| 192 | 191 | - `makeExecutorTestLayer(options)`, if repeated `createExecutor(makeTestConfig)` |
| 193 | 192 | 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. | |
| 197 | 193 | - Small request/response capture primitives built on `Ref`, only if multiple |
| 198 | 194 | plugins need the same shape. |
| 199 | 195 | |
| @@ -243,8 +239,8 @@ Work: · 43 unmodified lines | ||
| 243 | 239 | - Support a fresh `McpServer` factory per session, matching the current helper. |
| 244 | 240 | - Keep malformed/non-MCP HTTP endpoints as explicit test layers for probe |
| 245 | 241 | 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. | |
| 248 | 244 | |
| 249 | 245 | Representative scenarios: |
| 250 | 246 | |
| @@ -261,10 +257,11 @@ Representative scenarios: · 10 unmodified lines | ||
| 261 | 257 | GraphQL should gain a real executable schema server. This is the place where a |
| 262 | 258 | new dependency may be justified. |
| 263 | 259 | |
| 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. | |
| 268 | 265 | |
| 269 | 266 | Work: |
| 270 | 267 | |
| @@ -292,19 +289,19 @@ Representative scenarios: · 21 unmodified lines | ||
| 292 | 289 | ## Fetch Boundary |
| 293 | 290 | |
| 294 | 291 | 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. | |
| 298 | 295 | |
| 299 | 296 | Boundary files may exist, but they should be explicit and scarce. Examples: |
| 300 | 297 | |
| 301 | −- a SDK adapter for `oauth4webapi` custom fetch | |
| 298 | +- a core OAuth adapter for `oauth4webapi` custom fetch, if that library forces it | |
| 302 | 299 | - a MCP transport adapter if the upstream MCP SDK requires fetch-shaped input |
| 303 | 300 | - platform entrypoints that must implement a `fetch(request, env, ctx)` method |
| 304 | 301 | - test harnesses that intentionally call a Worker binding's `fetch` method |
| 305 | 302 | |
| 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. | |
| 308 | 305 | |
| 309 | 306 | Lint plan: |
| 310 | 307 | |
| @@ -319,17 +316,17 @@ Lint plan: · 8 unmodified lines | ||
| 319 | 316 | - Start with a narrow allowlist for known boundary files and remove entries as |
| 320 | 317 | migrations land. |
| 321 | 318 | - Include error messages that tell the author which Effect HTTP or approved |
| 322 | − adapter to use. | |
| 319 | + package-local boundary to use. | |
| 323 | 320 | |
| 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. | |
| 326 | 324 | |
| 327 | 325 | ## Migration Order |
| 328 | 326 | |
| 329 | 327 | 1. Add `./testing` exports and SDK test primitives. |
| 330 | 328 | - Expose `@executor-js/sdk/testing`. |
| 331 | 329 | - Add memory secrets test support. |
| 332 | − - Add the Effect HTTP to fetch-shaped adapter. | |
| 333 | 330 | - Keep `makeTestConfig` available from the root SDK. |
| 334 | 331 | |
| 335 | 332 | 2. Build the GraphQL vertical slice. |
| @@ -344,8 +341,8 @@ has to invent its own local escape hatch. · 8 unmodified lines | ||
| 344 | 341 | - Migrate current tests to import from the new public testing subpaths. |
| 345 | 342 | |
| 346 | 343 | 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. | |
| 349 | 346 | - Convert OpenAPI OAuth tests to the OAuth test server. |
| 350 | 347 | - Convert MCP probe tests to real local HTTP server layers. |
| 351 | 348 | - Convert Google Discovery tests using the same boundary pattern. |
| @@ -403,8 +400,8 @@ Turbo. Do not use `bun test`. · 51 unmodified lines | ||
| 403 | 400 | |
| 404 | 401 | - Should `@executor-js/sdk/testing` be a new subpath around the existing |
| 405 | 402 | `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? | |
| 408 | 405 | - Which app-level raw fetch calls should remain approved platform boundaries |
| 409 | 406 | versus being migrated to Effect HTTP? |
| 410 | 407 | - Should the raw fetch lint rule become repo-wide immediately with allowlists, |
| @@ -19,7 +19,8 @@ · 18 unmodified lines | ||
| 19 | 19 | ".": "./src/index.ts", |
| 20 | 20 | "./core": "./src/index.ts", |
| 21 | 21 | "./promise": "./src/promise.ts", |
| 22 | − "./client": "./src/client.ts" | |
| 22 | + "./client": "./src/client.ts", | |
| 23 | + "./testing": "./src/testing.ts" | |
| 23 | 24 | }, |
| 24 | 25 | "publishConfig": { |
| 25 | 26 | "access": "public", |
| @@ -41,6 +42,12 @@ · 15 unmodified lines | ||
| 41 | 42 | "types": "./dist/client.d.ts", |
| 42 | 43 | "default": "./dist/client.js" |
| 43 | 44 | } |
| 45 | + }, | |
| 46 | + "./testing": { | |
| 47 | + "import": { | |
| 48 | + "types": "./dist/testing.d.ts", | |
| 49 | + "default": "./dist/testing.js" | |
| 50 | + } | |
| 44 | 51 | } |
| 45 | 52 | } |
| 46 | 53 | }, |
| @@ -1,11 +1,14 @@ | ||
| 1 | 1 | import { makeMemoryAdapter } from "@executor-js/storage-core/testing/memory"; |
| 2 | 2 | |
| 3 | +import { Effect } from "effect"; | |
| 4 | + | |
| 3 | 5 | import { makeInMemoryBlobStore } from "./blob"; |
| 4 | 6 | import type { ExecutorConfig } from "./executor"; |
| 5 | 7 | import { collectSchemas } from "./executor"; |
| 6 | 8 | import { ScopeId } from "./ids"; |
| 7 | −import type { AnyPlugin } from "./plugin"; | |
| 9 | +import { definePlugin, type AnyPlugin } from "./plugin"; | |
| 8 | 10 | import { Scope } from "./scope"; |
| 11 | +import type { SecretProvider } from "./secrets"; | |
| 9 | 12 | |
| 10 | 13 | // --------------------------------------------------------------------------- |
| 11 | 14 | // makeTestConfig — build an ExecutorConfig backed by in-memory adapter + |
| @@ -42,3 +45,31 @@ export const makeTestConfig = <const TPlugins extends readonly AnyPlugin[] = []> · 30 unmodified lines | ||
| 42 | 45 | onElicitation: "accept-all", |
| 43 | 46 | }; |
| 44 | 47 | }; |
| 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 | +}); | |
| @@ -5,6 +5,7 @@ export default defineConfig({ · 4 unmodified lines | ||
| 5 | 5 | index: "src/promise.ts", |
| 6 | 6 | core: "src/index.ts", |
| 7 | 7 | client: "src/client.ts", |
| 8 | + testing: "src/testing.ts", | |
| 8 | 9 | }, |
| 9 | 10 | format: ["esm"], |
| 10 | 11 | dts: false, |
| @@ -21,7 +21,8 @@ · 20 unmodified lines | ||
| 21 | 21 | "./api": "./src/api/index.ts", |
| 22 | 22 | "./react": "./src/react/index.ts", |
| 23 | 23 | "./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" | |
| 25 | 26 | }, |
| 26 | 27 | "publishConfig": { |
| 27 | 28 | "access": "public", |
| @@ -37,6 +38,12 @@ · 9 unmodified lines | ||
| 37 | 38 | "types": "./dist/sdk/index.d.ts", |
| 38 | 39 | "default": "./dist/core.js" |
| 39 | 40 | } |
| 41 | + }, | |
| 42 | + "./testing": { | |
| 43 | + "import": { | |
| 44 | + "types": "./dist/testing/index.d.ts", | |
| 45 | + "default": "./dist/testing.js" | |
| 46 | + } | |
| 40 | 47 | } |
| 41 | 48 | } |
| 42 | 49 | }, |
| @@ -51,7 +58,9 @@ · 8 unmodified lines | ||
| 51 | 58 | "@effect/platform-node": "catalog:", |
| 52 | 59 | "@executor-js/config": "workspace:*", |
| 53 | 60 | "@executor-js/sdk": "workspace:*", |
| 54 | − "effect": "catalog:" | |
| 61 | + "effect": "catalog:", | |
| 62 | + "graphql": "^16.12.0", | |
| 63 | + "graphql-yoga": "^5.17.0" | |
| 55 | 64 | }, |
| 56 | 65 | "devDependencies": { |
| 57 | 66 | "@effect/atom-react": "catalog:", |
| @@ -220,8 +220,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* ( · 219 unmodified lines | ||
| 220 | 220 | ); |
| 221 | 221 | |
| 222 | 222 | if (response.status !== 200) { |
| 223 | + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed("<unreadable>"))); | |
| 223 | 224 | 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)}`, | |
| 225 | 226 | }); |
| 226 | 227 | } |
| 227 | 228 | |
| @@ -1,26 +1,24 @@ | ||
| 1 | 1 | import { describe, it, expect } from "@effect/vitest"; |
| 2 | −import { Data, Effect } from "effect"; | |
| 2 | +import { Effect } from "effect"; | |
| 3 | 3 | |
| 4 | 4 | import { |
| 5 | 5 | ConnectionId, |
| 6 | 6 | CreateConnectionInput, |
| 7 | 7 | createExecutor, |
| 8 | − definePlugin, | |
| 9 | 8 | makeTestConfig, |
| 10 | 9 | Scope, |
| 11 | 10 | ScopeId, |
| 12 | 11 | SecretId, |
| 13 | − type SecretProvider, | |
| 14 | 12 | TokenMaterial, |
| 15 | 13 | } from "@executor-js/sdk"; |
| 14 | +import { memorySecretsPlugin } from "@executor-js/sdk/testing"; | |
| 16 | 15 | |
| 17 | 16 | import { graphqlPlugin } from "./plugin"; |
| 18 | 17 | import type { IntrospectionResult } from "./introspect"; |
| 18 | +import { makeGreetingGraphqlSchema, serveGraphqlTestServer } from "../testing"; | |
| 19 | 19 | |
| 20 | 20 | const TEST_SCOPE = "test-scope"; |
| 21 | 21 | |
| 22 | −class TestServerAddressError extends Data.TaggedError("TestServerAddressError")<{}> {} | |
| 23 | − | |
| 24 | 22 | // --------------------------------------------------------------------------- |
| 25 | 23 | // Mock introspection response |
| 26 | 24 | // --------------------------------------------------------------------------- |
| @@ -92,31 +90,119 @@ const introspectionResult: IntrospectionResult = { · 65 unmodified lines | ||
| 92 | 90 | |
| 93 | 91 | const introspectionJson = JSON.stringify({ data: introspectionResult }); |
| 94 | 92 | |
| 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, | |
| 111 | 161 | }), |
| 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 | +}); | |
| 120 | 206 | |
| 121 | 207 | describe("graphqlPlugin", () => { |
| 122 | 208 | it.effect("registers tools from introspection JSON", () => |
| @@ -256,85 +342,6 @@ describe("graphqlPlugin", () => { · 133 unmodified lines | ||
| 256 | 342 | }), |
| 257 | 343 | ); |
| 258 | 344 | |
| 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 | − | |
| 338 | 345 | it.effect("static graphql.addSource delegates to extension", () => |
| 339 | 346 | Effect.gen(function* () { |
| 340 | 347 | const executor = yield* createExecutor( |
| @@ -506,7 +513,7 @@ describe("graphqlPlugin", () => { · 165 unmodified lines | ||
| 506 | 513 | Effect.gen(function* () { |
| 507 | 514 | const executor = yield* createExecutor( |
| 508 | 515 | makeTestConfig({ |
| 509 | − plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const, | |
| 516 | + plugins: [memorySecretsPlugin(), graphqlPlugin()] as const, | |
| 510 | 517 | }), |
| 511 | 518 | ); |
| 512 | 519 | |
| @@ -543,7 +550,7 @@ describe("graphqlPlugin", () => { · 30 unmodified lines | ||
| 543 | 550 | Effect.gen(function* () { |
| 544 | 551 | const executor = yield* createExecutor( |
| 545 | 552 | makeTestConfig({ |
| 546 | − plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const, | |
| 553 | + plugins: [memorySecretsPlugin(), graphqlPlugin()] as const, | |
| 547 | 554 | }), |
| 548 | 555 | ); |
| 549 | 556 | |
| @@ -579,7 +586,7 @@ describe("graphqlPlugin", () => { · 29 unmodified lines | ||
| 579 | 586 | Effect.gen(function* () { |
| 580 | 587 | const executor = yield* createExecutor( |
| 581 | 588 | makeTestConfig({ |
| 582 | − plugins: [makeMemorySecretsPlugin()(), graphqlPlugin()] as const, | |
| 589 | + plugins: [memorySecretsPlugin(), graphqlPlugin()] as const, | |
| 583 | 590 | }), |
| 584 | 591 | ); |
| 585 | 592 | |
| @@ -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 | +}; | |
| @@ -4,10 +4,11 @@ export default defineConfig({ · 3 unmodified lines | ||
| 4 | 4 | entry: { |
| 5 | 5 | index: "src/promise.ts", |
| 6 | 6 | core: "src/sdk/index.ts", |
| 7 | + testing: "src/testing/index.ts", | |
| 7 | 8 | }, |
| 8 | 9 | format: ["esm"], |
| 9 | 10 | dts: false, |
| 10 | 11 | sourcemap: true, |
| 11 | 12 | clean: true, |
| 12 | − external: [/^@executor-js\//, /^effect/, /^@effect\//], | |
| 13 | + external: [/^@executor-js\//, /^effect/, /^@effect\//, /^graphql/, /^graphql-yoga/], | |
| 13 | 14 | }); |
| @@ -3,5 +3,10 @@ import { defineConfig } from "vitest/config"; · 2 unmodified lines | ||
| 3 | 3 | export default defineConfig({ |
| 4 | 4 | test: { |
| 5 | 5 | include: ["src/**/*.test.ts"], |
| 6 | + server: { | |
| 7 | + deps: { | |
| 8 | + inline: ["graphql", "graphql-yoga", "@graphql-tools", "@envelop", "@whatwg-node"], | |
| 9 | + }, | |
| 10 | + }, | |
| 6 | 11 | }, |
| 7 | 12 | }); |
| @@ -21,7 +21,8 @@ · 20 unmodified lines | ||
| 21 | 21 | "./api": "./src/api/index.ts", |
| 22 | 22 | "./react": "./src/react/index.ts", |
| 23 | 23 | "./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" | |
| 25 | 26 | }, |
| 26 | 27 | "publishConfig": { |
| 27 | 28 | "access": "public", |
| @@ -37,6 +38,12 @@ · 9 unmodified lines | ||
| 37 | 38 | "types": "./dist/sdk/index.d.ts", |
| 38 | 39 | "default": "./dist/core.js" |
| 39 | 40 | } |
| 41 | + }, | |
| 42 | + "./testing": { | |
| 43 | + "import": { | |
| 44 | + "types": "./dist/testing/index.d.ts", | |
| 45 | + "default": "./dist/testing.js" | |
| 46 | + } | |
| 40 | 47 | } |
| 41 | 48 | } |
| 42 | 49 | }, |
| @@ -12,7 +12,7 @@ import { · 11 unmodified lines | ||
| 12 | 12 | } from "@executor-js/sdk"; |
| 13 | 13 | |
| 14 | 14 | import { mcpPlugin } from "./plugin"; |
| 15 | −import { serveMcpServer } from "./test-utils"; | |
| 15 | +import { serveMcpServer } from "../testing"; | |
| 16 | 16 | |
| 17 | 17 | // --------------------------------------------------------------------------- |
| 18 | 18 | // Test MCP server on a real HTTP port |
| @@ -18,7 +18,7 @@ import { · 17 unmodified lines | ||
| 18 | 18 | |
| 19 | 19 | import { mcpPlugin } from "./plugin"; |
| 20 | 20 | import { extractManifestFromListToolsResult, deriveMcpNamespace, joinToolPath } from "./manifest"; |
| 21 | −import { serveMcpServer } from "./test-utils"; | |
| 21 | +import { serveMcpServer } from "../testing"; | |
| 22 | 22 | |
| 23 | 23 | // --------------------------------------------------------------------------- |
| 24 | 24 | // Memory secrets plugin — without a writable provider in the stack, |
| @@ -0,0 +1 @@ | ||
| 1 | +export { serveMcpServer, type McpTestServer } from "./server"; | |
| @@ -10,12 +10,6 @@ export type McpTestServer = { · 9 unmodified lines | ||
| 10 | 10 | readonly sessionCount: () => number; |
| 11 | 11 | }; |
| 12 | 12 | |
| 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 | − */ | |
| 19 | 13 | export const serveMcpServer = (factory: () => McpServer) => |
| 20 | 14 | Effect.acquireRelease( |
| 21 | 15 | Effect.callback<McpTestServer, Error>((resume) => { |
| @@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines | ||
| 4 | 4 | entry: { |
| 5 | 5 | index: "src/promise.ts", |
| 6 | 6 | core: "src/sdk/index.ts", |
| 7 | + testing: "src/testing/index.ts", | |
| 7 | 8 | }, |
| 8 | 9 | format: ["esm"], |
| 9 | 10 | dts: false, |
| @@ -21,7 +21,8 @@ · 20 unmodified lines | ||
| 21 | 21 | "./api": "./src/api/index.ts", |
| 22 | 22 | "./react": "./src/react/index.ts", |
| 23 | 23 | "./client": "./src/react/plugin-client.tsx", |
| 24 | − "./presets": "./src/sdk/presets.ts" | |
| 24 | + "./presets": "./src/sdk/presets.ts", | |
| 25 | + "./testing": "./src/testing/index.ts" | |
| 25 | 26 | }, |
| 26 | 27 | "publishConfig": { |
| 27 | 28 | "access": "public", |
| @@ -37,6 +38,12 @@ · 9 unmodified lines | ||
| 37 | 38 | "types": "./dist/sdk/index.d.ts", |
| 38 | 39 | "default": "./dist/core.js" |
| 39 | 40 | } |
| 41 | + }, | |
| 42 | + "./testing": { | |
| 43 | + "import": { | |
| 44 | + "types": "./dist/testing/index.d.ts", | |
| 45 | + "default": "./dist/testing.js" | |
| 46 | + } | |
| 40 | 47 | } |
| 41 | 48 | } |
| 42 | 49 | }, |
| @@ -13,20 +13,20 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; · 12 unmodified lines | ||
| 13 | 13 | import { |
| 14 | 14 | createExecutor, |
| 15 | 15 | type DBAdapter, |
| 16 | − definePlugin, | |
| 17 | 16 | makeTestConfig, |
| 18 | 17 | Scope, |
| 19 | 18 | ScopeId, |
| 20 | 19 | SecretId, |
| 21 | 20 | SetSecretInput, |
| 22 | 21 | type InvokeOptions, |
| 23 | − type SecretProvider, | |
| 24 | 22 | type Where, |
| 25 | 23 | } from "@executor-js/sdk"; |
| 24 | +import { memorySecretsPlugin } from "@executor-js/sdk/testing"; | |
| 26 | 25 | |
| 27 | 26 | const TEST_SCOPE = "test-scope"; |
| 28 | 27 | import { openApiPlugin } from "./plugin"; |
| 29 | 28 | import { OAuth2Auth, OpenApiSourceBindingInput } from "./types"; |
| 29 | +import { makeOpenApiTestServer } from "../testing"; | |
| 30 | 30 | |
| 31 | 31 | const autoApprove: InvokeOptions = { onElicitation: "accept-all" }; |
| 32 | 32 | |
| @@ -53,38 +53,6 @@ const recordFindMany = (adapter: DBAdapter, calls: FindManyCall[]): DBAdapter => · 20 unmodified lines | ||
| 53 | 53 | ), |
| 54 | 54 | }); |
| 55 | 55 | |
| 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 | − | |
| 88 | 56 | // --------------------------------------------------------------------------- |
| 89 | 57 | // Define a test API with Effect HttpApi |
| 90 | 58 | // --------------------------------------------------------------------------- |
| @@ -186,19 +154,18 @@ const TestLayer = HttpRouter.serve(ApiLive, { disableListenLog: true, disableLog · 95 unmodified lines | ||
| 186 | 154 | layer(TestLayer)("OpenAPI Plugin", (it) => { |
| 187 | 155 | it.effect("previewSpec returns metadata and header presets", () => |
| 188 | 156 | Effect.gen(function* () { |
| 189 | − const httpClient = yield* HttpClient.HttpClient; | |
| 190 | − const clientLayer = Layer.succeed(HttpClient.HttpClient, httpClient); | |
| 157 | + const server = yield* makeOpenApiTestServer({ spec }); | |
| 191 | 158 | |
| 192 | 159 | const executor = yield* createExecutor( |
| 193 | 160 | makeTestConfig({ |
| 194 | 161 | plugins: [ |
| 195 | − openApiPlugin({ httpClientLayer: clientLayer }), | |
| 162 | + openApiPlugin({ httpClientLayer: server.httpClientLayer }), | |
| 196 | 163 | memorySecretsPlugin(), |
| 197 | 164 | ] as const, |
| 198 | 165 | }), |
| 199 | 166 | ); |
| 200 | 167 | |
| 201 | − const preview = yield* executor.openapi.previewSpec(specJson); | |
| 168 | + const preview = yield* executor.openapi.previewSpec(server.specJson); | |
| 202 | 169 | |
| 203 | 170 | expect(preview.operationCount).toBeGreaterThanOrEqual(2); |
| 204 | 171 | expect(preview.servers).toBeDefined(); |
| @@ -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 | +}; | |
| @@ -4,6 +4,7 @@ export default defineConfig({ · 3 unmodified lines | ||
| 4 | 4 | entry: { |
| 5 | 5 | index: "src/promise.ts", |
| 6 | 6 | core: "src/sdk/index.ts", |
| 7 | + testing: "src/testing/index.ts", | |
| 7 | 8 | }, |
| 8 | 9 | format: ["esm"], |
| 9 | 10 | dts: false, |
| @@ -12,6 +12,7 @@ import noManualTagCheck from "./oxlint-plugin-executor/rules/no-manual-tag-check · 11 unmodified lines | ||
| 12 | 12 | import noPromiseCatch from "./oxlint-plugin-executor/rules/no-promise-catch.js"; |
| 13 | 13 | import noPromiseClientSurface from "./oxlint-plugin-executor/rules/no-promise-client-surface.js"; |
| 14 | 14 | import noPromiseReject from "./oxlint-plugin-executor/rules/no-promise-reject.js"; |
| 15 | +import noRawFetch from "./oxlint-plugin-executor/rules/no-raw-fetch.js"; | |
| 15 | 16 | import noRawErrorThrow from "./oxlint-plugin-executor/rules/no-raw-error-throw.js"; |
| 16 | 17 | import noRedundantPrimitiveCast from "./oxlint-plugin-executor/rules/no-redundant-primitive-cast.js"; |
| 17 | 18 | import noRedundantErrorFactory from "./oxlint-plugin-executor/rules/no-redundant-error-factory.js"; |
| @@ -48,6 +49,7 @@ export default { · 30 unmodified lines | ||
| 48 | 49 | "no-promise-catch": noPromiseCatch, |
| 49 | 50 | "no-promise-client-surface": noPromiseClientSurface, |
| 50 | 51 | "no-promise-reject": noPromiseReject, |
| 52 | + "no-raw-fetch": noRawFetch, | |
| 51 | 53 | "no-raw-error-throw": noRawErrorThrow, |
| 52 | 54 | "no-redundant-primitive-cast": noRedundantPrimitiveCast, |
| 53 | 55 | "no-redundant-error-factory": noRedundantErrorFactory, |
| @@ -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 | +}; | |