@aloma.io/integration-sdk 3.8.69 → 3.8.70

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,253 @@
1
+ Issue: PLAT-47
2
+
3
+ # PLAT-47: the SDK Fetcher sends `fetchOptions.params` as the query string
4
+
5
+ Run 20261026-104553, on `origin/main` 70a9898 (`@aloma.io/integration-sdk` 3.8.69). The package version is unchanged.
6
+
7
+ ## Update, run 20261026-111752: the fix is committed (e8f0fbb)
8
+
9
+ After `integration-sdk/node_modules` was reinstalled from the lockfile (lockfile unchanged), `yarn build` exits 0 and
10
+ `yarn test` passes in full: 5 mocha files and 8 standalone scripts, 0 failed, with fetcher-query-params at 19/19. The
11
+ stashed work was the same, byte for byte, as `.aeh/runs/20261026-110540/unfinished.patch`. Test, code and docs were
12
+ committed together as e8f0fbb, "PLAT-47: the Fetcher sends fetchOptions.params as the query string". The gate passed
13
+ every check on tree 370dfe838730: secret-files, secrets, static, dependencies, tdd, model and probe. The sections
14
+ below are the history of the earlier refusals. Their "not committed" status no longer holds.
15
+
16
+ ## Status (runs 20261026-104553 and 20261026-110540, superseded): the fix is written and verified in the box but NOT committed (gate blocker)
17
+
18
+ The box's commit gate refused the code commit:
19
+
20
+ tdd fail no test command is configured for this project in the harness (config/tdd.json),
21
+ so a code change cannot be accepted
22
+
23
+ The gate's test command (`AEH_TDD`) is set from the Mac only; the box cannot change it.
24
+
25
+ - **Committed on this branch:** this FINDINGS file only. The gate's test-first check needs no test command for a
26
+ docs-only commit.
27
+ - **Not committed:** `src/internal/fetcher/fetcher.mts`, `test/fetcher-query-params.test.mjs`,
28
+ `tools/mutation-run.mjs` and the `OPENAPI_TO_CONNECTOR.md` edit. They are left in the box's working tree, and saved
29
+ as one patch at `.aeh/runs/20261026-104553/PLAT-47-fix.patch`.
30
+ - **Why the test was not committed on its own:** the gate requires the new test in the same commit as the code
31
+ change, so that it can see red then green.
32
+
33
+ **To land it:**
34
+
35
+ 1. Configure the gate's test command for this project. For example, `cd integration-sdk && yarn build && node
36
+ test/fetcher-query-params.test.mjs`, or the full `yarn test`. The tester also needs `integration-sdk/node_modules`
37
+ linked (`link` in the tdd settings), because the tester runs with no network.
38
+ 2. Then apply the patch (`git apply .aeh/runs/20261026-104553/PLAT-47-fix.patch`), review it and commit it as
39
+ "PLAT-47: Fetcher sends fetchOptions.params as the query string".
40
+
41
+ Everything below describes that patch.
42
+
43
+ ## Run 20261026-110540: second attempt, refused again by the gate (tdd, green run fails)
44
+
45
+ With the gate's test command now configured, this run tried to land the same patch (stash `plat47-fetcher-fix`, checked
46
+ byte-identical to `.aeh/runs/20261026-104553/PLAT-47-fix.patch`, which is also saved as
47
+ `.aeh/runs/20261026-110540/unfinished.patch`). It is still **not committed**. It stays in the box's working tree.
48
+
49
+ What happened, in order:
50
+
51
+ 1. **Test alone, then code (the task's plan).** The test file was committed alone as `401b1cf` (review warn: no
52
+ probe for the red step). The code and docs commit was then refused:
53
+
54
+ tdd fail non-test code changed (integration-sdk/src/internal/fetcher/fetcher.mts,
55
+ integration-sdk/tools/mutation-run.mjs) with no new or changed test: write a test that fails on the
56
+ current code first
57
+
58
+ 2. **Following the gate: test and code in one commit.** `401b1cf` was reverted by an ordinary commit, with no history
59
+ rewrite because it may already be published. Then test, code and docs were staged together and reviewed. That
60
+ commit was refused, and this is the gate's exact reason (review of tree `ea44c6244395`):
61
+
62
+ tdd fail the tests fail after the change (exit 2)
63
+ --- green run:
64
+ warning You don't appear to have an internet connection. Try the --offline flag to use the cache for registry queries.
65
+ error Command failed with exit code 2.
66
+
67
+ Every other check on that tree passed: secret-files, secrets, static, dependencies, the gate's own model review
68
+ ("No findings"), and the probe. The probe ran in the gate's tester and gave 19/19 for
69
+ `test/fetcher-query-params.test.mjs` on the fixed tree.
70
+
71
+ **Likely cause (environment, not the change):** the box cannot confirm this, because it cannot see the gate's test
72
+ command or its tester. In the box, `yarn build` (tsc) exits 2 on missing packages only:
73
+
74
+ src/openapi-to-connector.mts: Cannot find module 'zod'
75
+ src/testing/fake-aloma.mts: Cannot find module 'ws'
76
+ src/transform/index.mts: Cannot find module 'comment-parser'
77
+
78
+ `node_modules` here is incomplete, and `yarn install --frozen-lockfile` fails (registry proxy 403; `yarn.lock`
79
+ unchanged). `yarn test` then fails 8 of 13 files, every one with `ERR_MODULE_NOT_FOUND` (ws, zod, brace-expansion).
80
+ **The same 8 fail on HEAD without the patch**, and the new fetcher test passes 19/19. If the gate's command is
81
+ `yarn build && yarn test` and its linked `node_modules` is the same incomplete set, it fails before and after any
82
+ change, so no code commit can pass the tdd check.
83
+
84
+ **To land it (Mac side):** give the gate's tester a complete `node_modules` for `integration-sdk` (zod 4.6.6, ws,
85
+ comment-parser, brace-expansion, at the locked versions), or set its test command to one that runs offline. Then commit
86
+ test + `fetcher.mts` + `mutation-run.mjs` + `OPENAPI_TO_CONNECTOR.md` in **one** commit: the gate needs the new test
87
+ in the same commit as the code change.
88
+
89
+ ## The change
90
+
91
+ `src/internal/fetcher/fetcher.mts`:
92
+
93
+ - `toQuery(params)` turns `params` into a query string with `URLSearchParams`:
94
+ - `undefined` and `null` values (and array items) are skipped;
95
+ - numbers and booleans are stringified (`String(value)`), strings are sent as they are;
96
+ - arrays become repeated keys (`properties=a&properties=b`);
97
+ - a nested object (or an object inside an array) is refused with
98
+ `params.<key> is an object; only strings, numbers, booleans and arrays of them are sent`;
99
+ - a `params` that is not an object is refused (`params must be an object`);
100
+ - a `URLSearchParams` instance is sent as it is. Without this, `Object.entries` of it is empty and it would be
101
+ silently dropped, the same kind of bug as PLAT-47.
102
+ - `withQuery(url, query)` appends the query: `?` if the url has none, `&` if it already has one (nothing if it ends in
103
+ `?` or `&`), before any `#fragment`. An empty query returns the url unchanged, so no params, `{}` or all-undefined
104
+ params give exactly today's URL, byte for byte.
105
+ - In `fetch()`, the full URL is built after `customize` and after the `//` collapse, into a local `fullURL` only.
106
+ `url` and `options0` are untouched, and `options0` keeps `params`. The caller's `params` object is never mutated.
107
+ Retries go through `onError` → `fetch` → `customize` with the same `url` and `options0`, so a retry rebuilds the
108
+ same URL with the query exactly once.
109
+ - Only the object handed to native `fetch` drops `params` (`const {params, ...init} = options0`). Headers, body,
110
+ method, timeout, `customize`, `onResponse`, `unwrap` and the retry policy are unchanged.
111
+ - A refused `params` throws a private `ParamsError`, which the `catch` rethrows at once. Without that the error would
112
+ be retried (500 ms each) as if it were a network error.
113
+ - **Logging:** the failed-attempt log `console.log(theURL, e)` now logs `theURL.split('?')[0]`, the URL without
114
+ its query string. Query values may hold ids or emails. The params query is never in `theURL` (it lives only in
115
+ `fullURL`), and a query already written into `url` is now stripped from the log too.
116
+ - **Typing:** `params?: {[key: string]: any} | URLSearchParams` is added, with JSDoc, to the typed `fetch` options, so
117
+ hand-written connectors using `getClient()` see it. `build/internal/fetcher/fetcher.d.mts` declares it.
118
+ - **OAuth:** `OAuthFetcher` does not override `fetch()` and builds no URL of its own, so it inherits the behaviour.
119
+ One test proves it, with the Bearer header still set. The OAuth token calls (`make-oauth.mts`, `finish-oauth.mts`)
120
+ are separate requests and are not touched.
121
+
122
+ `OPENAPI_TO_CONNECTOR.md` (Parameter Mapping) now says the Fetcher sends `params` and how values are serialised.
123
+
124
+ `tools/mutation-run.mjs` has Fetcher mutants F01 to F12. They run `test/fetcher-query-params.test.mjs` instead of
125
+ mocha, and the runner checks that file's baseline too. `test/MUTATION.md` was **not** regenerated, because the runner
126
+ cannot run in this box (see below).
127
+
128
+ ## Array form chosen, and why
129
+
130
+ **Repeated keys** (`properties=a&properties=b`).
131
+
132
+ - connector-hubspot-private-v2 and the HubSpot OpenAPI spec are not in this box (no network, and no HubSpot spec under
133
+ `examples/`), so the connector's generated code could not be read here.
134
+ - The generator (`openapi-to-connector.mts`) copies raw values into `fetchOptions.params` and ignores `style` and
135
+ `explode`. A generator that ignores them is implicitly assuming the OpenAPI default for query parameters, which is
136
+ `style: form, explode: true`, i.e. repeated keys.
137
+ - The task notes that HubSpot accepts repeated keys for `properties` on CRM v3 GET endpoints, and that form is the
138
+ stated fallback.
139
+ - **Doubt:** not checked against HubSpot's live documentation from the box. Mutant F03 (comma-joined) shows the tests
140
+ pin the choice, so switching later is a one-line change plus test updates.
141
+
142
+ **Nested objects are refused, not JSON-encoded.** The generator emits no serialisation for objects and has no
143
+ `deepObject` or JSON intent. Sending `[object Object]` silently is the worst outcome, and guessing a JSON or
144
+ `a[b]=c` encoding would silently be wrong for some vendors. A clear error names the key instead.
145
+
146
+ ## Tests (`test/fetcher-query-params.test.mjs`, standalone script)
147
+
148
+ The test uses a stubbed `globalThis.fetch`, plus one real local HTTP server on 127.0.0.1. Nothing leaves the test
149
+ process. "Today" means the unchanged 70a9898 source, built and run with the same test file.
150
+
151
+ | # | Test | Rule | Today | After |
152
+ |---|------|------|-------|-------|
153
+ | 1 | numbers, strings and booleans are stringified into the query string | 1 | FAIL (query missing) | pass |
154
+ | 2 | query parameters already in the url are kept | 1 | FAIL (`bar=2` missing) | pass |
155
+ | 3 | undefined and null values are skipped (`0` and `''` kept) | 1 | FAIL (query missing) | pass |
156
+ | 4 | arrays become repeated keys | 1 | FAIL (query missing) | pass |
157
+ | 5 | undefined and null array items are skipped, other items stringified | 1 | FAIL (query missing) | pass |
158
+ | 6 | nested objects are refused with a clear error, nothing is sent, not retried | 1 | FAIL (no error) | pass |
159
+ | 7 | objects inside arrays are refused too | 1 | FAIL (no error) | pass |
160
+ | 8 | values are encoded by URLSearchParams and a `//` inside a value is not collapsed | 1 | FAIL (query missing) | pass |
161
+ | 9 | a URLSearchParams instance is sent as it is | 1 | FAIL (query missing) | pass |
162
+ | 10 | without baseUrl the query is appended to the url as given | 1 | FAIL (query missing) | pass |
163
+ | 11 | params is not passed on to global fetch; headers, body and method unchanged | 2 | FAIL (`params` reaches fetch) | pass |
164
+ | 12 | the caller's options and params objects are not mutated | 2 | pass (guard) | pass |
165
+ | 13 | customize runs as before and can change url and params | 2 | FAIL (query missing) | pass |
166
+ | 14 | a retried request sends the same url with the query string exactly once | 2 | FAIL (query missing) | pass |
167
+ | 15 | a failed attempt logs the url without its query string (the query is still sent) | 2 | FAIL (query not sent) | pass |
168
+ | 16 | no params, `{}`, all-undefined or `undefined` params give exactly the old url | 3 | pass (guard) | pass |
169
+ | 17 | the OAuth fetcher sends the query string and its Bearer token | 2 | FAIL (query missing) | pass |
170
+ | 18 | a local HTTP server receives the query string through the real global fetch | 1 | FAIL (request line has no query) | pass |
171
+ | 19 | params is part of the typed fetch options (`fetcher.d.mts`) | typing | FAIL (not declared) | pass |
172
+
173
+ Today: 17 fail, 2 pass. After: 19/19 pass.
174
+
175
+ Tests 12 and 16 pass on today's code by design. Rule 3 says today's URL must not change, and today's code never
176
+ mutates the caller, so these two guard against regressions. Mutants F04, F05 and F10 show they bite.
177
+
178
+ A typing check, done by hand outside the repo, compiled a hand-written call with `params: {limit, properties: [...],
179
+ archived}` and with a `URLSearchParams`, and a `// @ts-expect-error` confirmed that `params: 'a=1'` is a type error.
180
+
181
+ ## Mutation results
182
+
183
+ Run by hand with a driver that reads the F rows from `tools/mutation-run.mjs`. For each mutant it applies the change
184
+ to `src/`, rebuilds with tsc, runs the test file and restores the source.
185
+
186
+ | # | Mutant | Result | Killed by (failing tests) |
187
+ |---|--------|--------|---------------------------|
188
+ | F01 | params handling removed (`fullURL = theURL`) | killed | 15 tests (every query test) |
189
+ | F02 | array handling dropped | killed | arrays, array items, retry, OAuth, local server |
190
+ | F03 | arrays comma-joined instead of repeated keys | killed | arrays, array items, objects in arrays, retry, OAuth, local server |
191
+ | F04 | undefined not skipped (`=== null`) | killed | undefined/null skipped, array items, old url |
192
+ | F05 | null not skipped (`=== undefined`) | killed | undefined/null skipped, array items, old url |
193
+ | F06 | nested objects sent instead of refused | killed | nested objects refused, objects in arrays |
194
+ | F07 | refused params retried | killed | nested objects refused (timing) |
195
+ | F08 | always `?` even when the url has a query | killed | existing query kept, log test |
196
+ | F09 | `params` passed on to native fetch | killed | params not passed on, retry, OAuth |
197
+ | F10 | `delete options0.params` (the map's original approach) | killed | retry keeps the query |
198
+ | F11 | log the full URL with its query | killed | log test |
199
+ | F12 | URLSearchParams instance dropped | killed | URLSearchParams test |
200
+
201
+ **12/12 killed.** That covers the three mutants the task asks for: params handling removed (F01), array handling
202
+ dropped (F02), undefined not skipped (F04).
203
+
204
+ ## Build and full suite: what could and could not be run in this box
205
+
206
+ **Doubt, for Patrick to re-run on the Mac:** this box could not install the repo's lockfile.
207
+
208
+ - `yarn install --frozen-lockfile` fails: the egress proxy answers 403 for registry.yarnpkg.com.
209
+ - The offline yarn cache lacks exact locked versions of `zod@4.6.6`, `ws@8.21.2`, `qs@6.15.2`, `body-parser@1.20.9`,
210
+ `brace-expansion@2.1.7`, `serialize-javascript@7.0.5`, `chai@4.5.0`, `comment-parser@1.4.1` and a few
211
+ ts-node/mocha-type dependencies.
212
+ - Installing other versions would change the dependencies, which needs Patrick's agreement, so it was not done.
213
+ - What was done: the exact locked versions that *are* in the local cache (typescript 5.2.2, @types/node 18.19.39,
214
+ prettier, mocha's own package and others) were copied into the git-ignored `node_modules/`. Nothing was downloaded,
215
+ `yarn.lock` and `package.json` are unchanged (lockfile sha256 checked before and after).
216
+
217
+ Results with that partial install:
218
+
219
+ - **`yarn build` (tsc):** the same three errors before and after the change, all `TS2307 Cannot find module` for the
220
+ uninstalled `zod`, `ws` and `comment-parser`. No new error. tsc still emits `build/`.
221
+ - **`node tools/run-tests.mjs`:** 8 of 13 entries fail both before and after the change, every one with
222
+ `Cannot find module/package` (`brace-expansion` for mocha; `ws` or `zod` for the standalone scripts). The new
223
+ `test/fetcher-query-params.test.mjs` is the only added entry, and it passes.
224
+ - **Not run here:** the mocha suite (including `harness.test.mjs` "records the url the Fetcher really sends", which
225
+ pins the path-only URL and should be unaffected by rule 3), the generator and scenario tests, and
226
+ `yarn test:mutation` (M01 to M32 plus the new F rows).
227
+
228
+ Please run `yarn install --frozen-lockfile && yarn build && yarn test && yarn test:mutation` on the Mac, and commit
229
+ the regenerated `test/MUTATION.md`.
230
+
231
+ ## Follow-ups (not changed in this run)
232
+
233
+ - **Generator, dotted query names:** a name such as `a.b` becomes a nested option object in the method signature,
234
+ but the body emits `fetchOptions.params.a.b = options.a.b`. That throws at runtime when `fetchOptions.params.a` is
235
+ undefined (`openapi-to-connector.mts`, query params loop). It should emit `fetchOptions.params['a.b'] = ...`.
236
+ - **Generator, serialisation:** it ignores `style` and `explode` and emits raw values for null, arrays and objects. The
237
+ Fetcher now applies one fixed rule (form, explode true; objects refused). Specs that use `explode: false` (comma
238
+ form), `deepObject` or `pipeDelimited` would need the generator to pre-serialise, or to pass a per-parameter style.
239
+ - **Generator, headers and cookies:** `in: header` and `in: cookie` parameters are dropped by the generator (from the
240
+ map), separately from this issue.
241
+ - **Retry re-runs `customize`:** a non-idempotent `customize` (for example one that appends to `url`) is still applied
242
+ twice on retry. That is unchanged and out of scope; the params query is safe because it never enters `url`.
243
+ - **VendorMock string matchers** compare the full URL including the query. Connector tests that now send a query
244
+ must use a regexp or function matcher, or the exact query string.
245
+
246
+ ## Rollout risks to check before release
247
+
248
+ - A connector whose `customize` already works around PLAT-47 by appending `params` to `options.url` itself (the
249
+ workaround noted in the map) would now send every parameter twice. Patrick checked 29 active connectors and only
250
+ connector-hubspot-private-v2 uses `params`. A grep of its `customize` for `params` or `URLSearchParams` before
251
+ release settles it.
252
+ - A call that passes an object value in `params` used to have it silently dropped; it now fails with a clear error and
253
+ sends nothing. This is intended (rule 1), but it is a visible behaviour change.
@@ -253,7 +253,10 @@ The script generates method names using the following priority:
253
253
  ## Parameter Mapping
254
254
 
255
255
  - **Path parameters**: Mapped to `args.paramName`
256
- - **Query parameters**: Mapped to `args.paramName`
256
+ - **Query parameters**: Mapped to `args.paramName`, copied into `fetchOptions.params`; the SDK Fetcher sends them as
257
+ the query string. `undefined` and `null` values are skipped, numbers and booleans are stringified,
258
+ arrays become repeated keys (`properties=a&properties=b`, the OpenAPI default `style: form, explode: true`), and
259
+ nested objects are refused with an error. Values are encoded by `URLSearchParams`; a query already in the url is kept.
257
260
  - **Request body**: Mapped to `args.body`
258
261
  - **Headers**: Mapped to `args.headerName`
259
262
 
@@ -1,4 +1,5 @@
1
1
  /// <reference types="node" resolution-mode="require"/>
2
+ /// <reference types="node" resolution-mode="require"/>
2
3
  /**
3
4
  * http request fetcher
4
5
  */
@@ -41,5 +42,12 @@ export default class Fetcher {
41
42
  * request body like "hello world" or {hello: "world"}
42
43
  */
43
44
  body?: any;
45
+ /**
46
+ * query parameters like {limit: 10, properties: ["a", "b"]}, appended to the url;
47
+ * undefined and null are skipped, arrays become repeated keys, objects are refused
48
+ */
49
+ params?: {
50
+ [key: string]: any;
51
+ } | URLSearchParams;
44
52
  }, retries?: number, args?: any): Promise<any>;
45
53
  }
