@luckystack/test-runner 0.10.1 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/AGENTS.md +7 -0
  2. package/CHANGELOG.md +28 -1
  3. package/README.md +13 -66
  4. package/dist/authEnforcementCheck.d.ts +12 -0
  5. package/dist/contractCheck.d.ts +24 -0
  6. package/dist/csrfEnforcementCheck.d.ts +25 -0
  7. package/dist/customTests.d.ts +145 -0
  8. package/dist/extensionRegistry.d.ts +83 -0
  9. package/dist/fuzzCheck.d.ts +12 -0
  10. package/dist/index.d.ts +49 -557
  11. package/dist/index.js +560 -304
  12. package/dist/index.js.map +1 -1
  13. package/dist/probeRequest.d.ts +48 -0
  14. package/dist/rateLimitCheck.d.ts +14 -0
  15. package/dist/reporter.d.ts +3 -0
  16. package/dist/resetServerState.d.ts +13 -0
  17. package/dist/resolveTestBaseUrl.d.ts +7 -0
  18. package/dist/resolveTestEnvironment.d.ts +21 -0
  19. package/dist/runAllTests.d.ts +88 -0
  20. package/dist/runAuthEnforcementTests.d.ts +17 -0
  21. package/dist/runContractTests.d.ts +22 -0
  22. package/dist/runCsrfEnforcementTests.d.ts +29 -0
  23. package/dist/runFuzzTests.d.ts +16 -0
  24. package/dist/runRateLimitTests.d.ts +38 -0
  25. package/dist/runRegisteredLayers.d.ts +24 -0
  26. package/dist/sampleInputFor.d.ts +13 -0
  27. package/dist/schemaSampleInput.d.ts +12 -0
  28. package/dist/socketProbeTransport.d.ts +9 -0
  29. package/dist/streamWatcher.d.ts +37 -0
  30. package/dist/testDataMarker.d.ts +2 -0
  31. package/dist/testLayerHelpers.d.ts +55 -0
  32. package/dist/testSocket.d.ts +58 -0
  33. package/dist/types.d.ts +46 -0
  34. package/dist/walkEndpoints.d.ts +6 -0
  35. package/docs/auth-tests.md +4 -223
  36. package/docs/contract-tests.md +11 -293
  37. package/docs/extension-hooks.md +4 -460
  38. package/docs/fuzz-tests.md +4 -245
  39. package/docs/rate-limit-tests.md +9 -253
  40. package/package.json +5 -5
  41. package/CLAUDE.md +0 -104
package/dist/index.js CHANGED
@@ -32,20 +32,21 @@ var walkEndpoints = (apiMethodMap) => {
32
32
  }
33
33
  return endpoints;
34
34
  };
