@luckystack/test-runner 0.10.0 → 0.10.2

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.
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_";
@@ -939,84 +1182,26 @@ import crypto from "crypto";
939
1182
  import fs2 from "fs";
940
1183
  import path2 from "path";
941
1184
  import { pathToFileURL } from "url";
942
- import { getCsrfConfig, getPrismaClient, getProjectConfig as getProjectConfig3, getSrcDir, tryCatch as tryCatch5, tryCatchSync } from "@luckystack/core";
943
-
944
- // src/streamWatcher.ts
945
- import { io as createSocketIoClient } from "socket.io-client";
946
1185
  import {
947
- buildJoinRoomResponseEventName,
948
- socketEventNames,
949
- tryCatch as tryCatch4,
950
- getProjectConfig as getProjectConfig2
1186
+ buildRoutedGetUrl as buildRoutedGetUrl2,
1187
+ getCsrfConfig,
1188
+ getPrismaClient,
1189
+ getProjectConfig as getProjectConfig3,
1190
+ getSrcDir,
1191
+ tryCatch as tryCatch7,
1192
+ tryCatchSync
951
1193
  } from "@luckystack/core";
1194
+
1195
+ // src/streamWatcher.ts
1196
+ import { socketEventNames as socketEventNames2, tryCatch as tryCatch6 } from "@luckystack/core";
952
1197
  var DEFAULT_TIMEOUT_MS = 5e3;
953
- var JOIN_RESPONSE_TIMEOUT_MS = 3e3;
954
- var CONNECT_TIMEOUT_MS = 3e3;
955
- var isJoinRoomResponse = (value) => typeof value === "object" && value !== null;
1198
+ var LABEL2 = "watchStream";
956
1199
  var isStreamChunkFrame = (value) => typeof value === "object" && value !== null;
957
- var responseIndexCounter = 0;
958
- var nextResponseIndex = () => {
959
- responseIndexCounter += 1;
960
- return responseIndexCounter;
961
- };
962
- var waitForConnect = (socket) => {
963
- return new Promise((resolve, reject) => {
964
- if (socket.connected) {
965
- resolve();
966
- return;
967
- }
968
- const timer = setTimeout(() => {
969
- cleanup();
970
- reject(new Error(`watchStream: socket failed to connect within ${CONNECT_TIMEOUT_MS}ms`));
971
- }, CONNECT_TIMEOUT_MS);
972
- const onConnect = () => {
973
- cleanup();
974
- resolve();
975
- };
976
- const onConnectError = (err) => {
977
- cleanup();
978
- reject(new Error(`watchStream: connect_error \u2014 ${err.message}`));
979
- };
980
- const cleanup = () => {
981
- clearTimeout(timer);
982
- socket.off("connect", onConnect);
983
- socket.off("connect_error", onConnectError);
984
- };
985
- socket.on("connect", onConnect);
986
- socket.on("connect_error", onConnectError);
987
- });
988
- };
989
- var waitForJoinAck = (socket, responseIndex) => {
990
- const eventName = buildJoinRoomResponseEventName(responseIndex);
991
- return new Promise((resolve, reject) => {
992
- const timer = setTimeout(() => {
993
- socket.off(eventName, onAck);
994
- reject(new Error(`watchStream: joinRoom ack timeout after ${JOIN_RESPONSE_TIMEOUT_MS}ms`));
995
- }, JOIN_RESPONSE_TIMEOUT_MS);
996
- const onAck = (payload) => {
997
- clearTimeout(timer);
998
- socket.off(eventName, onAck);
999
- if (isJoinRoomResponse(payload) && payload.status === "error") {
1000
- reject(new Error(`watchStream: joinRoom rejected \u2014 ${payload.errorCode ?? "unknown"}`));
1001
- return;
1002
- }
1003
- resolve();
1004
- };
1005
- socket.on(eventName, onAck);
1006
- });
1007
- };
1008
1200
  var openStreamWatcher = async (input) => {
1009
1201
  const defaultTimeoutMs = input.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
1010
1202
  const chunks = [];
1011
1203
  const waiters = [];
1012
- const sessionCookieName = getProjectConfig2().http.sessionCookieName;
1013
- const socket = createSocketIoClient(input.baseUrl, {
1014
- transports: ["websocket"],
1015
- forceNew: true,
1016
- reconnection: false,
1017
- auth: input.token ? { token: input.token } : void 0,
1018
- extraHeaders: input.token ? { Cookie: `${sessionCookieName}=${input.token}` } : void 0
1019
- });
1204
+ const socket = await connectTestSocket({ baseUrl: input.baseUrl, token: input.token, label: LABEL2 });
1020
1205
  const handleChunk = (raw) => {
1021
1206
  if (!isStreamChunkFrame(raw)) return;
1022
1207
  if (raw.status !== "stream") return;
@@ -1032,21 +1217,10 @@ var openStreamWatcher = async (input) => {
1032
1217
  }
1033
1218
  }
1034
1219
  };