@@ -1,4 +1,42 @@
1
1
  import { unwrap } from '../util/index.mjs';
2
+ class ParamsError extends Error {
3
+ }
4
+ /**
5
+ * build a query string from request params: undefined and null are skipped, arrays become repeated keys,
6
+ * objects are refused; values are encoded by URLSearchParams
7
+ */
8
+ const toQuery = (params) => {
9
+ if (params == null)
10
+ return '';
11
+ if (params instanceof URLSearchParams)
12
+ return params.toString();
13
+ if (typeof params !== 'object' || Array.isArray(params))
14
+ throw new ParamsError('params must be an object');
15
+ const query = new URLSearchParams();
16
+ const add = (key, value) => {
17
+ if (value == null)
18
+ return;
19
+ if (typeof value === 'object') {
20
+ throw new ParamsError(`params.${key} is an object; only strings, numbers, booleans and arrays of them are sent`);
21
+ }
22
+ query.append(key, String(value));
23
+ };
24
+ for (const [key, value] of Object.entries(params)) {
25
+ if (Array.isArray(value))
26
+ value.forEach((item) => add(key, item));
27
+ else
28
+ add(key, value);
29
+ }
30
+ return query.toString();
31
+ };
32
+ const withQuery = (url, query) => {
33
+ if (!query)
34
+ return url;
35
+ const hash = url.indexOf('#');
36
+ const base = hash < 0 ? url : url.slice(0, hash);
37
+ const sep = !base.includes('?') ? '?' : /[?&]$/.test(base) ? '' : '&';
38
+ return base + sep + query + (hash < 0 ? '' : url.slice(hash));
39
+ };
2
40
  /**
3
41
  * http request fetcher
4
42
  */