35
- var walkSyncEndpoints = (syncMethodMap) => {
35
+ var walkSyncEndpoints = (syncMap) => {
36
36
  const endpoints = [];
37
- for (const [page, nameMap] of Object.entries(syncMethodMap)) {
37
+ for (const [page, nameMap] of Object.entries(syncMap)) {
38
38
  if (!nameMap) continue;
39
39
  for (const [name, versionMap] of Object.entries(nameMap)) {
40
40
  if (!versionMap) continue;
41
- for (const [version, method] of Object.entries(versionMap)) {
42
- if (method === void 0) continue;
41
+ for (const [version, leaf] of Object.entries(versionMap)) {
42
+ if (leaf === void 0) continue;
43
43
  endpoints.push({
44
44
  page,
45
45
  name,
46
46
  version,
47
47
  method: "POST",
48
- fullPath: `sync/${page}/${name}/${version}`
48
+ fullPath: `sync/${page}/${name}/${version}`,
49
+ kind: "sync"
49
50
  });
50
51
  }
51
52
  }
@@ -54,13 +55,15 @@ var walkSyncEndpoints = (syncMethodMap) => {
54
55
  };
55
56
 
56
57
  // src/probeRequest.ts
57
- import { tryCatch } from "@luckystack/core";
58
+ import { buildRoutedGetUrl, tryCatch } from "@luckystack/core";
58
59
  var sendProbe = async (input) => {
59
60
  const controller = new AbortController();
60
61
  const timeoutHandle = setTimeout(() => {
61
62
  controller.abort();
62
63
  }, input.requestTimeoutMs);
63
- const [fetchError, response] = await tryCatch(() => fetch(input.url, {
64
+ const isGet = input.method === "GET";
65
+ const url = isGet && input.body !== void 0 ? buildRoutedGetUrl(input.url, input.body) : input.url;
66
+ const [fetchError, response] = await tryCatch(() => fetch(url, {
64
67
  method: input.method,
65
68
  headers: {
66
69
  "Content-Type": "application/json",
@@ -69,7 +72,7 @@ var sendProbe = async (input) => {
69
72
  "Origin": new URL(input.baseUrl).origin,
70
73
  ...input.headers
71
74
  },
72
- body: input.method === "GET" ? void 0 : JSON.stringify(input.body),
75
+ body: isGet ? void 0 : JSON.stringify(input.body),
73
76
  signal: controller.signal
74
77
  }));
75
78
  clearTimeout(timeoutHandle);
@@ -79,17 +82,27 @@ var sendProbe = async (input) => {
79
82
  );
80
83
  return { httpStatus: response.status, parsed: parsed ?? null };
81
84
  };
85
+ var createHttpProbeTransport = (options = {}) => ({
86
+ name: "http",
87
+ send: (request) => sendProbe({
88
+ url: `${request.baseUrl.replace(/\/$/, "")}/${request.endpoint.fullPath}`,
89
+ method: request.endpoint.method,
90
+ baseUrl: request.baseUrl,
91
+ body: request.endpoint.kind === "sync" ? { data: request.body, receiver: options.syncReceiver } : request.body,
92
+ headers: request.headers,
93
+ requestTimeoutMs: request.requestTimeoutMs
94
+ })
95
+ });
96
+ var defaultProbeTransport = createHttpProbeTransport();
82
97
 
83
98
  // src/contractCheck.ts
84
99
  var DEFAULT_REQUEST_TIMEOUT_MS = 5e3;
85
100
  var runContractCheck = async (input) => {
86
101
  const { endpoint, baseUrl } = input;
87
- const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
88
102
  const body = input.inputFor ? input.inputFor(endpoint) : {};
89
103
  const started = Date.now();
90
- const result = await sendProbe({
91
- url,
92
- method: endpoint.method,
104
+ const result = await (input.transport ?? defaultProbeTransport).send({
105
+ endpoint,
93
106
  baseUrl,
94
107
  body,
95
108
  headers: input.headers,
@@ -167,23 +180,31 @@ var LAYER_KEYS = {
167
180
  custom: "custom"
168
181
  };
169
182
  var STATE_CHANGING_METHODS = /* @__PURE__ */ new Set(["POST", "PUT", "DELETE"]);
183
+ var routeKey = (endpoint) => `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
184
+ var matchesFilter = (endpoint, filter) => !filter || routeKey(endpoint).includes(filter);
185
+ var selectEndpoints = (input) => [
186
+ ...walkEndpoints(input.apiMethodMap),
187
+ ...input.syncMetaMap ? walkSyncEndpoints(input.syncMetaMap) : []
188
+ ].filter((endpoint) => matchesFilter(endpoint, input.filter));
189
+ var metaMapFor = (endpoint, apiMetaMap, syncMetaMap) => endpoint.kind === "sync" ? syncMetaMap ?? {} : apiMetaMap;
190
+ var transportFor = (endpoint, syncTransport) => endpoint.kind === "sync" && syncTransport ? syncTransport : defaultProbeTransport;
170
191
  var shouldSkip = (endpoint, skip) => {
171
192
  if (skip.length === 0) return false;
172
- const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
193
+ const versioned = routeKey(endpoint);
173
194
  const versionless = `${endpoint.page}/${endpoint.name}`;
174
195
  return skip.includes(versioned) || skip.includes(versionless);
175
196
  };
176
- var requiresLogin = (apiMetaMap, endpoint) => {
177
- const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
197
+ var requiresLogin = (metaMap, endpoint) => {
198
+ const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
178
199
  return meta?.auth?.login ?? false;
179
200
  };
180
- var hasAuthRequirement = (apiMetaMap, endpoint) => {
181
- const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
201
+ var hasAuthRequirement = (metaMap, endpoint) => {
202
+ const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
182
203
  return (meta?.auth?.login ?? false) || (meta?.auth?.hasAdditional ?? false);
183
204
  };
184
- var hasMetaEntry = (apiMetaMap, endpoint) => apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version] !== void 0;
185
- var getRateLimit = (apiMetaMap, endpoint) => {
186
- const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
205
+ var hasMetaEntry = (metaMap, endpoint) => metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version] !== void 0;
206
+ var getRateLimit = (metaMap, endpoint) => {
207
+ const meta = metaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
187
208
  if (!meta || meta.rateLimit === false || meta.rateLimit === void 0) return null;
188
209
  return meta.rateLimit;
189
210
  };
@@ -197,7 +218,7 @@ var calculateSummary = (results) => ({
197
218
 
198
219
  // src/runContractTests.ts
199
220
  var runContractTests = async (input) => {
200
- const endpoints = walkEndpoints(input.apiMethodMap);
221
+ const endpoints = selectEndpoints(input);
201
222
  const skip = input.skip ?? [];
202
223
  const results = [];
203
224
  for (const endpoint of endpoints) {
@@ -216,7 +237,8 @@ var runContractTests = async (input) => {
216
237
  endpoint,
217
238
  baseUrl: input.baseUrl,
218
239
  inputFor: input.inputFor,
219
- headers: input.headers
240
+ headers: input.headers,
241
+ transport: transportFor(endpoint, input.syncTransport)
220
242
  });
221
243
  results.push(result);
222
244
  input.onResult?.(result);
@@ -229,12 +251,10 @@ var DEFAULT_REQUEST_TIMEOUT_MS2 = 5e3;
229
251
  var EXPECTED_ERROR_CODES = /* @__PURE__ */ new Set(["auth.required", "auth.forbidden"]);
230
252
  var runAuthEnforcementCheck = async (input) => {
231
253
  const { endpoint, baseUrl } = input;
232
- const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
233
254
  const body = input.inputFor ? input.inputFor(endpoint) : {};
234
255
  const started = Date.now();
235
- const result = await sendProbe({
236
- url,
237
- method: endpoint.method,
256
+ const result = await (input.transport ?? defaultProbeTransport).send({
257
+ endpoint,
238
258
  baseUrl,
239
259
  body,
240
260
  requestTimeoutMs: input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2
@@ -292,22 +312,23 @@ var runAuthEnforcementCheck = async (input) => {
292
312
 
293
313
  // src/runAuthEnforcementTests.ts
294
314
  var runAuthEnforcementTests = async (input) => {
295
- const endpoints = walkEndpoints(input.apiMethodMap);
315
+ const endpoints = selectEndpoints(input);
296
316
  const skip = input.skip ?? [];
297
317
  const results = [];
298
318
  for (const endpoint of endpoints) {
299
- if (!hasMetaEntry(input.apiMetaMap, endpoint)) {
319
+ const metaMap = metaMapFor(endpoint, input.apiMetaMap, input.syncMetaMap);
320
+ if (!hasMetaEntry(metaMap, endpoint)) {
300
321
  const skipped = {
301
322
  endpoint,
302
323
  status: "skipped",
303
324
  durationMs: 0,
304
- reason: "No apiMetaMap entry \u2014 auth requirement unverifiable"
325
+ reason: `No ${endpoint.kind === "sync" ? "syncMetaMap" : "apiMetaMap"} entry \u2014 auth requirement unverifiable`
305
326
  };
306
327
  results.push(skipped);
307
328
  input.onResult?.(skipped);
308
329
  continue;
309
330
  }
310
- if (!hasAuthRequirement(input.apiMetaMap, endpoint)) {
331
+ if (!hasAuthRequirement(metaMap, endpoint)) {
311
332
  continue;
312
333
  }
313
334
  if (shouldSkip(endpoint, skip)) {
@@ -324,7 +345,8 @@ var runAuthEnforcementTests = async (input) => {
324
345
  const result = await runAuthEnforcementCheck({
325
346
  endpoint,
326
347
  baseUrl: input.baseUrl,
327
- inputFor: input.inputFor
348
+ inputFor: input.inputFor,
349
+ transport: transportFor(endpoint, input.syncTransport)
328
350
  });
329
351
  results.push(result);
330
352
  input.onResult?.(result);
@@ -334,21 +356,24 @@ var runAuthEnforcementTests = async (input) => {
334
356
 
335
357
  // src/rateLimitCheck.ts
336
358
  var DEFAULT_REQUEST_TIMEOUT_MS3 = 1e4;
337
- var EXPECTED_ERROR_CODE = "api.rateLimitExceeded";
359
+ var expectedErrorCodeFor = (endpoint) => endpoint.kind === "sync" ? "sync.rateLimitExceeded" : "api.rateLimitExceeded";
338
360
  var PRE_LIMITER_ERROR_CODES = /* @__PURE__ */ new Set([
339
361
  "auth.required",
340
362
  "auth.forbidden",
341
- "auth.csrfMismatch"
363
+ "auth.csrfMismatch",
364
+ "sync.notRoomMember",
365
+ "sync.receiverNotAllowed",
366
+ "sync.missingReceiver"
342
367
  ]);
343
368
  var runRateLimitCheck = async (input) => {
344
369
  const { endpoint, baseUrl, rateLimit } = input;
345
- const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
346
370
  const body = input.inputFor ? input.inputFor(endpoint) : {};
347
371
  const started = Date.now();
348
372
  const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS3;
349
- const send = () => sendProbe({
350
- url,
351
- method: endpoint.method,
373
+ const expectedErrorCode = expectedErrorCodeFor(endpoint);
374
+ const transport = input.transport ?? defaultProbeTransport;
375
+ const send = () => transport.send({
376
+ endpoint,
352
377
  baseUrl,
353
378
  body,
354
379
  headers: input.headers,
@@ -378,7 +403,7 @@ var runRateLimitCheck = async (input) => {
378
403
  };
379
404
  }
380
405
  const { httpStatus, parsed } = final;
381
- if (parsed?.status === "error" && parsed.errorCode === EXPECTED_ERROR_CODE) {
406
+ if (parsed?.status === "error" && parsed.errorCode === expectedErrorCode) {
382
407
  return {
383
408
  endpoint,
384
409
  status: "pass",
@@ -395,16 +420,17 @@ var runRateLimitCheck = async (input) => {
395
420
  httpStatus,
396
421
  responseStatus,
397
422
  errorCode: parsed?.errorCode,
398
- reason: `expected '${EXPECTED_ERROR_CODE}' on request ${rateLimit + 1} but got ${parsed?.status ?? "none"}/${parsed?.errorCode ?? "(none)"}`,
423
+ reason: `expected '${expectedErrorCode}' on request ${rateLimit + 1} but got ${parsed?.status ?? "none"}/${parsed?.errorCode ?? "(none)"}`,
399
424
  durationMs
400
425
  };
401
426
  };
402
427
 
403
428
  // src/resetServerState.ts
404
- import { getProjectConfig, tryCatch as tryCatch2 } from "@luckystack/core";
429
+ import { getProjectConfig, TEST_RESET_ONLY_PARAM, tryCatch as tryCatch2 } from "@luckystack/core";
405
430
  var DEFAULT_REQUEST_TIMEOUT_MS4 = 1e4;
406
- var resetServerState = async ({ baseUrl, token, requestTimeoutMs }) => {
407
- const url = `${baseUrl.replace(/\/$/, "")}${getProjectConfig().http.testResetEndpoint}`;
431
+ var resetServerState = async ({ baseUrl, token, requestTimeoutMs, only }) => {
432
+ const endpoint = `${baseUrl.replace(/\/$/, "")}${getProjectConfig().http.testResetEndpoint}`;
433
+ const url = only && only.length > 0 ? `${endpoint}?${new URLSearchParams({ [TEST_RESET_ONLY_PARAM]: only.join(",") }).toString()}` : endpoint;
408
434
  const headers = { "Content-Type": "application/json" };
409
435
  if (token) headers["X-Test-Reset-Token"] = token;
410
436
  const controller = new AbortController();
@@ -419,15 +445,16 @@ var resetServerState = async ({ baseUrl, token, requestTimeoutMs }) => {
419
445
 
420
446
  // src/runRateLimitTests.ts
421
447
  var runRateLimitTests = async (input) => {
422
- const endpoints = walkEndpoints(input.apiMethodMap);
448
+ const endpoints = selectEndpoints(input);
423
449
  const skip = input.skip ?? [];
424
450
  const maxRateLimit = input.maxRateLimitToTest ?? 50;
425
451
  const isAuthenticatedSweep = Boolean(input.headers?.Cookie);
426
452
  const results = [];
427
453
  for (const endpoint of endpoints) {
428
- const rateLimit = getRateLimit(input.apiMetaMap, endpoint);
454
+ const metaMap = metaMapFor(endpoint, input.apiMetaMap, input.syncMetaMap);
455
+ const rateLimit = getRateLimit(metaMap, endpoint);
429
456
  if (rateLimit === null) continue;
430
- if (!isAuthenticatedSweep && requiresLogin(input.apiMetaMap, endpoint)) {
457
+ if (!isAuthenticatedSweep && requiresLogin(metaMap, endpoint)) {
431
458
  const skipped = {
432
459
  endpoint,
433
460
  status: "skipped",
@@ -472,14 +499,15 @@ var runRateLimitTests = async (input) => {
472
499
  continue;
473
500
  }
474
501
  if (input.resetBetweenEndpoints) {
475
- await resetServerState({ baseUrl: input.baseUrl, token: input.resetToken });
502
+ await resetServerState({ baseUrl: input.baseUrl, token: input.resetToken, only: ["rateLimits"] });
476
503
  }
477
504
  const result = await runRateLimitCheck({
478
505
  endpoint,
479
506
  baseUrl: input.baseUrl,
480
507
  rateLimit,
481
508
  inputFor: input.inputFor,
482
- headers: input.headers
509
+ headers: input.headers,
510
+ transport: transportFor(endpoint, input.syncTransport)
483
511
  });
484
512
  results.push(result);
485
513
  input.onResult?.(result);
@@ -501,14 +529,21 @@ var runCsrfEnforcementCheck = async (input) => {
501
529
  durationMs: 0
502
530
  };
503
531
  }
504
- const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
532
+ const transport = input.transport ?? defaultProbeTransport;
533
+ if (transport.name !== "http") {
534
+ return {
535
+ endpoint,
536
+ status: "skipped",
537
+ reason: `CSRF enforcement only applies over HTTP (transport: ${transport.name})`,
538
+ durationMs: 0
539
+ };
540
+ }
505
541
  const body = input.inputFor ? input.inputFor(endpoint) : {};
506
542
  const started = Date.now();
507
543
  const expectedErrorCode = input.expectedErrorCode ?? DEFAULT_EXPECTED_ERROR_CODE;
508
544
  const expectedHttpStatus = input.expectedHttpStatus ?? DEFAULT_EXPECTED_HTTP_STATUS;
509
- const result = await sendProbe({
510
- url,
511
- method: endpoint.method,
545
+ const result = await transport.send({
546
+ endpoint,
512
547
  baseUrl,
513
548
  body,
514
549
  headers: { Cookie: input.authCookie },
@@ -557,12 +592,13 @@ var runCsrfEnforcementCheck = async (input) => {
557
592
 
558
593
  // src/runCsrfEnforcementTests.ts
559
594
  var runCsrfEnforcementTests = async (input) => {
560
- const endpoints = walkEndpoints(input.apiMethodMap);
595
+ const endpoints = selectEndpoints(input);
561
596
  const skip = input.skip ?? [];
562
597
  const results = [];
563
598
  for (const endpoint of endpoints) {
564
- if (!hasMetaEntry(input.apiMetaMap, endpoint)) continue;
565
- if (!requiresLogin(input.apiMetaMap, endpoint)) continue;
599
+ const metaMap = metaMapFor(endpoint, input.apiMetaMap, input.syncMetaMap);
600
+ if (!hasMetaEntry(metaMap, endpoint)) continue;
601
+ if (!requiresLogin(metaMap, endpoint)) continue;
566
602
  if (!STATE_CHANGING_METHODS.has(endpoint.method)) continue;
567
603
  if (shouldSkip(endpoint, skip)) {
568
604
  const skipped = {
@@ -582,7 +618,8 @@ var runCsrfEnforcementTests = async (input) => {
582
618
  inputFor: input.inputFor,
583
619
  requestTimeoutMs: input.requestTimeoutMs,
584
620
  expectedErrorCode: input.expectedErrorCode,
585
- expectedHttpStatus: input.expectedHttpStatus
621
+ expectedHttpStatus: input.expectedHttpStatus,
622
+ transport: transportFor(endpoint, input.syncTransport)
586
623
  });
587
624
  results.push(result);
588
625
  input.onResult?.(result);
@@ -609,11 +646,10 @@ var JUNK_PAYLOADS = [
609
646
  var runFuzzCheck = async (input) => {
610
647
  const started = Date.now();
611
648
  const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS6;
612
- const url = `${input.baseUrl.replace(/\/$/, "")}/${input.endpoint.fullPath}`;
649
+ const transport = input.transport ?? defaultProbeTransport;
613
650
  for (const payload of JUNK_PAYLOADS) {
614
- const result = await sendProbe({
615
- url,
616
- method: input.endpoint.method,
651
+ const result = await transport.send({
652
+ endpoint: input.endpoint,
617
653
  baseUrl: input.baseUrl,
618
654
  body: payload,
619
655
  headers: input.headers,
@@ -656,7 +692,7 @@ var runFuzzCheck = async (input) => {
656
692
 
657
693
  // src/runFuzzTests.ts
658
694
  var runFuzzTests = async (input) => {
659
- const endpoints = walkEndpoints(input.apiMethodMap);
695
+ const endpoints = selectEndpoints(input);
660
696
  const skip = input.skip ?? [];
661
697
  const isAuthenticatedSweep = Boolean(input.headers?.Cookie);
662
698
  const results = [];
@@ -686,7 +722,8 @@ var runFuzzTests = async (input) => {
686
722
  const result = await runFuzzCheck({
687
723
  endpoint,
688
724
  baseUrl: input.baseUrl,
689
- headers: input.headers
725
+ headers: input.headers,
726
+ transport: transportFor(endpoint, input.syncTransport)
690
727
  });
691
728
  results.push(result);
692
729
  input.onResult?.(result);
@@ -694,81 +731,203 @@ var runFuzzTests = async (input) => {
694
731
  return calculateSummary(results);
695
732
  };
696
733
 
697
- // src/resolveTestBaseUrl.ts
698
- import fs from "fs";
699
- import path from "path";
700
- var isProcessRunning = (pid) => {
701
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
702
- try {
703
- process.kill(pid, 0);
704
- return true;
705
- } catch (error) {
706
- return error instanceof Error && "code" in error && error.code === "EPERM";
707
- }
734
+ // src/socketProbeTransport.ts
735
+ import { tryCatch as tryCatch4 } from "@luckystack/core";
736
+
737
+ // src/testSocket.ts
738
+ import { io as createSocketIoClient } from "socket.io-client";
739
+ import {
740
+ buildJoinRoomResponseEventName,
741
+ buildSyncResponseEventName,
742
+ getProjectConfig as getProjectConfig2,
743
+ socketEventNames,
744
+ tryCatch as tryCatch3
745
+ } from "@luckystack/core";
746
+ var DEFAULT_CONNECT_TIMEOUT_MS = 3e3;
747
+ var DEFAULT_JOIN_TIMEOUT_MS = 3e3;
748
+ var responseIndexCounter = 0;
749
+ var nextResponseIndex = () => {
750
+ responseIndexCounter += 1;
751
+ return responseIndexCounter;
708
752
  };
709
- var resolveTestBaseUrl = (options = {}) => {
710
- const explicit = process.env.TEST_BASE_URL;
711
- if (explicit) return explicit;
712
- try {
713
- const raw = fs.readFileSync(
714
- path.join(options.cwd ?? process.cwd(), "node_modules", ".luckystack", "dev-server.json"),
715
- "utf8"
716
- );
717
- const info = JSON.parse(raw);
718
- if (typeof info.port === "number" && Number.isInteger(info.port) && info.port > 0 && info.port <= 65535 && isProcessRunning(info.pid)) {
719
- return `http://localhost:${String(info.port)}`;
753
+ var waitForConnect = (socket, label, timeoutMs) => {
754
+ return new Promise((resolve, reject) => {
755
+ if (socket.connected) {
756
+ resolve();
757
+ return;
720
758
  }
721
- } catch {
759
+ const timer = setTimeout(() => {
760
+ cleanup();
761
+ reject(new Error(`${label}: socket failed to connect within ${timeoutMs}ms`));
762
+ }, timeoutMs);
763
+ const onConnect = () => {
764
+ cleanup();
765
+ resolve();
766
+ };
767
+ const onConnectError = (err) => {
768
+ cleanup();
769
+ reject(new Error(`${label}: connect_error \u2014 ${err.message}`));
770
+ };
771
+ const cleanup = () => {
772
+ clearTimeout(timer);
773
+ socket.off("connect", onConnect);
774
+ socket.off("connect_error", onConnectError);
775
+ };
776
+ socket.on("connect", onConnect);
777
+ socket.on("connect_error", onConnectError);
778
+ });
779
+ };
780
+ var connectTestSocket = async (input) => {
781
+ const sessionCookieName = input.sessionCookieName ?? getProjectConfig2().http.sessionCookieName;
782
+ const socket = createSocketIoClient(input.baseUrl, {
783
+ transports: ["websocket"],
784
+ forceNew: true,
785
+ reconnection: false,
786
+ auth: input.token ? { token: input.token } : void 0,
787
+ extraHeaders: input.token ? { Cookie: `${sessionCookieName}=${input.token}` } : void 0
788
+ });
789
+ const [connectError] = await tryCatch3(
790
+ () => waitForConnect(socket, input.label, input.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
791
+ );
792
+ if (connectError) {
793
+ socket.disconnect();
794
+ throw connectError;
722
795
  }
723
- return options.fallbackUrl ?? "http://localhost:80";
796
+ return socket;
797
+ };
798
+ var RELEASE_TIMEOUT_MS = 1e3;
799
+ var releaseTestSocket = async (socket) => {
800
+ if (!socket.connected) return;
801
+ await new Promise((resolve) => {
802
+ const timer = setTimeout(() => {
803
+ socket.off("disconnect", onDisconnect);
804
+ resolve();
805
+ }, RELEASE_TIMEOUT_MS);
806
+ const onDisconnect = () => {
807
+ clearTimeout(timer);
808
+ resolve();
809
+ };
810
+ socket.once("disconnect", onDisconnect);
811
+ socket.emit(socketEventNames.intentionalDisconnect);
812
+ });
813
+ socket.disconnect();
814
+ };
815
+ var joinTestRoom = (socket, room, options) => {
816
+ const timeoutMs = options.timeoutMs ?? DEFAULT_JOIN_TIMEOUT_MS;
817
+ const responseIndex = nextResponseIndex();
818
+ const eventName = buildJoinRoomResponseEventName(responseIndex);
819
+ return new Promise((resolve, reject) => {
820
+ const timer = setTimeout(() => {
821
+ socket.off(eventName, onAck);
822
+ reject(new Error(`${options.label}: joinRoom ack timeout after ${timeoutMs}ms`));
823
+ }, timeoutMs);
824
+ const onAck = (payload) => {
825
+ clearTimeout(timer);
826
+ socket.off(eventName, onAck);
827
+ if (typeof payload === "object" && payload !== null && payload.status === "error") {
828
+ const errorCode = payload.errorCode;
829
+ reject(new Error(`${options.label}: joinRoom rejected \u2014 ${typeof errorCode === "string" ? errorCode : "unknown"}`));
830
+ return;
831
+ }
832
+ resolve();
833
+ };
834
+ socket.on(eventName, onAck);
835
+ socket.emit(socketEventNames.joinRoom, { group: room, responseIndex });
836
+ });
837
+ };
838
+ var emitSyncOverSocket = (socket, input) => {
839
+ const responseIndex = nextResponseIndex();
840
+ const eventName = buildSyncResponseEventName(responseIndex);
841
+ return new Promise((resolve) => {
842
+ const timer = setTimeout(() => {
843
+ socket.off(eventName, onResponse);
844
+ resolve({ kind: "timeout" });
845
+ }, input.timeoutMs);
846
+ const onResponse = (payload) => {
847
+ clearTimeout(timer);
848
+ socket.off(eventName, onResponse);
849
+ resolve({ kind: "response", payload });
850
+ };
851
+ socket.on(eventName, onResponse);
852
+ socket.emit(socketEventNames.sync, {
853
+ name: `sync/${input.routePath}`,
854
+ //? Only `undefined` defaults — a fuzz probe's `null` must reach the server as-is.
855
+ data: input.data === void 0 ? {} : input.data,
856
+ //? Same fallback cancel key the real client sends before the server's
857
+ //? `__cancelId` handshake arrives; the runner never cancels, so it is
858
+ //? only there to keep the request shape identical.
859
+ cb: input.routePath,
860
+ receiver: input.receiver,
861
+ responseIndex,
862
+ ignoreSelf: input.ignoreSelf ?? false
863
+ });
864
+ });
724
865
  };
725
866
 
726
- // src/resolveTestEnvironment.ts
727
- import { loadEnvFiles, tryCatch as tryCatch3 } from "@luckystack/core";
728
- var assertProjectConfigLoader = (input, caller) => {
729
- if (typeof input !== "object" || input === null || !("loadProjectConfig" in input) || typeof input.loadProjectConfig !== "function") {
730
- throw new TypeError(`[test-runner] ${caller} requires a lazy loadProjectConfig callback`);
867
+ // src/socketProbeTransport.ts
868
+ var LABEL = "sync probe";
869
+ var tokenFromCookieHeader = (cookieHeader, cookieName) => {
870
+ if (!cookieHeader) return null;
871
+ for (const part of cookieHeader.split(";")) {
872
+ const [rawName, ...rest] = part.trim().split("=");
873
+ if (rawName === cookieName && rest.length > 0) return rest.join("=");
731
874
  }
875
+ return null;
732
876
  };
733
- var isRecord = (value) => typeof value === "object" && value !== null;
734
- var isToken = (value) => typeof value === "string" || isRecord(value) && typeof value.fromFile === "string";
735
- var readSecretManagerConfig = (projectConfig) => {
736
- if (!isRecord(projectConfig)) return null;
737
- const candidate = projectConfig.secretManager;
738
- if (!isRecord(candidate)) return null;
739
- const url = candidate.url;
740
- if (url === void 0 || url === "") return null;
741
- if (typeof url !== "string") {
742
- throw new TypeError("[test-runner] config.secretManager.url must be a string.");
743
- }
744
- if (!isToken(candidate.token)) {
745
- throw new TypeError(
746
- "[test-runner] config.secretManager.token must be a string or { fromFile: string }."
747
- );
748
- }
749
- return { ...candidate, url, token: candidate.token };
877
+ var toProbeResponse = (payload) => {
878
+ if (typeof payload !== "object" || payload === null) return { parsed: null };
879
+ const record = payload;
880
+ return {
881
+ parsed: {
882
+ status: typeof record.status === "string" ? record.status : void 0,
883
+ errorCode: typeof record.errorCode === "string" ? record.errorCode : void 0
884
+ }
885
+ };
750
886
  };
751
- var resolveTestEnvironment = async (input = {}) => {
752
- loadEnvFiles();
753
- if (!input.loadProjectConfig) return;
754
- const projectConfig = await input.loadProjectConfig();
755
- const secretManagerConfig = readSecretManagerConfig(projectConfig);
756
- if (!secretManagerConfig) return;
757
- const [importError, secretManager] = await tryCatch3(
758
- () => import("@luckystack/secret-manager")
759
- );
760
- if (importError || !secretManager) {
761
- throw new Error(
762
- "[test-runner] config.secretManager.url is set, but @luckystack/secret-manager could not be loaded. Install the package or remove the secret-manager config; tests will not continue with unresolved env pointers.",
763
- { cause: importError ?? void 0 }
764
- );
765
- }
766
- await secretManager.initSecretManager({
767
- ...secretManagerConfig,
768
- //? Match the normal server/ORM bootstrap: configured remote resolution is
769
- //? fail-fast. A raw DATABASE_URL_V<n> must never leak into Prisma.
770
- source: "remote"
771
- });
887
+ var createSocketProbeTransport = (options) => {
888
+ const http = createHttpProbeTransport({ syncReceiver: options.receiver });
889
+ const sockets = /* @__PURE__ */ new Map();
890
+ const socketFor = (token) => {
891
+ const key = token ?? "";
892
+ const existing = sockets.get(key);
893
+ if (existing) return existing;
894
+ const opening = (async () => {
895
+ const [connectError, socket] = await tryCatch4(() => connectTestSocket({
896
+ baseUrl: options.baseUrl,
897
+ token,
898
+ label: LABEL,
899
+ sessionCookieName: options.sessionCookieName
900
+ }));
901
+ if (connectError || !socket) return null;
902
+ if (token) await tryCatch4(() => joinTestRoom(socket, options.receiver, { label: LABEL }));
903
+ return socket;
904
+ })();
905
+ sockets.set(key, opening);
906
+ return opening;
907
+ };
908
+ const send = async (request) => {
909
+ if (request.endpoint.kind !== "sync") return http.send(request);
910
+ const socket = await socketFor(tokenFromCookieHeader(request.headers?.Cookie, options.sessionCookieName));
911
+ if (!socket) return null;
912
+ const outcome = await emitSyncOverSocket(socket, {
913
+ routePath: `${request.endpoint.page}/${request.endpoint.name}/${request.endpoint.version}`,
914
+ data: request.body,
915
+ receiver: options.receiver,
916
+ timeoutMs: request.requestTimeoutMs
917
+ });
918
+ return outcome.kind === "response" ? toProbeResponse(outcome.payload) : null;
919
+ };
920
+ const close = async () => {
921
+ const opened = [...sockets.entries()];
922
+ sockets.clear();
923
+ for (const [key, pending] of opened) {
924
+ const socket = await pending;
925
+ if (!socket) continue;
926
+ if (key) await releaseTestSocket(socket);
927
+ else socket.disconnect();
928
+ }
929
+ };
930
+ return { name: "socket", send, close };
772
931
  };
773
932
 
774
933
  // src/schemaSampleInput.ts
@@ -886,6 +1045,90 @@ var sampleSchemaInput = (schema, options) => {
886
1045
  return sampleForDef(node.def ?? node._def, options);
887
1046
  };
888
1047
 
1048
+ // src/sampleInputFor.ts
1049
+ var createSampleInputFor = (options) => (endpoint) => {
1050
+ const schemas = endpoint.kind === "sync" ? options.syncInputSchemas : options.apiInputSchemas;
1051
+ const schema = schemas?.[endpoint.page]?.[endpoint.name]?.[endpoint.version];
1052
+ return schema ? sampleSchemaInput(schema, { stringPrefix: options.stringPrefix }) : {};
1053
+ };
1054
+
1055
+ // src/resolveTestBaseUrl.ts
1056
+ import fs from "fs";
1057
+ import path from "path";
1058
+ var isProcessRunning = (pid) => {
1059
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0) return false;
1060
+ try {
1061
+ process.kill(pid, 0);
1062
+ return true;
1063
+ } catch (error) {
1064
+ return error instanceof Error && "code" in error && error.code === "EPERM";
1065
+ }
1066
+ };
1067
+ var resolveTestBaseUrl = (options = {}) => {
1068
+ const explicit = process.env.TEST_BASE_URL;
1069
+ if (explicit) return explicit;
1070
+ try {
1071
+ const raw = fs.readFileSync(
1072
+ path.join(options.cwd ?? process.cwd(), "node_modules", ".luckystack", "dev-server.json"),
1073
+ "utf8"
1074
+ );
1075
+ const info = JSON.parse(raw);
1076
+ if (typeof info.port === "number" && Number.isInteger(info.port) && info.port > 0 && info.port <= 65535 && isProcessRunning(info.pid)) {
1077
+ return `http://localhost:${String(info.port)}`;
1078
+ }
1079
+ } catch {
1080
+ }
1081
+ return options.fallbackUrl ?? "http://localhost:80";
1082
+ };
1083
+
1084
+ // src/resolveTestEnvironment.ts
1085
+ import { loadEnvFiles, tryCatch as tryCatch5 } from "@luckystack/core";
1086
+ var assertProjectConfigLoader = (input, caller) => {
1087
+ if (typeof input !== "object" || input === null || !("loadProjectConfig" in input) || typeof input.loadProjectConfig !== "function") {
1088
+ throw new TypeError(`[test-runner] ${caller} requires a lazy loadProjectConfig callback`);
1089
+ }
1090
+ };
1091
+ var isRecord = (value) => typeof value === "object" && value !== null;
1092
+ var isToken = (value) => typeof value === "string" || isRecord(value) && typeof value.fromFile === "string";
1093
+ var readSecretManagerConfig = (projectConfig) => {
1094
+ if (!isRecord(projectConfig)) return null;
1095
+ const candidate = projectConfig.secretManager;
1096
+ if (!isRecord(candidate)) return null;
1097
+ const url = candidate.url;
1098
+ if (url === void 0 || url === "") return null;
1099
+ if (typeof url !== "string") {
1100
+ throw new TypeError("[test-runner] config.secretManager.url must be a string.");
1101
+ }
1102
+ if (!isToken(candidate.token)) {
1103
+ throw new TypeError(
1104
+ "[test-runner] config.secretManager.token must be a string or { fromFile: string }."
1105
+ );
1106
+ }
1107
+ return { ...candidate, url, token: candidate.token };
1108
+ };
1109
+ var resolveTestEnvironment = async (input = {}) => {
1110
+ loadEnvFiles();
1111
+ if (!input.loadProjectConfig) return;
1112
+ const projectConfig = await input.loadProjectConfig();
1113
+ const secretManagerConfig = readSecretManagerConfig(projectConfig);
1114
+ if (!secretManagerConfig) return;
1115
+ const [importError, secretManager] = await tryCatch5(
1116
+ () => import("@luckystack/secret-manager")
1117
+ );
1118
+ if (importError || !secretManager) {
1119
+ throw new Error(
1120
+ "[test-runner] config.secretManager.url is set, but @luckystack/secret-manager could not be loaded. Install the package or remove the secret-manager config; tests will not continue with unresolved env pointers.",
1121
+ { cause: importError ?? void 0 }
1122
+ );
1123
+ }
1124
+ await secretManager.initSecretManager({
1125
+ ...secretManagerConfig,
1126
+ //? Match the normal server/ORM bootstrap: configured remote resolution is
1127
+ //? fail-fast. A raw DATABASE_URL_V<n> must never leak into Prisma.
1128
+ source: "remote"
1129
+ });
1130
+ };
1131
+
889
1132
  // src/testDataMarker.ts
890
1133
  import { randomUUID } from "crypto";
891
1134
  var TEST_DATA_PREFIX = "lstest_";
@@ -940,91 +1183,25 @@ import fs2 from "fs";
940
1183
  import path2 from "path";
941
1184
  import { pathToFileURL } from "url";
942
1185
  import {
943
- buildRoutedGetUrl,
1186
+ buildRoutedGetUrl as buildRoutedGetUrl2,
944
1187
  getCsrfConfig,
945
1188
  getPrismaClient,
946
1189
  getProjectConfig as getProjectConfig3,
947
1190
  getSrcDir,
948
- tryCatch as tryCatch5,
1191
+ tryCatch as tryCatch7,
949
1192
  tryCatchSync
950
1193
  } from "@luckystack/core";
951
1194
 
952
1195
  // src/streamWatcher.ts
953
- import { io as createSocketIoClient } from "socket.io-client";
954
- import {
955
- buildJoinRoomResponseEventName,
956
- socketEventNames,
957
- tryCatch as tryCatch4,
958
- getProjectConfig as getProjectConfig2
959
- } from "@luckystack/core";
1196
+ import { socketEventNames as socketEventNames2, tryCatch as tryCatch6 } from "@luckystack/core";
960
1197
  var DEFAULT_TIMEOUT_MS = 5e3;
961
- var JOIN_RESPONSE_TIMEOUT_MS = 3e3;
962
- var CONNECT_TIMEOUT_MS = 3e3;
963
- var isJoinRoomResponse = (value) => typeof value === "object" && value !== null;
1198
+ var LABEL2 = "watchStream";
964
1199
  var isStreamChunkFrame = (value) => typeof value === "object" && value !== null;
965
- var responseIndexCounter = 0;
966
- var nextResponseIndex = () => {
967
- responseIndexCounter += 1;
968
- return responseIndexCounter;
969
- };
970
- var waitForConnect = (socket) => {
971
- return new Promise((resolve, reject) => {
972
- if (socket.connected) {
973
- resolve();
974
- return;
975
- }
976
- const timer = setTimeout(() => {
977
- cleanup();
978
- reject(new Error(`watchStream: socket failed to connect within ${CONNECT_TIMEOUT_MS}ms`));
979
- }, CONNECT_TIMEOUT_MS);
980
- const onConnect = () => {
981
- cleanup();
982
- resolve();
983
- };
984
- const onConnectError = (err) => {
985
- cleanup();
986
- reject(new Error(`watchStream: connect_error \u2014 ${err.message}`));
987
- };
988
- const cleanup = () => {
989
- clearTimeout(timer);
990
- socket.off("connect", onConnect);
991
- socket.off("connect_error", onConnectError);
992
- };
993
- socket.on("connect", onConnect);
994
- socket.on("connect_error", onConnectError);
995
- });
996
- };
997
- var waitForJoinAck = (socket, responseIndex) => {
998
- const eventName = buildJoinRoomResponseEventName(responseIndex);
999
- return new Promise((resolve, reject) => {
1000
- const timer = setTimeout(() => {
1001
- socket.off(eventName, onAck);
1002
- reject(new Error(`watchStream: joinRoom ack timeout after ${JOIN_RESPONSE_TIMEOUT_MS}ms`));
1003
- }, JOIN_RESPONSE_TIMEOUT_MS);
1004
- const onAck = (payload) => {
1005
- clearTimeout(timer);
1006
- socket.off(eventName, onAck);
1007
- if (isJoinRoomResponse(payload) && payload.status === "error") {
1008
- reject(new Error(`watchStream: joinRoom rejected \u2014 ${payload.errorCode ?? "unknown"}`));
1009
- return;
1010
- }
1011
- resolve();
1012
- };
1013
- socket.on(eventName, onAck);
1014
- });
1015
- };
1016
1200
  var openStreamWatcher = async (input) => {
1017
1201
  const defaultTimeoutMs = input.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
1018
1202
  const chunks = [];
1019
1203
  const waiters = [];
1020
- const sessionCookieName = getProjectConfig2().http.sessionCookieName;
1021
- const socket = createSocketIoClient(input.baseUrl, {
1022
- transports: ["websocket"],
1023
- forceNew: true,
1024
- reconnection: false,
1025
- auth: input.token ? { token: input.token } : void 0,
1026
- extraHeaders: input.token ? { Cookie: `${sessionCookieName}=${input.token}` } : void 0
1027
- });
1204
+ const socket = await connectTestSocket({ baseUrl: input.baseUrl, token: input.token, label: LABEL2 });
1028
1205
  const handleChunk = (raw) => {
1029
1206
  if (!isStreamChunkFrame(raw)) return;
1030
1207
  if (raw.status !== "stream") return;
@@ -1040,21 +1217,10 @@ var openStreamWatcher = async (input) => {
1040
1217
  }
1041
1218
  }
1042
1219
  };
1043
- socket.on(socketEventNames.sync, handleChunk);
1044
- const [connectError] = await tryCatch4(() => waitForConnect(socket));
1045
- if (connectError) {
1046
- socket.off(socketEventNames.sync, handleChunk);
1047
- socket.disconnect();
1048
- throw connectError;
1049
- }
1050
- const responseIndex = nextResponseIndex();
1051
- socket.emit(socketEventNames.joinRoom, {
1052
- group: input.roomCode,
1053
- responseIndex
1054
- });
1055
- const [joinError] = await tryCatch4(() => waitForJoinAck(socket, responseIndex));
1220
+ socket.on(socketEventNames2.sync, handleChunk);
1221
+ const [joinError] = await tryCatch6(() => joinTestRoom(socket, input.roomCode, { label: LABEL2 }));
1056
1222
  if (joinError) {
1057
- socket.off(socketEventNames.sync, handleChunk);
1223
+ socket.off(socketEventNames2.sync, handleChunk);
1058
1224
  socket.disconnect();
1059
1225
  throw joinError;
1060
1226
  }
@@ -1062,7 +1228,7 @@ var openStreamWatcher = async (input) => {
1062
1228
  const close = () => {
1063
1229
  if (closed) return Promise.resolve();
1064
1230
  closed = true;
1065
- socket.off(socketEventNames.sync, handleChunk);
1231
+ socket.off(socketEventNames2.sync, handleChunk);
1066
1232
  for (const waiter of waiters) waiter.resolve();
1067
1233
  waiters.length = 0;
1068
1234
  socket.disconnect();
@@ -1166,7 +1332,7 @@ var buildExpect = () => ({
1166
1332
  if (!value) throw new AssertionError(message ?? `expected truthy value, got ${JSON.stringify(value)}`);
1167
1333
  },
1168
1334
  throws: async (fn, message) => {
1169
- const [err] = await tryCatch5(fn);
1335
+ const [err] = await tryCatch7(fn);
1170
1336
  if (!err) throw new AssertionError(message ?? "expected fn to throw, but it did not");
1171
1337
  return err;
1172
1338
  },
@@ -1218,7 +1384,7 @@ var buildSessionHelpers = (state) => {
1218
1384
  };
1219
1385
  };
1220
1386
  var parseResponse = async (response, state) => {
1221
- const [textError, text] = await tryCatch5(() => response.text());
1387
+ const [textError, text] = await tryCatch7(() => response.text());
1222
1388
  if (textError || text === null) {
1223
1389
  state.lastResponse = null;
1224
1390
  throw new Error(`failed to read response body (HTTP ${response.status}): ${textError?.message ?? "unknown"}`);
@@ -1234,7 +1400,7 @@ var parseResponse = async (response, state) => {
1234
1400
  };
1235
1401
  var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1236
1402
  return async (input) => {
1237
- const path3 = `${baseUrl.replace(/\/$/, "")}/api/${routePath}`;
1403
+ const path4 = `${baseUrl.replace(/\/$/, "")}/api/${routePath}`;
1238
1404
  const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
1239
1405
  if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
1240
1406
  if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
@@ -1244,9 +1410,9 @@ var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1244
1410
  }, DEFAULT_CALL_TIMEOUT_MS);
1245
1411
  const init = { method, headers, signal: controller.signal };
1246
1412
  const bodyless = method === "GET" || method === "HEAD";
1247
- const url = bodyless ? buildRoutedGetUrl(path3, input ?? {}) : path3;
1413
+ const url = bodyless ? buildRoutedGetUrl2(path4, input ?? {}) : path4;
1248
1414
  if (!bodyless) init.body = JSON.stringify(input ?? {});
1249
- const [fetchError, response] = await tryCatch5(() => fetch(url, init));
1415
+ const [fetchError, response] = await tryCatch7(() => fetch(url, init));
1250
1416
  clearTimeout(timeoutHandle);
1251
1417
  if (fetchError || !response) {
1252
1418
  state.lastResponse = null;
@@ -1255,18 +1421,57 @@ var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1255
1421
  return await parseResponse(response, state);
1256
1422
  };
1257
1423
  };
1258
- var buildCallSync = (baseUrl, routePath, state, cookieName) => {
1424
+ var createCaseSyncSocket = (baseUrl, cookieName) => {
1425
+ let current = null;
1426
+ const close = () => {
1427
+ current?.socket.disconnect();
1428
+ current = null;
1429
+ };
1430
+ return {
1431
+ get: async (token) => {
1432
+ if (current?.token === token && current.socket.connected) return current;
1433
+ close();
1434
+ const socket = await connectTestSocket({ baseUrl, token, label: "callSync", sessionCookieName: cookieName });
1435
+ current = { socket, token, joinedRooms: /* @__PURE__ */ new Set() };
1436
+ return current;
1437
+ },
1438
+ close
1439
+ };
1440
+ };
1441
+ var resolveSyncTransport = (opts) => opts?.transport ?? (getProjectConfig3().transport.invocation === "routed-http" ? "http" : "socket");
1442
+ var buildCallSync = (baseUrl, routePath, state, cookieName, syncSocket) => {
1259
1443
  return async (input, opts) => {
1444
+ if (resolveSyncTransport(opts) === "socket") {
1445
+ const { socket, joinedRooms } = await syncSocket.get(state.token);
1446
+ const { receiver, joinRoom = true } = opts ?? {};
1447
+ if (receiver && receiver !== "all" && joinRoom && !joinedRooms.has(receiver)) {
1448
+ const [joinError] = await tryCatch7(() => joinTestRoom(socket, receiver, { label: "callSync" }));
1449
+ if (!joinError) joinedRooms.add(receiver);
1450
+ }
1451
+ const outcome = await emitSyncOverSocket(socket, {
1452
+ routePath,
1453
+ data: input,
1454
+ receiver,
1455
+ ignoreSelf: opts?.ignoreSelf,
1456
+ timeoutMs: DEFAULT_CALL_TIMEOUT_MS
1457
+ });
1458
+ if (outcome.kind === "timeout") {
1459
+ state.lastResponse = null;
1460
+ throw new Error(`callSync ${routePath} failed: no socket response within ${DEFAULT_CALL_TIMEOUT_MS}ms`);
1461
+ }
1462
+ state.lastResponse = outcome.payload;
1463
+ return outcome.payload;
1464
+ }
1260
1465
  const url = `${baseUrl.replace(/\/$/, "")}/sync/${routePath}`;
1261
1466
  const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
1262
1467
  if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
1263
1468
  if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
1264
- const body = { data: input ?? {}, receiver: opts?.receiver };
1469
+ const body = { data: input ?? {}, receiver: opts?.receiver, ignoreSelf: opts?.ignoreSelf };
1265
1470
  const controller = new AbortController();
1266
1471
  const timeoutHandle = setTimeout(() => {
1267
1472
  controller.abort();
1268
1473
  }, DEFAULT_CALL_TIMEOUT_MS);
1269
- const [fetchError, response] = await tryCatch5(() => fetch(url, {
1474
+ const [fetchError, response] = await tryCatch7(() => fetch(url, {
1270
1475
  method: "POST",
1271
1476
  headers,
1272
1477
  body: JSON.stringify(body),
@@ -1297,18 +1502,20 @@ var buildContext = (discovery, input) => {
1297
1502
  watchers.push(watcher);
1298
1503
  return watcher;
1299
1504
  };
1505
+ const syncSocket = createCaseSyncSocket(input.baseUrl, cookieName);
1300
1506
  const closeAllWatchers = async () => {
1507
+ syncSocket.close();
1301
1508
  while (watchers.length > 0) {
1302
1509
  const watcher = watchers.pop();
1303
1510
  if (!watcher) continue;
1304
- const [closeError] = await tryCatch5(() => watcher.close());
1511
+ const [closeError] = await tryCatch7(() => watcher.close());
1305
1512
  if (closeError) {
1306
1513
  }
1307
1514
  }
1308
1515
  };
1309
1516
  const ctx = {
1310
1517
  callApi: buildCallApi(input.baseUrl, routePath, state, cookieName, apiMethod),
1311
- callSync: buildCallSync(input.baseUrl, routePath, state, cookieName),
1518
+ callSync: buildCallSync(input.baseUrl, routePath, state, cookieName, syncSocket),
1312
1519
  watchStream,
1313
1520
  session: buildSessionHelpers(state),
1314
1521
  //? Lazy: only resolves when a case actually touches `ctx.prisma`, so an
@@ -1343,7 +1550,7 @@ var runCustomTestsAfterEnvironmentPrepared = async (input) => {
1343
1550
  input.onResult?.(r);
1344
1551
  continue;
1345
1552
  }
1346
- const [importError, mod] = await tryCatch5(
1553
+ const [importError, mod] = await tryCatch7(
1347
1554
  () => import(pathToFileURL(resolvedPath).href)
1348
1555
  );
1349
1556
  if (importError || !mod) {
@@ -1368,11 +1575,11 @@ var runCustomTestsAfterEnvironmentPrepared = async (input) => {
1368
1575
  const expectedToFail = typeof c.expectedToFail === "string" ? c.expectedToFail : void 0;
1369
1576
  const built = buildContext(discovery, input);
1370
1577
  const started = Date.now();
1371
- const [runError] = await tryCatch5(async () => c.run(built.ctx));
1372
- await tryCatch5(() => built.closeAllWatchers());
1578
+ const [runError] = await tryCatch7(async () => c.run(built.ctx));
1579
+ await tryCatch7(() => built.closeAllWatchers());
1373
1580
  const residualToken = built.state.token;
1374
1581
  if (residualToken) {
1375
- await tryCatch5(async () => {
1582
+ await tryCatch7(async () => {
1376
1583
  const { deleteSession } = await import("@luckystack/login");
1377
1584
  await deleteSession(residualToken);
1378
1585
  });
@@ -1413,11 +1620,10 @@ var runCustomTests = async (input) => {
1413
1620
  };
1414
1621
 
1415
1622
  // src/runAllTests.ts
1416
- import { clearAllRateLimits, getCsrfConfig as getCsrfConfig2, getProjectConfig as getProjectConfig4 } from "@luckystack/core";
1417
- var inputForEndpoint = (apiInputSchemas, stringPrefix) => (endpoint) => {
1418
- const schema = apiInputSchemas[endpoint.page]?.[endpoint.name]?.[endpoint.version];
1419
- return schema ? sampleSchemaInput(schema, { stringPrefix }) : {};
1420
- };
1623
+ import crypto2 from "crypto";
1624
+ import fs3 from "fs";
1625
+ import path3 from "path";
1626
+ import { clearAllRateLimits, getCsrfConfig as getCsrfConfig2, getProjectConfig as getProjectConfig4, tryCatch as tryCatch8, tryCatchSync as tryCatchSync2 } from "@luckystack/core";
1421
1627
  var resolveResetBookendToken = (input) => {
1422
1628
  if (input.resetBookends === false) return null;
1423
1629
  const token = input.resetToken ?? process.env.TEST_RESET_TOKEN;
@@ -1429,15 +1635,6 @@ var resolveResetBookendToken = (input) => {
1429
1635
  }
1430
1636
  return token;
1431
1637
  };
1432
- var matchesFilter = (endpoint, filter) => {
1433
- if (!filter) return true;
1434
- return `${endpoint.page}/${endpoint.name}/${endpoint.version}`.includes(filter);
1435
- };
1436
- var filterResults = (results, filter) => filter ? results.filter((r) => matchesFilter(r.endpoint, filter)) : results;
1437
- var cloneSummary = (summary, filter) => {
1438
- if (!filter) return summary;
1439
- return calculateSummary(filterResults(summary.results, filter));
1440
- };
1441
1638
  var buildAuthHeaders = async (input) => {
1442
1639
  const headers = {};
1443
1640
  if (!input.authToken) return headers;
@@ -1458,70 +1655,92 @@ var buildAuthHeaders = async (input) => {
1458
1655
  }
1459
1656
  return headers;
1460
1657
  };
1658
+ var prepareSyncSweep = async (input) => {
1659
+ const receiver = `lstest-room-${crypto2.randomUUID()}`;
1660
+ const sessionCookieName = input.sessionCookieName ?? getProjectConfig4().http.sessionCookieName;
1661
+ const mode = input.syncTransport ?? (getProjectConfig4().transport.invocation === "routed-http" ? "http" : "socket");
1662
+ const http = createHttpProbeTransport({ syncReceiver: receiver });
1663
+ let memberSocket = null;
1664
+ if (input.authToken) {
1665
+ const authToken = input.authToken;
1666
+ const [connectError, socket] = await tryCatch8(() => connectTestSocket({
1667
+ baseUrl: input.baseUrl,
1668
+ token: authToken,
1669
+ label: "sync sweep",
1670
+ sessionCookieName
1671
+ }));
1672
+ if (connectError || !socket) {
1673
+ console.warn(`[test-runner] runAllTests: could not open a socket for the sync sweep \u2014 ${connectError?.message ?? "no socket"}; sync probes will be rejected as non-members.`);
1674
+ } else {
1675
+ memberSocket = socket;
1676
+ const [joinError] = await tryCatch8(() => joinTestRoom(socket, receiver, { label: "sync sweep" }));
1677
+ if (joinError) console.warn(`[test-runner] runAllTests: ${joinError.message}; sync probes will be rejected as non-members.`);
1678
+ }
1679
+ }
1680
+ const sweep = mode === "socket" ? createSocketProbeTransport({ baseUrl: input.baseUrl, receiver, sessionCookieName }) : http;
1681
+ return {
1682
+ sweep,
1683
+ csrf: http,
1684
+ close: async () => {
1685
+ await sweep.close?.();
1686
+ if (memberSocket) await releaseTestSocket(memberSocket);
1687
+ }
1688
+ };
1689
+ };
1461
1690
  var runSweepLayers = async (input, summary, headers, inputFor) => {
1691
+ const resetToken = input.resetToken ?? process.env.TEST_RESET_TOKEN;
1462
1692
  const resetBookendToken = resolveResetBookendToken(input);
1463
1693
  if (resetBookendToken) {
1464
- await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken });
1694
+ await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken, only: ["rateLimits"] });
1465
1695
  }
1466
- const contract = await runContractTests({
1467
- apiMethodMap: input.apiMethodMap,
1468
- baseUrl: input.baseUrl,
1469
- skip: input.skip,
1470
- headers,
1471
- inputFor
1472
- });
1473
- summary.contract = cloneSummary(contract, input.filter);
1474
- const auth = await runAuthEnforcementTests({
1696
+ const hasSyncRoutes = Object.keys(input.syncMetaMap ?? {}).length > 0;
1697
+ const sync = hasSyncRoutes ? await prepareSyncSweep(input) : null;
1698
+ const shared = {
1475
1699
  apiMethodMap: input.apiMethodMap,
1476
- apiMetaMap: input.apiMetaMap,
1700
+ syncMetaMap: input.syncMetaMap,
1477
1701
  baseUrl: input.baseUrl,
1478
1702
  skip: input.skip,
1479
- inputFor
1703
+ filter: input.filter,
1704
+ syncTransport: sync?.sweep
1705
+ };
1706
+ const [layerError] = await tryCatch8(async () => {
1707
+ summary.contract = await runContractTests({ ...shared, headers, inputFor });
1708
+ summary.auth = await runAuthEnforcementTests({ ...shared, apiMetaMap: input.apiMetaMap, inputFor });
1709
+ if (!input.noRateLimit) {
1710
+ summary.rateLimit = await runRateLimitTests({
1711
+ ...shared,
1712
+ apiMetaMap: input.apiMetaMap,
1713
+ headers,
1714
+ inputFor,
1715
+ //? Reset the shared per-IP rate-limit bucket between endpoints so a
1716
+ //? neighbour saturating the window doesn't cause false pass/fail on the
1717
+ //? next endpoint. Requires the server to expose /_test/reset (NODE_ENV in
1718
+ //? { 'development', 'test' } AND TEST_RESET_TOKEN set + matching header).
1719
+ resetBetweenEndpoints: true,
1720
+ //? Thread the reset token (explicit input, else the TEST_RESET_TOKEN env the
1721
+ //? server validates against) — without it `/_test/reset` returns 403 and the
1722
+ //? between-endpoint reset silently never happens.
1723
+ resetToken
1724
+ });
1725
+ }
1726
+ if (!input.noCsrf && headers.Cookie) {
1727
+ summary.csrf = await runCsrfEnforcementTests({
1728
+ ...shared,
1729
+ apiMetaMap: input.apiMetaMap,
1730
+ authCookie: headers.Cookie,
1731
+ inputFor,
1732
+ syncTransport: sync?.csrf
1733
+ });
1734
+ }
1735
+ if (!input.noFuzz) {
1736
+ summary.fuzz = await runFuzzTests({ ...shared, headers });
1737
+ }
1480
1738
  });
1481
- summary.auth = cloneSummary(auth, input.filter);
1482
- if (!input.noRateLimit) {
1483
- const rateLimit = await runRateLimitTests({
1484
- apiMethodMap: input.apiMethodMap,
1485
- apiMetaMap: input.apiMetaMap,
1486
- baseUrl: input.baseUrl,
1487
- skip: input.skip,
1488
- headers,
1489
- inputFor,
1490
- //? Reset the shared per-IP rate-limit bucket between endpoints so a
1491
- //? neighbour saturating the window doesn't cause false pass/fail on the
1492
- //? next endpoint. Requires the server to expose /_test/reset (NODE_ENV in
1493
- //? { 'development', 'test' } AND TEST_RESET_TOKEN set + matching header).
1494
- resetBetweenEndpoints: true,
1495
- //? Thread the reset token (explicit input, else the TEST_RESET_TOKEN env the
1496
- //? server validates against) — without it `/_test/reset` returns 403 and the
1497
- //? between-endpoint reset silently never happens.
1498
- resetToken: input.resetToken ?? process.env.TEST_RESET_TOKEN
1499
- });
1500
- summary.rateLimit = cloneSummary(rateLimit, input.filter);
1501
- }
1502
- if (!input.noCsrf && headers.Cookie) {
1503
- const csrf = await runCsrfEnforcementTests({
1504
- apiMethodMap: input.apiMethodMap,
1505
- apiMetaMap: input.apiMetaMap,
1506
- baseUrl: input.baseUrl,
1507
- authCookie: headers.Cookie,
1508
- skip: input.skip,
1509
- inputFor
1510
- });
1511
- summary.csrf = cloneSummary(csrf, input.filter);
1512
- }
1513
- if (!input.noFuzz) {
1514
- const fuzz = await runFuzzTests({
1515
- apiMethodMap: input.apiMethodMap,
1516
- baseUrl: input.baseUrl,
1517
- skip: input.skip,
1518
- headers
1519
- });
1520
- summary.fuzz = cloneSummary(fuzz, input.filter);
1521
- }
1739
+ await sync?.close();
1522
1740
  if (resetBookendToken) {
1523
- await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken });
1741
+ await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken, only: ["rateLimits"] });
1524
1742
  }
1743
+ if (layerError) throw layerError;
1525
1744
  };
1526
1745
  var runCustomLayer = async (input, summary) => {
1527
1746
  try {
@@ -1575,9 +1794,18 @@ var runAllTests = async (input) => {
1575
1794
  "[test-runner] runAllTests: apiMetaMap is empty but apiMethodMap has routes. The auth-enforcement and rate-limit layers will skip every route. Pass the generated apiMetaMap to enable those checks."
1576
1795
  );
1577
1796
  }
1797
+ if (!input.syncMetaMap) {
1798
+ console.warn(
1799
+ "[test-runner] runAllTests: no syncMetaMap passed \u2014 sync routes are NOT swept. Pass the generated syncMetaMap (+ syncInputSchemas) to cover them."
1800
+ );
1801
+ }
1578
1802
  }
1579
1803
  const headers = await buildAuthHeaders(input);
1580
- const inputFor = inputForEndpoint(input.apiInputSchemas, createTestDataMarker());
1804
+ const inputFor = createSampleInputFor({
1805
+ apiInputSchemas: input.apiInputSchemas,
1806
+ syncInputSchemas: input.syncInputSchemas,
1807
+ stringPrefix: createTestDataMarker()
1808
+ });
1581
1809
  if (!input.noSweep) {
1582
1810
  await runSweepLayers(input, summary, headers, inputFor);
1583
1811
  }
@@ -1585,8 +1813,21 @@ var runAllTests = async (input) => {
1585
1813
  await runCustomLayer(input, summary);
1586
1814
  }
1587
1815
  computeTotals(summary);
1816
+ writeSummaryFile(summary, input.outputFile);
1588
1817
  return summary;
1589
1818
  };
1819
+ var writeSummaryFile = (summary, outputFile) => {
1820
+ if (outputFile === false) return;
1821
+ const target = path3.resolve(outputFile ?? process.env.TEST_OUTPUT_FILE ?? "test-results.json");
1822
+ const [writeError] = tryCatchSync2(() => {
1823
+ fs3.writeFileSync(target, JSON.stringify(summary, null, 2), "utf8");
1824
+ });
1825
+ if (writeError) {
1826
+ console.warn(`[test-runner] runAllTests: could not write ${target} \u2014 ${writeError.message}`);
1827
+ return;
1828
+ }
1829
+ console.log(`[test-runner] full results written to ${target}`);
1830
+ };
1590
1831
  var useColor = (() => {
1591
1832
  if (process.env.NO_COLOR) return false;
1592
1833
  if (process.env.FORCE_COLOR) return true;
@@ -1701,13 +1942,13 @@ var resetTestExtensionsForTests = () => {
1701
1942
  };
1702
1943
 
1703
1944
  // src/runRegisteredLayers.ts
1704
- import { tryCatch as tryCatch6, tryCatchSync as tryCatchSync2 } from "@luckystack/core";
1945
+ import { tryCatch as tryCatch9, tryCatchSync as tryCatchSync3 } from "@luckystack/core";
1705
1946
  var isLoopbackHost = (host) => host === "localhost" || host === "::1" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
1706
1947
  var postWebhook = async (summary) => {
1707
1948
  const reporter2 = getTestReporter();
1708
1949
  const webhookUrl = reporter2?.webhookUrl;
1709
1950
  if (!reporter2 || !webhookUrl) return;
1710
- const [, parsedUrl] = tryCatchSync2(() => new URL(webhookUrl));
1951
+ const [, parsedUrl] = tryCatchSync3(() => new URL(webhookUrl));
1711
1952
  if (parsedUrl?.protocol === "http:" && !isLoopbackHost(parsedUrl.hostname)) {
1712
1953
  console.warn(
1713
1954
  `[test-runner] reporter.webhookUrl is plaintext http to a non-loopback host (${parsedUrl.hostname}); the summary (endpoint paths, error codes, metadata) and any bearer token are sent unencrypted.`
@@ -1719,7 +1960,7 @@ var postWebhook = async (summary) => {
1719
1960
  const timeoutHandle = setTimeout(() => {
1720
1961
  controller.abort();
1721
1962
  }, 1e4);
1722
- await tryCatch6(() => fetch(webhookUrl, {
1963
+ await tryCatch9(() => fetch(webhookUrl, {
1723
1964
  method: "POST",
1724
1965
  headers,
1725
1966
  body: JSON.stringify(summary),
@@ -1729,15 +1970,25 @@ var postWebhook = async (summary) => {
1729
1970
  };
1730
1971
  var runRegisteredLayers = async (input) => {
1731
1972
  const layers2 = listTestLayers();
1732
- const endpoints = walkEndpoints(input.apiMethodMap);
1973
+ const endpoints = selectEndpoints(input);
1733
1974
  const reporter2 = getTestReporter();
1734
1975
  const skip = input.skip ?? [];
1735
1976
  const results = [];
1977
+ const inputFor = input.inputFor ?? createSampleInputFor({
1978
+ apiInputSchemas: input.apiInputSchemas,
1979
+ syncInputSchemas: input.syncInputSchemas,
1980
+ stringPrefix: createTestDataMarker()
1981
+ });
1736
1982
  for (const layer of layers2) {
1737
1983
  for (const endpoint of endpoints) {
1738
1984
  if (shouldSkip(endpoint, skip)) continue;
1739
1985
  const started = Date.now();
1740
- const [runError, layerResult] = await tryCatch6(async () => layer.run({ endpoint: endpoint.fullPath, method: endpoint.method, authToken: input.authToken }));
1986
+ const [runError, layerResult] = await tryCatch9(async () => layer.run({
1987
+ endpoint: endpoint.fullPath,
1988
+ method: endpoint.kind === "sync" ? void 0 : endpoint.method,
1989
+ authToken: input.authToken,
1990
+ sampleInput: inputFor(endpoint)
1991
+ }));
1741
1992
  const durationMs = Date.now() - started;
1742
1993
  const result = runError || !layerResult ? {
1743
1994
  layer: layer.name,
@@ -1754,7 +2005,7 @@ var runRegisteredLayers = async (input) => {
1754
2005
  metadata: layerResult.metadata
1755
2006
  };
1756
2007
  results.push(result);
1757
- await tryCatch6(async () => reporter2?.onResult?.(result));
2008
+ await tryCatch9(async () => reporter2?.onResult?.(result));
1758
2009
  }
1759
2010
  }
1760
2011
  const summary = {
@@ -1764,14 +2015,18 @@ var runRegisteredLayers = async (input) => {
1764
2015
  failed: results.filter((r) => !r.passed).length,
1765
2016
  results
1766
2017
  };
1767
- await tryCatch6(async () => reporter2?.onSummary?.(summary));
2018
+ await tryCatch9(async () => reporter2?.onSummary?.(summary));
1768
2019
  await postWebhook(summary);
1769
2020
  return summary;
1770
2021
  };
1771
2022
  export {
1772
2023
  LAYER_KEYS,
1773
2024
  TEST_DATA_PREFIX,
2025
+ createHttpProbeTransport,
2026
+ createSampleInputFor,
2027
+ createSocketProbeTransport,
1774
2028
  createTestDataMarker,
2029
+ defaultProbeTransport,
1775
2030
  discoverCustomTestFiles,
1776
2031
  getTestFixture,
1777
2032
  getTestReporter,
@@ -1801,6 +2056,7 @@ export {
1801
2056
  runRateLimitTests,
1802
2057
  runRegisteredLayers,
1803
2058
  sampleSchemaInput,
2059
+ sendProbe,
1804
2060
  walkEndpoints,
1805
2061
  walkSyncEndpoints
1806
2062
  };