1035
- socket.on(socketEventNames.sync, handleChunk);
1036
- const [connectError] = await tryCatch4(() => waitForConnect(socket));
1037
- if (connectError) {
1038
- socket.off(socketEventNames.sync, handleChunk);
1039
- socket.disconnect();
1040
- throw connectError;
1041
- }
1042
- const responseIndex = nextResponseIndex();
1043
- socket.emit(socketEventNames.joinRoom, {
1044
- group: input.roomCode,
1045
- responseIndex
1046
- });
1047
- const [joinError] = await tryCatch4(() => waitForJoinAck(socket, responseIndex));
1220
+ socket.on(socketEventNames2.sync, handleChunk);
1221
+ const [joinError] = await tryCatch6(() => joinTestRoom(socket, input.roomCode, { label: LABEL2 }));
1048
1222
  if (joinError) {
1049
- socket.off(socketEventNames.sync, handleChunk);
1223
+ socket.off(socketEventNames2.sync, handleChunk);
1050
1224
  socket.disconnect();
1051
1225
  throw joinError;
1052
1226
  }
@@ -1054,7 +1228,7 @@ var openStreamWatcher = async (input) => {
1054
1228
  const close = () => {
1055
1229
  if (closed) return Promise.resolve();
1056
1230
  closed = true;
1057
- socket.off(socketEventNames.sync, handleChunk);
1231
+ socket.off(socketEventNames2.sync, handleChunk);
1058
1232
  for (const waiter of waiters) waiter.resolve();
1059
1233
  waiters.length = 0;
1060
1234
  socket.disconnect();
@@ -1158,7 +1332,7 @@ var buildExpect = () => ({
1158
1332
  if (!value) throw new AssertionError(message ?? `expected truthy value, got ${JSON.stringify(value)}`);
1159
1333
  },
1160
1334
  throws: async (fn, message) => {
1161
- const [err] = await tryCatch5(fn);
1335
+ const [err] = await tryCatch7(fn);
1162
1336
  if (!err) throw new AssertionError(message ?? "expected fn to throw, but it did not");
1163
1337
  return err;
1164
1338
  },
@@ -1210,7 +1384,7 @@ var buildSessionHelpers = (state) => {
1210
1384
  };
1211
1385
  };
1212
1386
  var parseResponse = async (response, state) => {
1213
- const [textError, text] = await tryCatch5(() => response.text());
1387
+ const [textError, text] = await tryCatch7(() => response.text());
1214
1388
  if (textError || text === null) {
1215
1389
  state.lastResponse = null;
1216
1390
  throw new Error(`failed to read response body (HTTP ${response.status}): ${textError?.message ?? "unknown"}`);
@@ -1226,7 +1400,7 @@ var parseResponse = async (response, state) => {
1226
1400
  };
1227
1401
  var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1228
1402
  return async (input) => {
1229
- const url = `${baseUrl.replace(/\/$/, "")}/api/${routePath}`;
1403
+ const path4 = `${baseUrl.replace(/\/$/, "")}/api/${routePath}`;
1230
1404
  const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
1231
1405
  if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
1232
1406
  if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
@@ -1235,8 +1409,10 @@ var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1235
1409
  controller.abort();
1236
1410
  }, DEFAULT_CALL_TIMEOUT_MS);
1237
1411
  const init = { method, headers, signal: controller.signal };
1238
- if (method !== "GET" && method !== "HEAD") init.body = JSON.stringify(input ?? {});
1239
- const [fetchError, response] = await tryCatch5(() => fetch(url, init));
1412
+ const bodyless = method === "GET" || method === "HEAD";
1413
+ const url = bodyless ? buildRoutedGetUrl2(path4, input ?? {}) : path4;
1414
+ if (!bodyless) init.body = JSON.stringify(input ?? {});
1415
+ const [fetchError, response] = await tryCatch7(() => fetch(url, init));
1240
1416
  clearTimeout(timeoutHandle);
1241
1417
  if (fetchError || !response) {
1242
1418
  state.lastResponse = null;
@@ -1245,18 +1421,57 @@ var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1245
1421
  return await parseResponse(response, state);
1246
1422
  };
1247
1423
  };
1248
- 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) => {
1249
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
+ }
1250
1465
  const url = `${baseUrl.replace(/\/$/, "")}/sync/${routePath}`;
1251
1466
  const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
1252
1467
  if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
1253
1468
  if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
1254
- const body = { data: input ?? {}, receiver: opts?.receiver };
1469
+ const body = { data: input ?? {}, receiver: opts?.receiver, ignoreSelf: opts?.ignoreSelf };
1255
1470
  const controller = new AbortController();
1256
1471
  const timeoutHandle = setTimeout(() => {
1257
1472
  controller.abort();
1258
1473
  }, DEFAULT_CALL_TIMEOUT_MS);
1259
- const [fetchError, response] = await tryCatch5(() => fetch(url, {
1474
+ const [fetchError, response] = await tryCatch7(() => fetch(url, {
1260
1475
  method: "POST",
1261
1476
  headers,
1262
1477
  body: JSON.stringify(body),
@@ -1287,18 +1502,20 @@ var buildContext = (discovery, input) => {
1287
1502
  watchers.push(watcher);
1288
1503
  return watcher;
1289
1504
  };
1505
+ const syncSocket = createCaseSyncSocket(input.baseUrl, cookieName);
1290
1506
  const closeAllWatchers = async () => {
1507
+ syncSocket.close();
1291
1508
  while (watchers.length > 0) {
1292
1509
  const watcher = watchers.pop();
1293
1510
  if (!watcher) continue;
1294
- const [closeError] = await tryCatch5(() => watcher.close());
1511
+ const [closeError] = await tryCatch7(() => watcher.close());
1295
1512
  if (closeError) {
1296
1513
  }
1297
1514
  }
1298
1515
  };
1299
1516
  const ctx = {
1300
1517
  callApi: buildCallApi(input.baseUrl, routePath, state, cookieName, apiMethod),
1301
- callSync: buildCallSync(input.baseUrl, routePath, state, cookieName),
1518
+ callSync: buildCallSync(input.baseUrl, routePath, state, cookieName, syncSocket),
1302
1519
  watchStream,
1303
1520
  session: buildSessionHelpers(state),
1304
1521
  //? Lazy: only resolves when a case actually touches `ctx.prisma`, so an
@@ -1333,7 +1550,7 @@ var runCustomTestsAfterEnvironmentPrepared = async (input) => {
1333
1550
  input.onResult?.(r);
1334
1551
  continue;
1335
1552
  }
1336
- const [importError, mod] = await tryCatch5(
1553
+ const [importError, mod] = await tryCatch7(
1337
1554
  () => import(pathToFileURL(resolvedPath).href)
1338
1555
  );
1339
1556
  if (importError || !mod) {
@@ -1358,11 +1575,11 @@ var runCustomTestsAfterEnvironmentPrepared = async (input) => {
1358
1575
  const expectedToFail = typeof c.expectedToFail === "string" ? c.expectedToFail : void 0;
1359
1576
  const built = buildContext(discovery, input);
1360
1577
  const started = Date.now();
1361
- const [runError] = await tryCatch5(async () => c.run(built.ctx));
1362
- await tryCatch5(() => built.closeAllWatchers());
1578
+ const [runError] = await tryCatch7(async () => c.run(built.ctx));
1579
+ await tryCatch7(() => built.closeAllWatchers());
1363
1580
  const residualToken = built.state.token;
1364
1581
  if (residualToken) {
1365
- await tryCatch5(async () => {
1582
+ await tryCatch7(async () => {
1366
1583
  const { deleteSession } = await import("@luckystack/login");
1367
1584
  await deleteSession(residualToken);
1368
1585
  });
@@ -1403,11 +1620,10 @@ var runCustomTests = async (input) => {
1403
1620
  };
1404
1621
 
1405
1622
  // src/runAllTests.ts
1406
- import { clearAllRateLimits, getCsrfConfig as getCsrfConfig2, getProjectConfig as getProjectConfig4 } from "@luckystack/core";
1407
- var inputForEndpoint = (apiInputSchemas, stringPrefix) => (endpoint) => {
1408
- const schema = apiInputSchemas[endpoint.page]?.[endpoint.name]?.[endpoint.version];
1409
- return schema ? sampleSchemaInput(schema, { stringPrefix }) : {};
1410
- };
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";
1411
1627
  var resolveResetBookendToken = (input) => {
1412
1628
  if (input.resetBookends === false) return null;
1413
1629
  const token = input.resetToken ?? process.env.TEST_RESET_TOKEN;
@@ -1419,15 +1635,6 @@ var resolveResetBookendToken = (input) => {
1419
1635
  }
1420
1636
  return token;
1421
1637
  };
1422
- var matchesFilter = (endpoint, filter) => {
1423
- if (!filter) return true;
1424
- return `${endpoint.page}/${endpoint.name}/${endpoint.version}`.includes(filter);
1425
- };
1426
- var filterResults = (results, filter) => filter ? results.filter((r) => matchesFilter(r.endpoint, filter)) : results;
1427
- var cloneSummary = (summary, filter) => {
1428
- if (!filter) return summary;
1429
- return calculateSummary(filterResults(summary.results, filter));
1430
- };
1431
1638
  var buildAuthHeaders = async (input) => {
1432
1639
  const headers = {};
1433
1640
  if (!input.authToken) return headers;
@@ -1448,70 +1655,92 @@ var buildAuthHeaders = async (input) => {
1448
1655
  }
1449
1656
  return headers;
1450
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
+ };
1451
1690
  var runSweepLayers = async (input, summary, headers, inputFor) => {
1691
+ const resetToken = input.resetToken ?? process.env.TEST_RESET_TOKEN;
1452
1692
  const resetBookendToken = resolveResetBookendToken(input);
1453
1693
  if (resetBookendToken) {
1454
- await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken });
1694
+ await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken, only: ["rateLimits"] });
1455
1695
  }
1456
- const contract = await runContractTests({
1457
- apiMethodMap: input.apiMethodMap,
1458
- baseUrl: input.baseUrl,
1459
- skip: input.skip,
1460
- headers,
1461
- inputFor
1462
- });
1463
- summary.contract = cloneSummary(contract, input.filter);
1464
- const auth = await runAuthEnforcementTests({
1696
+ const hasSyncRoutes = Object.keys(input.syncMetaMap ?? {}).length > 0;
1697
+ const sync = hasSyncRoutes ? await prepareSyncSweep(input) : null;
1698
+ const shared = {
1465
1699
  apiMethodMap: input.apiMethodMap,
1466
- apiMetaMap: input.apiMetaMap,
1700
+ syncMetaMap: input.syncMetaMap,
1467
1701
  baseUrl: input.baseUrl,
1468
1702
  skip: input.skip,
1469
- 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
+ }
1470
1738
  });
1471
- summary.auth = cloneSummary(auth, input.filter);
1472
- if (!input.noRateLimit) {
1473
- const rateLimit = await runRateLimitTests({
1474
- apiMethodMap: input.apiMethodMap,
1475
- apiMetaMap: input.apiMetaMap,
1476
- baseUrl: input.baseUrl,
1477
- skip: input.skip,
1478
- headers,
1479
- inputFor,
1480
- //? Reset the shared per-IP rate-limit bucket between endpoints so a
1481
- //? neighbour saturating the window doesn't cause false pass/fail on the
1482
- //? next endpoint. Requires the server to expose /_test/reset (NODE_ENV in
1483
- //? { 'development', 'test' } AND TEST_RESET_TOKEN set + matching header).
1484
- resetBetweenEndpoints: true,
1485
- //? Thread the reset token (explicit input, else the TEST_RESET_TOKEN env the
1486
- //? server validates against) — without it `/_test/reset` returns 403 and the
1487
- //? between-endpoint reset silently never happens.
1488
- resetToken: input.resetToken ?? process.env.TEST_RESET_TOKEN
1489
- });
1490
- summary.rateLimit = cloneSummary(rateLimit, input.filter);
1491
- }
1492
- if (!input.noCsrf && headers.Cookie) {
1493
- const csrf = await runCsrfEnforcementTests({
1494
- apiMethodMap: input.apiMethodMap,
1495
- apiMetaMap: input.apiMetaMap,
1496
- baseUrl: input.baseUrl,
1497
- authCookie: headers.Cookie,
1498
- skip: input.skip,
1499
- inputFor
1500
- });
1501
- summary.csrf = cloneSummary(csrf, input.filter);
1502
- }
1503
- if (!input.noFuzz) {
1504
- const fuzz = await runFuzzTests({
1505
- apiMethodMap: input.apiMethodMap,
1506
- baseUrl: input.baseUrl,
1507
- skip: input.skip,
1508
- headers
1509
- });
1510
- summary.fuzz = cloneSummary(fuzz, input.filter);
1511
- }
1739
+ await sync?.close();
1512
1740
  if (resetBookendToken) {
1513
- await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken });
1741
+ await resetServerState({ baseUrl: input.baseUrl, token: resetBookendToken, only: ["rateLimits"] });
1514
1742
  }
1743
+ if (layerError) throw layerError;
1515
1744
  };
1516
1745
  var runCustomLayer = async (input, summary) => {
1517
1746
  try {
@@ -1565,9 +1794,18 @@ var runAllTests = async (input) => {
1565
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."
1566
1795
  );
1567
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
+ }
1568
1802
  }
1569
1803
  const headers = await buildAuthHeaders(input);
1570
- const inputFor = inputForEndpoint(input.apiInputSchemas, createTestDataMarker());
1804
+ const inputFor = createSampleInputFor({
1805
+ apiInputSchemas: input.apiInputSchemas,
1806
+ syncInputSchemas: input.syncInputSchemas,
1807
+ stringPrefix: createTestDataMarker()
1808
+ });
1571
1809
  if (!input.noSweep) {
1572
1810
  await runSweepLayers(input, summary, headers, inputFor);
1573
1811
  }
@@ -1575,8 +1813,21 @@ var runAllTests = async (input) => {
1575
1813
  await runCustomLayer(input, summary);
1576
1814
  }
1577
1815
  computeTotals(summary);
1816
+ writeSummaryFile(summary, input.outputFile);
1578
1817
  return summary;
1579
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
+ };
1580
1831
  var useColor = (() => {
1581
1832
  if (process.env.NO_COLOR) return false;
1582
1833
  if (process.env.FORCE_COLOR) return true;
@@ -1691,13 +1942,13 @@ var resetTestExtensionsForTests = () => {
1691
1942
  };
1692
1943
 
1693
1944
  // src/runRegisteredLayers.ts
1694
- import { tryCatch as tryCatch6, tryCatchSync as tryCatchSync2 } from "@luckystack/core";
1945
+ import { tryCatch as tryCatch9, tryCatchSync as tryCatchSync3 } from "@luckystack/core";
1695
1946
  var isLoopbackHost = (host) => host === "localhost" || host === "::1" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
1696
1947
  var postWebhook = async (summary) => {
1697
1948
  const reporter2 = getTestReporter();
1698
1949
  const webhookUrl = reporter2?.webhookUrl;
1699
1950
  if (!reporter2 || !webhookUrl) return;
1700
- const [, parsedUrl] = tryCatchSync2(() => new URL(webhookUrl));
1951
+ const [, parsedUrl] = tryCatchSync3(() => new URL(webhookUrl));
1701
1952
  if (parsedUrl?.protocol === "http:" && !isLoopbackHost(parsedUrl.hostname)) {
1702
1953
  console.warn(
1703
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.`
@@ -1709,7 +1960,7 @@ var postWebhook = async (summary) => {
1709
1960
  const timeoutHandle = setTimeout(() => {
1710
1961
  controller.abort();
1711
1962
  }, 1e4);
1712
- await tryCatch6(() => fetch(webhookUrl, {
1963
+ await tryCatch9(() => fetch(webhookUrl, {
1713
1964
  method: "POST",
1714
1965
  headers,
1715
1966
  body: JSON.stringify(summary),
@@ -1719,15 +1970,25 @@ var postWebhook = async (summary) => {
1719
1970
  };
1720
1971
  var runRegisteredLayers = async (input) => {
1721
1972
  const layers2 = listTestLayers();
1722
- const endpoints = walkEndpoints(input.apiMethodMap);
1973
+ const endpoints = selectEndpoints(input);
1723
1974
  const reporter2 = getTestReporter();
1724
1975
  const skip = input.skip ?? [];
1725
1976
  const results = [];
1977
+ const inputFor = input.inputFor ?? createSampleInputFor({
1978
+ apiInputSchemas: input.apiInputSchemas,
1979
+ syncInputSchemas: input.syncInputSchemas,
1980
+ stringPrefix: createTestDataMarker()
1981
+ });
1726
1982
  for (const layer of layers2) {
1727
1983
  for (const endpoint of endpoints) {
1728
1984
  if (shouldSkip(endpoint, skip)) continue;
1729
1985
  const started = Date.now();
1730
- 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
+ }));
1731
1992
  const durationMs = Date.now() - started;
1732
1993
  const result = runError || !layerResult ? {
1733
1994
  layer: layer.name,
@@ -1744,7 +2005,7 @@ var runRegisteredLayers = async (input) => {
1744
2005
  metadata: layerResult.metadata
1745
2006
  };
1746
2007
  results.push(result);
1747
- await tryCatch6(async () => reporter2?.onResult?.(result));
2008
+ await tryCatch9(async () => reporter2?.onResult?.(result));
1748
2009
  }
1749
2010
  }
1750
2011
  const summary = {
@@ -1754,14 +2015,18 @@ var runRegisteredLayers = async (input) => {
1754
2015
  failed: results.filter((r) => !r.passed).length,
1755
2016
  results
1756
2017
  };
1757
- await tryCatch6(async () => reporter2?.onSummary?.(summary));
2018
+ await tryCatch9(async () => reporter2?.onSummary?.(summary));
1758
2019
  await postWebhook(summary);
1759
2020
  return summary;
1760
2021
  };
1761
2022
  export {
1762
2023
  LAYER_KEYS,
1763
2024
  TEST_DATA_PREFIX,
2025
+ createHttpProbeTransport,
2026
+ createSampleInputFor,
2027
+ createSocketProbeTransport,
1764
2028
  createTestDataMarker,
2029
+ defaultProbeTransport,
1765
2030
  discoverCustomTestFiles,
1766
2031
  getTestFixture,
1767
2032
  getTestReporter,
@@ -1791,6 +2056,7 @@ export {
1791
2056
  runRateLimitTests,
1792
2057
  runRegisteredLayers,
1793
2058
  sampleSchemaInput,
2059
+ sendProbe,
1794
2060
  walkEndpoints,
1795
2061
  walkSyncEndpoints
1796
2062
  };