@@ -55,6 +93,8 @@ export default class Fetcher {
55
93
  url = options0.url;
56
94
  delete options0.url;
57
95
  theURL = !baseUrl ? url : `${baseUrl?.endsWith('/') ? baseUrl : baseUrl + '/'}${url}`.replace(/\/\/+/gi, '/');
96
+ // after the slash collapse, so encoded values are not altered; options0 keeps params for retries
97
+ const fullURL = withQuery(theURL, toQuery(options0.params));
58
98
  if (!options0?.headers || !options0?.headers?.Accept) {
59
99
  options0.headers = {
60
100
  ...options0.headers,
@@ -74,8 +114,9 @@ export default class Fetcher {
74
114
  options0.body = JSON.stringify(options0.body);
75
115
  }
76
116
  const timeout = Math.min(options0?.timeout || 30 * 60 * 1000, 30 * 60 * 1000);
77
- const ret = await fetch(theURL, {
78
- ...options0,
117
+ const { params, ...init } = options0;
118
+ const ret = await fetch(fullURL, {
119
+ ...init,
79
120
  signal: AbortSignal.timeout(timeout),
80
121
  });
81
122
  const status = await ret.status;
@@ -94,6 +135,8 @@ export default class Fetcher {
94
135
  return unwrap(ret, options0);
95
136
  }
96
137
  catch (e) {
138
+ if (e instanceof ParamsError)
139
+ throw e;
97
140
  // too many requests
98
141
  if (e.status === 429) {
99
142
  return local.onError(e, url, options0, retries, args, true);
@@ -103,7 +146,8 @@ export default class Fetcher {
103
146
  throw e;
104
147
  }
105
148
  --retries;
106
- console.log(theURL, e);
149
+ // without the query string: its values may hold ids or emails
150
+ console.log(theURL.split('?')[0], e);
107
151
  if (retries <= 0)
108
152
  throw e;
109
153
  return local.onError(e, url, options0, retries, args);
@@ -97,10 +97,13 @@ export class OpenAPIToConnector {
97
97
  const validationResult = OpenAPISchema.safeParse(parsed);
98
98
  if (!validationResult.success) {
99
99
  // Check if the errors are just about missing 'type' fields in schemas
100
- const criticalErrors = validationResult.error.errors.filter((err) => {
100
+ // zod 4 reports .issues (zod 3's .errors is gone), and a missing field is an invalid_type issue whose message no
101
+ // longer reads 'Required'
102
+ const isMissing = (err) => err.message.includes('Required') || (err.code === 'invalid_type' && /received undefined/.test(err.message));
103
+ const criticalErrors = validationResult.error.issues.filter((err) => {
101
104
  const path = err.path.join('.');
102
105
  // Allow missing 'type' in schema definitions as many OpenAPI specs don't include it
103
- return !path.includes('components.schemas') || !err.message.includes('Required');
106
+ return !path.includes('components.schemas') || !isMissing(err);
104
107
  });
105
108
  if (criticalErrors.length > 0) {
106
109
  const errors = criticalErrors.map((err) => `${err.path.join('.')}: ${err.message}`).join(', ');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aloma.io/integration-sdk",
3
- "version": "3.8.69",
3
+ "version": "3.8.70",
4
4
  "description": "",
5
5
  "author": "aloma.io",
6
6
  "license": "Apache-2.0",
@@ -42,7 +42,7 @@
42
42
  "prom-client": "^15",
43
43
  "typescript": "^5",
44
44
  "ws": "^8",
45
- "zod": "^3"
45
+ "zod": "^4"
46
46
  },
47
47
  "optionalDependencies": {
48
48
  "bufferutil": "^4",
@@ -1,5 +1,43 @@
1
1
  import {unwrap} from '../util/index.mjs';
2
2
 
3
+ class ParamsError extends Error {}
4
+
5
+ /**
6
+ * build a query string from request params: undefined and null are skipped, arrays become repeated keys,
7
+ * objects are refused; values are encoded by URLSearchParams
8
+ */
9
+ const toQuery = (params: any): string => {
10
+ if (params == null) return '';
11
+ if (params instanceof URLSearchParams) return params.toString();
12
+ if (typeof params !== 'object' || Array.isArray(params)) throw new ParamsError('params must be an object');
13
+
14
+ const query = new URLSearchParams();
15
+ const add = (key: string, value: any) => {
16
+ if (value == null) return;
17
+ if (typeof value === 'object') {
18
+ throw new ParamsError(`params.${key} is an object; only strings, numbers, booleans and arrays of them are sent`);
19
+ }
20
+ query.append(key, String(value));
21
+ };
22
+
23
+ for (const [key, value] of Object.entries(params)) {
24
+ if (Array.isArray(value)) value.forEach((item) => add(key, item));
25
+ else add(key, value);
26
+ }
27
+
28
+ return query.toString();
29
+ };
30
+
31
+ const withQuery = (url: string, query: string): string => {
32
+ if (!query) return url;
33
+
34
+ const hash = url.indexOf('#');
35
+ const base = hash < 0 ? url : url.slice(0, hash);
36
+ const sep = !base.includes('?') ? '?' : /[?&]$/.test(base) ? '' : '&';
37
+
38
+ return base + sep + query + (hash < 0 ? '' : url.slice(hash));
39
+ };
40
+
3
41
  /**
4
42
  * http request fetcher
5
43
  */
@@ -73,6 +111,11 @@ export default class Fetcher {
73
111
  * request body like "hello world" or {hello: "world"}
74
112
  */
75
113
  body?: any;
114
+ /**
115
+ * query parameters like {limit: 10, properties: ["a", "b"]}, appended to the url;
116
+ * undefined and null are skipped, arrays become repeated keys, objects are refused
117
+ */
118
+ params?: {[key: string]: any} | URLSearchParams;
76
119
  },
77
120
  retries?: number,
78
121
  args: any = {}
@@ -95,6 +138,8 @@ export default class Fetcher {
95
138
  delete options0.url;
96
139
 
97
140
  theURL = !baseUrl ? url : `${baseUrl?.endsWith('/') ? baseUrl : baseUrl + '/'}${url}`.replace(/\/\/+/gi, '/');
141
+ // after the slash collapse, so encoded values are not altered; options0 keeps params for retries
142
+ const fullURL = withQuery(theURL, toQuery(options0.params));
98
143
 
99
144
  if (!options0?.headers || !options0?.headers?.Accept) {
100
145
  options0.headers = {
@@ -120,8 +165,9 @@ export default class Fetcher {
120
165
  }
121
166
 
122
167
  const timeout = Math.min(options0?.timeout || 30 * 60 * 1000, 30 * 60 * 1000);
123
- const ret = await fetch(theURL, {
124
- ...options0,
168
+ const {params, ...init} = options0;
169
+ const ret = await fetch(fullURL, {
170
+ ...init,
125
171
  signal: AbortSignal.timeout(timeout),
126
172
  });
127
173
  const status = await ret.status;
@@ -144,6 +190,8 @@ export default class Fetcher {
144
190
 
145
191
  return unwrap(ret, options0);
146
192
  } catch (e: any) {
193
+ if (e instanceof ParamsError) throw e;
194
+
147
195
  // too many requests
148
196
  if (e.status === 429) {
149
197
  return local.onError(e, url, options0, retries, args, true);
@@ -156,7 +204,8 @@ export default class Fetcher {
156
204
 
157
205
  --retries;
158
206
 
159
- console.log(theURL, e);
207
+ // without the query string: its values may hold ids or emails
208
+ console.log(theURL.split('?')[0], e);
160
209
 
161
210
  if (retries <= 0) throw e;
162
211
 
@@ -126,10 +126,14 @@ export class OpenAPIToConnector {
126
126
  const validationResult = OpenAPISchema.safeParse(parsed);
127
127
  if (!validationResult.success) {
128
128
  // Check if the errors are just about missing 'type' fields in schemas
129
- const criticalErrors = validationResult.error.errors.filter((err) => {
129
+ // zod 4 reports .issues (zod 3's .errors is gone), and a missing field is an invalid_type issue whose message no
130
+ // longer reads 'Required'
131
+ const isMissing = (err: any) =>
132
+ err.message.includes('Required') || (err.code === 'invalid_type' && /received undefined/.test(err.message));
133
+ const criticalErrors = validationResult.error.issues.filter((err) => {
130
134
  const path = err.path.join('.');
131
135
  // Allow missing 'type' in schema definitions as many OpenAPI specs don't include it
132
- return !path.includes('components.schemas') || !err.message.includes('Required');
136
+ return !path.includes('components.schemas') || !isMissing(err);
133
137
  });
134
138
 
135
139
  if (criticalErrors.length > 0) {
@@ -0,0 +1,294 @@
1
+ // PLAT-47: the Fetcher sends fetchOptions.params as the query string.
2
+ // Standalone script (no mocha). Global fetch is stubbed in this process, except one test that uses a local
3
+ // HTTP server on 127.0.0.1. No request leaves the test process.
4
+ import http from 'node:http';
5
+ import fs from 'node:fs';
6
+ import Fetcher from '../build/internal/fetcher/fetcher.mjs';
7
+ import {OAuth} from '../build/internal/fetcher/oauth-fetcher.mjs';
8
+
9
+ const colors = {green: '\x1b[32m', red: '\x1b[31m', yellow: '\x1b[33m', reset: '\x1b[0m', cyan: '\x1b[36m'};
10
+
11
+ let passed = 0;
12
+ let failed = 0;
13
+
14
+ async function test(name, fn) {
15
+ try {
16
+ console.log(`${colors.cyan}Running: ${name}${colors.reset}`);
17
+ await fn();
18
+ console.log(`${colors.green}✓ PASS: ${name}${colors.reset}\n`);
19
+ passed++;
20
+ } catch (error) {
21
+ console.log(`${colors.red}✗ FAIL: ${name}${colors.reset}`);
22
+ console.log(`${colors.red} Error: ${error.message}${colors.reset}\n`);
23
+ failed++;
24
+ }
25
+ }
26
+
27
+ function assert(condition, message) {
28
+ if (!condition) throw new Error(message || 'Assertion failed');
29
+ }
30
+
31
+ function assertEqual(actual, expected, message) {
32
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
33
+ throw new Error(`${message || 'not equal'}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
34
+ }
35
+ }
36
+
37
+ const BASE = 'https://api.vendor.example.test';
38
+ // Fetcher collapses every run of slashes in baseUrl + url, so the scheme loses one slash (today's behaviour).
39
+ const RAW = 'https:/api.vendor.example.test';
40
+
41
+ // Replace global fetch with a recorder. `replies` is a list of statuses; the last one repeats.
42
+ function stubFetch(replies = [200]) {
43
+ const calls = [];
44
+ const realFetch = globalThis.fetch;
45
+ globalThis.fetch = async (url, init) => {
46
+ calls.push({url, init});
47
+ const status = replies[Math.min(calls.length - 1, replies.length - 1)];
48
+ return new Response(status === 204 ? null : JSON.stringify({ok: status < 400}), {
49
+ status,
50
+ headers: {'content-type': 'application/json'},
51
+ });
52
+ };
53
+ return {calls, restore: () => (globalThis.fetch = realFetch)};
54
+ }
55
+
56
+ async function send(path, options, fetcherOptions = {}, replies) {
57
+ const stub = stubFetch(replies);
58
+ try {
59
+ const fetcher = new Fetcher({baseUrl: BASE, retry: 1, ...fetcherOptions});
60
+ let error;
61
+ try {
62
+ await fetcher.fetch(path, options);
63
+ } catch (e) {
64
+ error = e;
65
+ }
66
+ return {calls: stub.calls, error};
67
+ } finally {
68
+ stub.restore();
69
+ }
70
+ }
71
+
72
+ function initWithoutSignal(init) {
73
+ const {signal, ...rest} = init;
74
+ return rest;
75
+ }
76
+
77
+ // --- Rule 1: params become the query string ---
78
+
79
+ await test('numbers, strings and booleans are stringified into the query string', async () => {
80
+ const {calls} = await send('/crm/v3/objects/contacts', {
81
+ method: 'GET',
82
+ params: {limit: 10, after: 'abc', archived: false},
83
+ });
84
+ assertEqual(calls.length, 1, 'one request');
85
+ assertEqual(calls[0].url, `${RAW}/crm/v3/objects/contacts?limit=10&after=abc&archived=false`, 'url');
86
+ });
87
+
88
+ await test('query parameters already in the url are kept', async () => {
89
+ const {calls} = await send('/items?foo=1', {method: 'GET', params: {bar: 2}});
90
+ assertEqual(calls[0].url, `${RAW}/items?foo=1&bar=2`, 'url');
91
+ });
92
+
93
+ await test('undefined and null values are skipped', async () => {
94
+ const {calls} = await send('/items', {method: 'GET', params: {a: undefined, b: null, c: 'x', d: 0, e: ''}});
95
+ assertEqual(calls[0].url, `${RAW}/items?c=x&d=0&e=`, 'url');
96
+ });
97
+
98
+ await test('arrays become repeated keys', async () => {
99
+ const {calls} = await send('/crm/v3/objects/contacts', {
100
+ method: 'GET',
101
+ params: {properties: ['firstname', 'lastname'], limit: 1},
102
+ });
103
+ assertEqual(calls[0].url, `${RAW}/crm/v3/objects/contacts?properties=firstname&properties=lastname&limit=1`, 'url');
104
+ });
105
+
106
+ await test('undefined and null array items are skipped, other items stringified', async () => {
107
+ const {calls} = await send('/items', {method: 'GET', params: {ids: [1, undefined, null, true, 'x']}});
108
+ assertEqual(calls[0].url, `${RAW}/items?ids=1&ids=true&ids=x`, 'url');
109
+ });
110
+
111
+ await test('nested objects are refused with a clear error and nothing is sent', async () => {
112
+ const started = Date.now();
113
+ const {calls, error} = await send('/items', {method: 'GET', params: {filter: {a: 1}}}, {retry: 3});
114
+ assert(error, 'fetch must reject');
115
+ assert(/params\.filter/.test(error.message) && /object/.test(error.message), `error message: ${error.message}`);
116
+ assertEqual(calls.length, 0, 'no request sent');
117
+ assert(Date.now() - started < 400, 'refused at once, not retried');
118
+ });
119
+
120
+ await test('objects inside arrays are refused too', async () => {
121
+ const {calls, error} = await send('/items', {method: 'GET', params: {ids: [{a: 1}]}});
122
+ assert(error && /params\.ids/.test(error.message), `error: ${error && error.message}`);
123
+ assertEqual(calls.length, 0, 'no request sent');
124
+ });
125
+
126
+ await test('values are encoded by URLSearchParams and a "//" inside a value is not collapsed', async () => {
127
+ const value = 'a b&c=d/é https://x.test//y';
128
+ const {calls} = await send('/items', {method: 'GET', params: {q: value}});
129
+ assertEqual(calls[0].url, `${RAW}/items?${new URLSearchParams({q: value})}`, 'url');
130
+ assertEqual(new URL(calls[0].url).searchParams.get('q'), value, 'decoded value round-trips');
131
+ });
132
+
133
+ await test('a URLSearchParams instance is sent as it is', async () => {
134
+ const {calls} = await send('/items', {
135
+ method: 'GET',
136
+ params: new URLSearchParams([
137
+ ['a', '1'],
138
+ ['a', '2'],
139
+ ]),
140
+ });
141
+ assertEqual(calls[0].url, `${RAW}/items?a=1&a=2`, 'url');
142
+ });
143
+
144
+ await test('without baseUrl the query is appended to the url as given', async () => {
145
+ const {calls} = await send('https://other.example.test/x', {method: 'GET', params: {a: 1}}, {baseUrl: undefined});
146
+ assertEqual(calls[0].url, 'https://other.example.test/x?a=1', 'url');
147
+ });
148
+
149
+ // --- Rule 2: nothing else changes ---
150
+
151
+ await test('params is not passed on to global fetch; headers, body and method are unchanged', async () => {
152
+ const options = {method: 'POST', headers: {'X-Custom': '1'}, body: {name: 'n'}};
153
+ const plain = await send('/items', {...options});
154
+ const withParams = await send('/items', {...options, params: {a: 1}});
155
+ assert(!('params' in withParams.calls[0].init), 'params must not reach native fetch');
156
+ assertEqual(initWithoutSignal(withParams.calls[0].init), initWithoutSignal(plain.calls[0].init), 'init');
157
+ });
158
+
159
+ await test('the caller options and params objects are not mutated', async () => {
160
+ const params = {a: 1, list: ['x', 'y'], skip: undefined};
161
+ const options = {method: 'GET', params};
162
+ await send('/items', options);
163
+ assertEqual(Object.keys(options), ['method', 'params'], 'options keys');
164
+ assert(options.params === params, 'same params object');
165
+ assertEqual(Object.keys(params), ['a', 'list', 'skip'], 'params keys');
166
+ assertEqual(params.list, ['x', 'y'], 'array untouched');
167
+ });
168
+
169
+ await test('customize runs as before and can change url and params', async () => {
170
+ let seen;
171
+ const {calls} = await send(
172
+ '/items',
173
+ {method: 'GET', params: {a: 1}},
174
+ {
175
+ customize: (o) => {
176
+ seen = {url: o.url, params: {...o.params}};
177
+ o.url = '/v2' + o.url;
178
+ o.params = {...o.params, key: 'k'};
179
+ o.headers = {...o.headers, Authorization: 'Bearer t'};
180
+ },
181
+ }
182
+ );
183
+ assertEqual(seen, {url: '/items', params: {a: 1}}, 'customize input');
184
+ assertEqual(calls[0].url, `${RAW}/v2/items?a=1&key=k`, 'url');
185
+ assertEqual(calls[0].init.headers.Authorization, 'Bearer t', 'customize header');
186
+ });
187
+
188
+ await test('a retried request sends the same url with the query string exactly once', async () => {
189
+ const {calls, error} = await send(
190
+ '/items',
191
+ {method: 'GET', params: {a: 1, list: ['x', 'y']}},
192
+ {retry: 2},
193
+ [500, 200]
194
+ );
195
+ assert(!error, `no error expected, got ${error && error.message}`);
196
+ assertEqual(calls.length, 2, 'first try + one retry');
197
+ assertEqual(calls[0].url, `${RAW}/items?a=1&list=x&list=y`, 'first url');
198
+ assertEqual(calls[1].url, calls[0].url, 'retry url');
199
+ assert(!('params' in calls[1].init), 'params must not reach native fetch on retry');
200
+ });
201
+
202
+ await test('a failed attempt logs the url without its query string', async () => {
203
+ const logged = [];
204
+ const realLog = console.log;
205
+ console.log = (...args) => logged.push(args.map(String).join(' '));
206
+ let calls;
207
+ try {
208
+ ({calls} = await send('/items?id=user42', {method: 'GET', params: {email: 'person@example.test'}}, {retry: 1}, [
209
+ 500,
210
+ ]));
211
+ } finally {
212
+ console.log = realLog;
213
+ }
214
+ assertEqual(calls[0].url, `${RAW}/items?id=user42&email=person%40example.test`, 'the query is sent');
215
+ assert(logged.length > 0, 'the failure is logged');
216
+ assert(
217
+ !logged.some((l) => l.includes('person') || l.includes('user42')),
218
+ `query value leaked into the log: ${logged.join(' | ')}`
219
+ );
220
+ assert(
221
+ logged.some((l) => l.includes(`${RAW}/items`)),
222
+ 'the path is still logged'
223
+ );
224
+ });
225
+
226
+ // --- Rule 3: no params gives exactly today's URL ---
227
+
228
+ await test('no params, empty params or all-undefined params give exactly the old url', async () => {
229
+ for (const options of [
230
+ {method: 'GET'},
231
+ {method: 'GET', params: {}},
232
+ {method: 'GET', params: {a: undefined, b: null}},
233
+ {method: 'GET', params: undefined},
234
+ ]) {
235
+ const {calls} = await send('/items/1', options);
236
+ assertEqual(calls[0].url, `${RAW}/items/1`, `url for ${JSON.stringify(options)}`);
237
+ }
238
+ const {calls} = await send('/items/1?x=1', {method: 'GET', params: {}});
239
+ assertEqual(calls[0].url, `${RAW}/items/1?x=1`, 'existing query alone is untouched');
240
+ });
241
+
242
+ // --- OAuth fetcher inherits the behaviour ---
243
+
244
+ await test('the OAuth fetcher sends the query string and its Bearer token', async () => {
245
+ const stub = stubFetch();
246
+ try {
247
+ const client = new OAuth({access_token: 'tok'}).getClient({baseUrl: BASE, retry: 1});
248
+ await client.fetch('/crm/v3/objects/contacts', {method: 'GET', params: {properties: ['a', 'b'], archived: true}});
249
+ assertEqual(stub.calls[0].url, `${RAW}/crm/v3/objects/contacts?properties=a&properties=b&archived=true`, 'url');
250
+ assertEqual(stub.calls[0].init.headers.Authorization, 'Bearer tok', 'auth header');
251
+ assert(!('params' in stub.calls[0].init), 'params must not reach native fetch');
252
+ } finally {
253
+ stub.restore();
254
+ }
255
+ });
256
+
257
+ // --- End to end over a local socket ---
258
+
259
+ await test('a local HTTP server receives the query string through the real global fetch', async () => {
260
+ const seen = [];
261
+ const server = http.createServer((req, res) => {
262
+ seen.push(req.url);
263
+ res.writeHead(200, {'content-type': 'application/json'});
264
+ res.end('{"ok":true}');
265
+ });
266
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
267
+ try {
268
+ const {port} = server.address();
269
+ const fetcher = new Fetcher({baseUrl: `http://127.0.0.1:${port}`, retry: 1});
270
+ const ret = await fetcher.fetch('/crm/v3/objects/contacts', {
271
+ method: 'GET',
272
+ params: {limit: 2, properties: ['a', 'b']},
273
+ });
274
+ assertEqual(ret, {ok: true}, 'response');
275
+ assertEqual(seen, ['/crm/v3/objects/contacts?limit=2&properties=a&properties=b'], 'request line');
276
+ } finally {
277
+ await new Promise((resolve) => server.close(resolve));
278
+ }
279
+ });
280
+
281
+ // --- Typing for hand-written connectors ---
282
+
283
+ await test('params is part of the typed fetch options', async () => {
284
+ const dts = fs.readFileSync(new URL('../build/internal/fetcher/fetcher.d.mts', import.meta.url), 'utf8');
285
+ assert(/params\?:/.test(dts), 'fetcher.d.mts declares params?');
286
+ });
287
+
288
+ console.log(`\n${colors.yellow}--- Fetcher query params test results ---${colors.reset}`);
289
+ console.log(`${colors.green}Passed: ${passed}${colors.reset}`);
290
+ console.log(`${colors.red}Failed: ${failed}${colors.reset}`);
291
+
292
+ if (failed > 0) {
293
+ process.exit(1);
294
+ }
@@ -138,6 +138,13 @@ paths:
138
138
  }).to.throw('Invalid OpenAPI 3.x specification');
139
139
  });
140
140
 
141
+ it('names the field that fails validation (zod 4 reports issues, not errors)', () => {
142
+ const missingTitle = {openapi: '3.0.0', info: {version: '1.0.0'}, paths: {}};
143
+ expect(() => {
144
+ OpenAPIToConnector.parseSpec(JSON.stringify(missingTitle));
145
+ }).to.throw(/Invalid OpenAPI 3\.x specification: info\.title: /);
146
+ });
147
+
141
148
  it('should reject invalid JSON/YAML', () => {
142
149
  expect(() => {
143
150
  OpenAPIToConnector.parseSpec('invalid json {');
@@ -2,8 +2,8 @@
2
2
  //
3
3
  // yarn build && yarn test:mutation
4
4
  //
5
- // Applies each deliberate single-point bug below to src/, rebuilds, runs the harness tests and expects them
6
- // to FAIL. The original file is restored after every mutant (and on Ctrl-C). Writes test/MUTATION.md.
5
+ // Applies each deliberate single-point bug below to src/, rebuilds, runs the harness tests (or, for the F mutants,
6
+ // test/fetcher-query-params.test.mjs) and expects them to FAIL. The original file is restored after every mutant (and on Ctrl-C). Writes test/MUTATION.md.
7
7
  // Not part of CI: it rebuilds the SDK ~30 times. Run it when the harness or the SDK paths it covers change.
8
8
  import {spawnSync} from 'node:child_process';
9
9
  import fs from 'node:fs';
@@ -47,6 +47,19 @@ const MUTANTS = [
47
47
  ['M30', 'harness fake ALOMA', 'testing/fake-aloma.mts', "{query: method, variables, ...ctx}", "{query: method, variables}", 'call() drops the ctx fields'],
48
48
  ['M31', 'harness', 'testing/harness.mts', 'this.vendor.verify();', '', 'stop() no longer fails on unmocked requests'],
49
49
  ['M32', 'harness', 'testing/harness.mts', "await aloma.setConfig(options.config || {});", "", 'the harness never sends configuration'],
50
+ // PLAT-47: Fetcher query parameters, checked by test/fetcher-query-params.test.mjs
51
+ ['F01', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'const fullURL = withQuery(theURL, toQuery(options0.params));', 'const fullURL = theURL;', 'params are not sent at all (the PLAT-47 bug)'],
52
+ ['F02', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'if (Array.isArray(value)) value.forEach((item) => add(key, item));\n else add(key, value);', 'add(key, value);', 'array handling dropped (arrays are refused as objects)'],
53
+ ['F03', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'if (Array.isArray(value)) value.forEach((item) => add(key, item));', "if (Array.isArray(value)) add(key, value.join(','));", 'arrays are comma-joined instead of repeated keys'],
54
+ ['F04', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'if (value == null) return;', 'if (value === null) return;', 'undefined values are not skipped'],
55
+ ['F05', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'if (value == null) return;', 'if (value === undefined) return;', 'null values are not skipped'],
56
+ ['F06', 'sdk fetcher', 'internal/fetcher/fetcher.mts', " if (typeof value === 'object') {", ' if (false) {', 'nested objects are sent instead of refused'],
57
+ ['F07', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'if (e instanceof ParamsError) throw e;', '', 'a refused params object is retried'],
58
+ ['F08', 'sdk fetcher', 'internal/fetcher/fetcher.mts', "const sep = !base.includes('?') ? '?'", "const sep = true ? '?'", 'a query already in the url gets a second "?"'],
59
+ ['F09', 'sdk fetcher', 'internal/fetcher/fetcher.mts', ' ...init,\n', ' ...options0,\n', 'params is passed on to native fetch'],
60
+ ['F10', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'const {params, ...init} = options0;', 'const {params, ...init} = options0;\n delete options0.params;', 'params are dropped from options0, so a retry loses the query'],
61
+ ['F11', 'sdk fetcher', 'internal/fetcher/fetcher.mts', "console.log(theURL.split('?')[0], e);", 'console.log(theURL, e);', 'a failed attempt logs query values'],
62
+ ['F12', 'sdk fetcher', 'internal/fetcher/fetcher.mts', 'if (params instanceof URLSearchParams) return params.toString();', '', 'a URLSearchParams instance is silently dropped'],
50
63
  ];
51
64
 
52
65
  const run = (cmd, args, timeout) =>
@@ -57,6 +70,8 @@ const build = () => {
57
70
  if (r.status !== 0) throw new Error('build failed:\n' + r.stdout + r.stderr);
58
71
  };
59
72
  const mocha = () => run('node', ['./node_modules/mocha/bin/_mocha', '--bail', '--exit', 'test/harness.test.mjs', 'test/harness-connector-smoke.test.mjs'], 150000);
73
+ const fetcherTests = () => run('node', ['test/fetcher-query-params.test.mjs'], 150000);
74
+ const testsFor = (id) => (id.startsWith('F') ? fetcherTests() : mocha());
60
75
 
61
76
  let current = null;
62
77
  const restore = () => {
@@ -73,6 +88,8 @@ try {
73
88
  build();
74
89
  const baseline = mocha();
75
90
  if (baseline.status !== 0) throw new Error('baseline run must pass before mutating:\n' + baseline.stdout.slice(-2000));
91
+ const fetcherBaseline = fetcherTests();
92
+ if (fetcherBaseline.status !== 0) throw new Error('fetcher baseline must pass:\n' + fetcherBaseline.stdout.slice(-2000));
76
93
 
77
94
  for (const [id, area, rel, find, replace, bug] of MUTANTS) {
78
95
  const file = src(rel);
@@ -85,8 +102,8 @@ try {
85
102
  try {
86
103
  fs.writeFileSync(file, original.replace(find, () => replace));
87
104
  build();
88
- const r = mocha();
89
- const failing = (r.stdout.match(/^\s+\d+\) .+$/gm) || [])[0]?.trim() || '';
105
+ const r = testsFor(id);
106
+ const failing = (r.stdout.match(/^\s+\d+\) .+$|FAIL: .+$/gm) || [])[0]?.trim() || '';
90
107
  outcome = r.error?.code === 'ETIMEDOUT' ? 'killed (timeout)' : r.status !== 0 ? 'killed' : 'SURVIVED';
91
108
  results.push({id, area, rel, bug, outcome, by: failing});
92
109
  } finally {
@@ -107,7 +124,8 @@ const md = [
107
124
  '',
108
125
  'Each row is one deliberate single-point bug in the SDK code paths the harness exercises (handshake, transport,',
109
126
  'dispatch, result return, config) or in the harness itself. `tools/mutation-run.mjs` applies it to `src/`, rebuilds,',
110
- 'runs `test/harness.test.mjs` with `--bail`, expects a failure, and restores the file. Re-run with',
127
+ 'runs `test/harness.test.mjs` with `--bail` (F rows: `test/fetcher-query-params.test.mjs`), expects a failure, and',
128
+ 'restores the file. Re-run with',
111
129
  '`yarn build && yarn test:mutation` after changing the harness or those SDK paths.',
112
130
  '',
113
131
  '| # | Area | File | Bug | Result |',