@luckystack/test-runner 0.1.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.
package/dist/index.js ADDED
@@ -0,0 +1,1270 @@
1
+ // src/walkEndpoints.ts
2
+ var walkEndpoints = (apiMethodMap) => {
3
+ const endpoints = [];
4
+ for (const [page, nameMap] of Object.entries(apiMethodMap)) {
5
+ if (!nameMap) continue;
6
+ for (const [name, versionMap] of Object.entries(nameMap)) {
7
+ if (!versionMap) continue;
8
+ for (const [version, method] of Object.entries(versionMap)) {
9
+ if (method === void 0) continue;
10
+ endpoints.push({
11
+ page,
12
+ name,
13
+ version,
14
+ method,
15
+ fullPath: `api/${page}/${name}/${version}`
16
+ });
17
+ }
18
+ }
19
+ }
20
+ return endpoints;
21
+ };
22
+
23
+ // src/contractCheck.ts
24
+ import { tryCatch } from "@luckystack/core";
25
+ var DEFAULT_REQUEST_TIMEOUT_MS = 5e3;
26
+ var runContractCheck = async (input) => {
27
+ const { endpoint, baseUrl } = input;
28
+ const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
29
+ const body = input.inputFor ? input.inputFor(endpoint) : {};
30
+ const started = Date.now();
31
+ const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
32
+ const controller = new AbortController();
33
+ const timeoutHandle = setTimeout(() => {
34
+ controller.abort();
35
+ }, requestTimeoutMs);
36
+ const [fetchError, response] = await tryCatch(() => fetch(url, {
37
+ method: endpoint.method,
38
+ headers: {
39
+ "Content-Type": "application/json",
40
+ //? Browsers attach Origin on state-changing requests; the server's origin
41
+ //? policy fail-closes POST/PUT/DELETE without one. Send the same-origin
42
+ //? value so the request reaches the API pipeline (consumers can override).
43
+ "Origin": new URL(baseUrl).origin,
44
+ ...input.headers
45
+ },
46
+ body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
47
+ signal: controller.signal
48
+ }));
49
+ clearTimeout(timeoutHandle);
50
+ if (fetchError || !response) {
51
+ return {
52
+ endpoint,
53
+ status: "fail",
54
+ reason: fetchError?.message ?? "fetch returned no response",
55
+ durationMs: Date.now() - started
56
+ };
57
+ }
58
+ const [parseError, parsed] = await tryCatch(
59
+ async () => await response.json()
60
+ );
61
+ const durationMs = Date.now() - started;
62
+ if (parseError) {
63
+ return {
64
+ endpoint,
65
+ status: "fail",
66
+ httpStatus: response.status,
67
+ responseStatus: "unknown",
68
+ reason: `JSON parse failed: ${parseError.message}`,
69
+ durationMs
70
+ };
71
+ }
72
+ if (!parsed || parsed.status !== "success" && parsed.status !== "error") {
73
+ return {
74
+ endpoint,
75
+ status: "fail",
76
+ httpStatus: response.status,
77
+ responseStatus: "unknown",
78
+ reason: "Response missing standard `status` envelope",
79
+ durationMs
80
+ };
81
+ }
82
+ if (parsed.status === "error" && !parsed.errorCode) {
83
+ return {
84
+ endpoint,
85
+ status: "fail",
86
+ httpStatus: response.status,
87
+ responseStatus: "error",
88
+ reason: "Error response missing `errorCode`",
89
+ durationMs
90
+ };
91
+ }
92
+ return {
93
+ endpoint,
94
+ status: "pass",
95
+ httpStatus: response.status,
96
+ responseStatus: parsed.status,
97
+ errorCode: parsed.errorCode,
98
+ durationMs
99
+ };
100
+ };
101
+
102
+ // src/runContractTests.ts
103
+ var shouldSkip = (endpoint, skip) => {
104
+ if (skip.length === 0) return false;
105
+ const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
106
+ const versionless = `${endpoint.page}/${endpoint.name}`;
107
+ return skip.includes(versioned) || skip.includes(versionless);
108
+ };
109
+ var runContractTests = async (input) => {
110
+ const endpoints = walkEndpoints(input.apiMethodMap);
111
+ const skip = input.skip ?? [];
112
+ const results = [];
113
+ for (const endpoint of endpoints) {
114
+ if (shouldSkip(endpoint, skip)) {
115
+ const skipped = {
116
+ endpoint,
117
+ status: "skipped",
118
+ durationMs: 0,
119
+ reason: "Explicitly skipped"
120
+ };
121
+ results.push(skipped);
122
+ input.onResult?.(skipped);
123
+ continue;
124
+ }
125
+ const result = await runContractCheck({
126
+ endpoint,
127
+ baseUrl: input.baseUrl,
128
+ inputFor: input.inputFor,
129
+ headers: input.headers
130
+ });
131
+ results.push(result);
132
+ input.onResult?.(result);
133
+ }
134
+ return {
135
+ total: results.length,
136
+ passed: results.filter((r) => r.status === "pass").length,
137
+ failed: results.filter((r) => r.status === "fail").length,
138
+ skipped: results.filter((r) => r.status === "skipped").length,
139
+ results
140
+ };
141
+ };
142
+
143
+ // src/authEnforcementCheck.ts
144
+ import { tryCatch as tryCatch2 } from "@luckystack/core";
145
+ var DEFAULT_REQUEST_TIMEOUT_MS2 = 5e3;
146
+ var EXPECTED_ERROR_CODE = "auth.required";
147
+ var runAuthEnforcementCheck = async (input) => {
148
+ const { endpoint, baseUrl } = input;
149
+ const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
150
+ const body = input.inputFor ? input.inputFor(endpoint) : {};
151
+ const started = Date.now();
152
+ const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2;
153
+ const controller = new AbortController();
154
+ const timeoutHandle = setTimeout(() => {
155
+ controller.abort();
156
+ }, requestTimeoutMs);
157
+ const [fetchError, response] = await tryCatch2(() => fetch(url, {
158
+ method: endpoint.method,
159
+ //? No session cookie, no auth header — deliberately. Origin IS sent so the
160
+ //? server's origin policy doesn't 403 before the auth check can run.
161
+ headers: { "Content-Type": "application/json", "Origin": new URL(baseUrl).origin },
162
+ body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
163
+ signal: controller.signal
164
+ }));
165
+ clearTimeout(timeoutHandle);
166
+ if (fetchError || !response) {
167
+ return {
168
+ endpoint,
169
+ status: "fail",
170
+ reason: fetchError?.message ?? "fetch returned no response",
171
+ durationMs: Date.now() - started
172
+ };
173
+ }
174
+ const [parseError, parsed] = await tryCatch2(
175
+ async () => await response.json()
176
+ );
177
+ const durationMs = Date.now() - started;
178
+ if (parseError) {
179
+ return {
180
+ endpoint,
181
+ status: "fail",
182
+ httpStatus: response.status,
183
+ responseStatus: "unknown",
184
+ reason: `JSON parse failed: ${parseError.message}`,
185
+ durationMs
186
+ };
187
+ }
188
+ if (parsed?.status === "success") {
189
+ return {
190
+ endpoint,
191
+ status: "fail",
192
+ httpStatus: response.status,
193
+ responseStatus: "success",
194
+ reason: `auth.login endpoint returned success without a session`,
195
+ durationMs
196
+ };
197
+ }
198
+ if (parsed?.status !== "error") {
199
+ return {
200
+ endpoint,
201
+ status: "fail",
202
+ httpStatus: response.status,
203
+ responseStatus: "unknown",
204
+ reason: "Response missing standard `status` envelope",
205
+ durationMs
206
+ };
207
+ }
208
+ if (parsed.errorCode !== EXPECTED_ERROR_CODE) {
209
+ return {
210
+ endpoint,
211
+ status: "fail",
212
+ httpStatus: response.status,
213
+ responseStatus: "error",
214
+ errorCode: parsed.errorCode,
215
+ reason: `expected errorCode '${EXPECTED_ERROR_CODE}' but got '${parsed.errorCode ?? "(missing)"}'`,
216
+ durationMs
217
+ };
218
+ }
219
+ return {
220
+ endpoint,
221
+ status: "pass",
222
+ httpStatus: response.status,
223
+ responseStatus: "error",
224
+ errorCode: parsed.errorCode,
225
+ durationMs
226
+ };
227
+ };
228
+
229
+ // src/runAuthEnforcementTests.ts
230
+ var shouldSkip2 = (endpoint, skip) => {
231
+ if (skip.length === 0) return false;
232
+ const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
233
+ const versionless = `${endpoint.page}/${endpoint.name}`;
234
+ return skip.includes(versioned) || skip.includes(versionless);
235
+ };
236
+ var requiresLogin = (apiMetaMap, endpoint) => {
237
+ const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
238
+ return meta?.auth.login ?? false;
239
+ };
240
+ var runAuthEnforcementTests = async (input) => {
241
+ const endpoints = walkEndpoints(input.apiMethodMap);
242
+ const skip = input.skip ?? [];
243
+ const results = [];
244
+ for (const endpoint of endpoints) {
245
+ if (!requiresLogin(input.apiMetaMap, endpoint)) {
246
+ continue;
247
+ }
248
+ if (shouldSkip2(endpoint, skip)) {
249
+ const skipped = {
250
+ endpoint,
251
+ status: "skipped",
252
+ durationMs: 0,
253
+ reason: "Explicitly skipped"
254
+ };
255
+ results.push(skipped);
256
+ input.onResult?.(skipped);
257
+ continue;
258
+ }
259
+ const result = await runAuthEnforcementCheck({
260
+ endpoint,
261
+ baseUrl: input.baseUrl,
262
+ inputFor: input.inputFor
263
+ });
264
+ results.push(result);
265
+ input.onResult?.(result);
266
+ }
267
+ return {
268
+ total: results.length,
269
+ passed: results.filter((r) => r.status === "pass").length,
270
+ failed: results.filter((r) => r.status === "fail").length,
271
+ skipped: results.filter((r) => r.status === "skipped").length,
272
+ results
273
+ };
274
+ };
275
+
276
+ // src/rateLimitCheck.ts
277
+ import { tryCatch as tryCatch3 } from "@luckystack/core";
278
+ var DEFAULT_REQUEST_TIMEOUT_MS3 = 1e4;
279
+ var EXPECTED_ERROR_CODE2 = "api.rateLimitExceeded";
280
+ var runRateLimitCheck = async (input) => {
281
+ const { endpoint, baseUrl, rateLimit } = input;
282
+ const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
283
+ const body = input.inputFor ? input.inputFor(endpoint) : {};
284
+ const started = Date.now();
285
+ const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS3;
286
+ const send = async () => {
287
+ const controller = new AbortController();
288
+ const timeoutHandle = setTimeout(() => {
289
+ controller.abort();
290
+ }, requestTimeoutMs);
291
+ const [error, response] = await tryCatch3(() => fetch(url, {
292
+ method: endpoint.method,
293
+ headers: {
294
+ "Content-Type": "application/json",
295
+ "Origin": new URL(baseUrl).origin,
296
+ ...input.headers
297
+ },
298
+ body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
299
+ signal: controller.signal
300
+ }));
301
+ clearTimeout(timeoutHandle);
302
+ if (error || !response) return null;
303
+ return response;
304
+ };
305
+ for (let i = 0; i < rateLimit; i += 1) {
306
+ await send();
307
+ }
308
+ const final = await send();
309
+ const durationMs = Date.now() - started;
310
+ if (!final) {
311
+ return {
312
+ endpoint,
313
+ status: "fail",
314
+ reason: "Final rate-limit probe request failed to return a response",
315
+ durationMs
316
+ };
317
+ }
318
+ const [, parsed] = await tryCatch3(
319
+ async () => await final.json()
320
+ );
321
+ if (parsed?.status === "error" && parsed.errorCode === EXPECTED_ERROR_CODE2) {
322
+ return {
323
+ endpoint,
324
+ status: "pass",
325
+ httpStatus: final.status,
326
+ responseStatus: "error",
327
+ errorCode: parsed.errorCode,
328
+ durationMs
329
+ };
330
+ }
331
+ return {
332
+ endpoint,
333
+ status: "fail",
334
+ httpStatus: final.status,
335
+ responseStatus: parsed?.status ?? "unknown",
336
+ errorCode: parsed?.errorCode,
337
+ reason: `expected '${EXPECTED_ERROR_CODE2}' on request ${rateLimit + 1} but got ${parsed?.status ?? "none"}/${parsed?.errorCode ?? "(none)"}`,
338
+ durationMs
339
+ };
340
+ };
341
+
342
+ // src/resetServerState.ts
343
+ import { tryCatch as tryCatch4 } from "@luckystack/core";
344
+ var resetServerState = async ({ baseUrl, token }) => {
345
+ const url = `${baseUrl.replace(/\/$/, "")}/_test/reset`;
346
+ const headers = { "Content-Type": "application/json" };
347
+ if (token) headers["X-Test-Reset-Token"] = token;
348
+ const [error, response] = await tryCatch4(() => fetch(url, { method: "POST", headers }));
349
+ if (error || !response) return false;
350
+ return response.ok;
351
+ };
352
+
353
+ // src/runRateLimitTests.ts
354
+ var getRateLimit = (apiMetaMap, endpoint) => {
355
+ const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
356
+ if (!meta || meta.rateLimit === false || meta.rateLimit === void 0) return null;
357
+ return meta.rateLimit;
358
+ };
359
+ var requiresLogin2 = (apiMetaMap, endpoint) => {
360
+ const meta = apiMetaMap[endpoint.page]?.[endpoint.name]?.[endpoint.version];
361
+ return meta?.auth.login ?? false;
362
+ };
363
+ var shouldSkip3 = (endpoint, skip) => {
364
+ if (skip.length === 0) return false;
365
+ const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
366
+ const versionless = `${endpoint.page}/${endpoint.name}`;
367
+ return skip.includes(versioned) || skip.includes(versionless);
368
+ };
369
+ var runRateLimitTests = async (input) => {
370
+ const endpoints = walkEndpoints(input.apiMethodMap);
371
+ const skip = input.skip ?? [];
372
+ const maxRateLimit = input.maxRateLimitToTest ?? 50;
373
+ const isAuthenticatedSweep = Boolean(input.headers?.Cookie);
374
+ const results = [];
375
+ for (const endpoint of endpoints) {
376
+ const rateLimit = getRateLimit(input.apiMetaMap, endpoint);
377
+ if (rateLimit === null) continue;
378
+ if (!isAuthenticatedSweep && requiresLogin2(input.apiMetaMap, endpoint)) {
379
+ const skipped = {
380
+ endpoint,
381
+ status: "skipped",
382
+ durationMs: 0,
383
+ reason: "login-required route \u2014 set TEST_AUTH_TOKEN to rate-limit-test it (unauthenticated calls hit auth.required before the limiter)"
384
+ };
385
+ results.push(skipped);
386
+ input.onResult?.(skipped);
387
+ continue;
388
+ }
389
+ if (rateLimit > maxRateLimit) {
390
+ const skipped = {
391
+ endpoint,
392
+ status: "skipped",
393
+ durationMs: 0,
394
+ reason: `rateLimit ${rateLimit} exceeds maxRateLimitToTest=${maxRateLimit}`
395
+ };
396
+ results.push(skipped);
397
+ input.onResult?.(skipped);
398
+ continue;
399
+ }
400
+ if (shouldSkip3(endpoint, skip)) {
401
+ const skipped = {
402
+ endpoint,
403
+ status: "skipped",
404
+ durationMs: 0,
405
+ reason: "Explicitly skipped"
406
+ };
407
+ results.push(skipped);
408
+ input.onResult?.(skipped);
409
+ continue;
410
+ }
411
+ if (input.resetBetweenEndpoints) {
412
+ await resetServerState({ baseUrl: input.baseUrl, token: input.resetToken });
413
+ }
414
+ const result = await runRateLimitCheck({
415
+ endpoint,
416
+ baseUrl: input.baseUrl,
417
+ rateLimit,
418
+ inputFor: input.inputFor,
419
+ headers: input.headers
420
+ });
421
+ results.push(result);
422
+ input.onResult?.(result);
423
+ }
424
+ return {
425
+ total: results.length,
426
+ passed: results.filter((r) => r.status === "pass").length,
427
+ failed: results.filter((r) => r.status === "fail").length,
428
+ skipped: results.filter((r) => r.status === "skipped").length,
429
+ results
430
+ };
431
+ };
432
+
433
+ // src/fuzzCheck.ts
434
+ import { tryCatch as tryCatch5 } from "@luckystack/core";
435
+ var DEFAULT_REQUEST_TIMEOUT_MS4 = 5e3;
436
+ var JUNK_PAYLOADS = [
437
+ null,
438
+ [],
439
+ [1, 2, 3],
440
+ "string-instead-of-object",
441
+ 1234567890,
442
+ true,
443
+ { nested: { deeply: { nested: { value: "x".repeat(1e4) } } } },
444
+ { __proto__: { polluted: true } },
445
+ { key: null, other: void 0, third: Number.NaN }
446
+ ];
447
+ var probe = async (endpoint, baseUrl, body, headers, requestTimeoutMs) => {
448
+ const url = `${baseUrl.replace(/\/$/, "")}/${endpoint.fullPath}`;
449
+ const controller = new AbortController();
450
+ const timeoutHandle = setTimeout(() => {
451
+ controller.abort();
452
+ }, requestTimeoutMs);
453
+ const [fetchError, response] = await tryCatch5(() => fetch(url, {
454
+ method: endpoint.method,
455
+ headers: { "Content-Type": "application/json", "Origin": new URL(baseUrl).origin, ...headers },
456
+ body: endpoint.method === "GET" ? void 0 : JSON.stringify(body),
457
+ signal: controller.signal
458
+ }));
459
+ clearTimeout(timeoutHandle);
460
+ if (fetchError || !response) return null;
461
+ const [, parsed] = await tryCatch5(
462
+ async () => await response.json()
463
+ );
464
+ return { httpStatus: response.status, parsed: parsed ?? null };
465
+ };
466
+ var runFuzzCheck = async (input) => {
467
+ const started = Date.now();
468
+ const requestTimeoutMs = input.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS4;
469
+ for (const payload of JUNK_PAYLOADS) {
470
+ const result = await probe(input.endpoint, input.baseUrl, payload, input.headers, requestTimeoutMs);
471
+ const durationMs = Date.now() - started;
472
+ if (!result) {
473
+ return {
474
+ endpoint: input.endpoint,
475
+ status: "fail",
476
+ reason: `fuzz probe crashed (no response) with payload: ${JSON.stringify(payload).slice(0, 80)}`,
477
+ durationMs
478
+ };
479
+ }
480
+ if (!result.parsed || result.parsed.status !== "success" && result.parsed.status !== "error") {
481
+ return {
482
+ endpoint: input.endpoint,
483
+ status: "fail",
484
+ httpStatus: result.httpStatus,
485
+ reason: `fuzz payload produced non-envelope response: ${JSON.stringify(payload).slice(0, 80)}`,
486
+ durationMs
487
+ };
488
+ }
489
+ if (result.parsed.status === "error" && typeof result.parsed.errorCode !== "string") {
490
+ return {
491
+ endpoint: input.endpoint,
492
+ status: "fail",
493
+ httpStatus: result.httpStatus,
494
+ reason: `fuzz error response missing errorCode for payload: ${JSON.stringify(payload).slice(0, 80)}`,
495
+ durationMs
496
+ };
497
+ }
498
+ }
499
+ return {
500
+ endpoint: input.endpoint,
501
+ status: "pass",
502
+ durationMs: Date.now() - started
503
+ };
504
+ };
505
+
506
+ // src/runFuzzTests.ts
507
+ var shouldSkip4 = (endpoint, skip) => {
508
+ if (skip.length === 0) return false;
509
+ const versioned = `${endpoint.page}/${endpoint.name}/${endpoint.version}`;
510
+ const versionless = `${endpoint.page}/${endpoint.name}`;
511
+ return skip.includes(versioned) || skip.includes(versionless);
512
+ };
513
+ var runFuzzTests = async (input) => {
514
+ const endpoints = walkEndpoints(input.apiMethodMap);
515
+ const skip = input.skip ?? [];
516
+ const results = [];
517
+ for (const endpoint of endpoints) {
518
+ if (shouldSkip4(endpoint, skip)) {
519
+ const skipped = {
520
+ endpoint,
521
+ status: "skipped",
522
+ durationMs: 0,
523
+ reason: "Explicitly skipped"
524
+ };
525
+ results.push(skipped);
526
+ input.onResult?.(skipped);
527
+ continue;
528
+ }
529
+ const result = await runFuzzCheck({
530
+ endpoint,
531
+ baseUrl: input.baseUrl,
532
+ headers: input.headers
533
+ });
534
+ results.push(result);
535
+ input.onResult?.(result);
536
+ }
537
+ return {
538
+ total: results.length,
539
+ passed: results.filter((r) => r.status === "pass").length,
540
+ failed: results.filter((r) => r.status === "fail").length,
541
+ skipped: results.filter((r) => r.status === "skipped").length,
542
+ results
543
+ };
544
+ };
545
+
546
+ // src/schemaSampleInput.ts
547
+ var sampleForDef = (def) => {
548
+ const d = def;
549
+ const name = d.typeName ?? d.type;
550
+ switch (name) {
551
+ case "ZodString":
552
+ case "string": {
553
+ return "test";
554
+ }
555
+ case "ZodNumber":
556
+ case "number": {
557
+ return 0;
558
+ }
559
+ case "ZodBoolean":
560
+ case "boolean": {
561
+ return false;
562
+ }
563
+ case "ZodNull":
564
+ case "null": {
565
+ return null;
566
+ }
567
+ case "ZodUndefined":
568
+ case "undefined": {
569
+ return void 0;
570
+ }
571
+ case "ZodAny":
572
+ case "any":
573
+ case "ZodUnknown":
574
+ case "unknown": {
575
+ return null;
576
+ }
577
+ case "ZodLiteral":
578
+ case "literal": {
579
+ return d.value ?? d.values?.[0];
580
+ }
581
+ case "ZodOptional":
582
+ case "optional":
583
+ case "ZodNullable":
584
+ case "nullable": {
585
+ return void 0;
586
+ }
587
+ case "ZodUnion":
588
+ case "union": {
589
+ return d.options && d.options.length > 0 && d.options[0] ? sampleForDef(d.options[0]._def) : null;
590
+ }
591
+ case "ZodArray":
592
+ case "array": {
593
+ return [];
594
+ }
595
+ case "ZodObject":
596
+ case "object": {
597
+ const out = {};
598
+ const shape = typeof d.shape === "function" ? d.shape() : d.shape ?? {};
599
+ for (const [key, valueSchema] of Object.entries(shape)) {
600
+ const sample = sampleForDef(valueSchema._def);
601
+ if (sample !== void 0) out[key] = sample;
602
+ }
603
+ return out;
604
+ }
605
+ case "ZodRecord":
606
+ case "record": {
607
+ return {};
608
+ }
609
+ case "ZodDate":
610
+ case "date": {
611
+ return (/* @__PURE__ */ new Date()).toISOString();
612
+ }
613
+ default: {
614
+ return null;
615
+ }
616
+ }
617
+ };
618
+ var sampleSchemaInput = (schema) => {
619
+ return sampleForDef(schema.def);
620
+ };
621
+
622
+ // src/reporter.ts
623
+ var statusIcon = (status) => {
624
+ switch (status) {
625
+ case "pass": {
626
+ return "[PASS]";
627
+ }
628
+ case "fail": {
629
+ return "[FAIL]";
630
+ }
631
+ case "skipped": {
632
+ return "[SKIP]";
633
+ }
634
+ }
635
+ };
636
+ var logContractResult = (result) => {
637
+ const { endpoint, durationMs, httpStatus, responseStatus, errorCode, reason } = result;
638
+ const icon = statusIcon(result.status);
639
+ const core = `${icon} ${endpoint.method} /${endpoint.fullPath} ${durationMs}ms`;
640
+ if (result.status === "pass") {
641
+ const tail = responseStatus === "error" ? ` (error: ${errorCode ?? "unknown"})` : "";
642
+ console.log(`${core} http=${httpStatus ?? "-"}${tail}`);
643
+ return;
644
+ }
645
+ if (result.status === "skipped") {
646
+ console.log(`${core} ${reason ?? ""}`);
647
+ return;
648
+ }
649
+ console.log(`${core} http=${httpStatus ?? "-"} reason=${reason ?? "(none)"}`);
650
+ };
651
+ var logContractSummary = (summary) => {
652
+ const { total, passed, failed, skipped } = summary;
653
+ console.log("");
654
+ console.log(`Contract tests: ${passed}/${total} passed, ${failed} failed, ${skipped} skipped`);
655
+ if (failed > 0) {
656
+ console.log("");
657
+ console.log("Failures:");
658
+ for (const result of summary.results) {
659
+ if (result.status !== "fail") continue;
660
+ console.log(` - /${result.endpoint.fullPath}: ${result.reason ?? "(no reason)"}`);
661
+ }
662
+ }
663
+ };
664
+
665
+ // src/customTests.ts
666
+ import fs from "fs";
667
+ import path from "path";
668
+ import { pathToFileURL } from "url";
669
+ import { getCsrfConfig, getPrismaClient, getProjectConfig, getSrcDir, tryCatch as tryCatch7 } from "@luckystack/core";
670
+
671
+ // src/streamWatcher.ts
672
+ import { io as createSocketIoClient } from "socket.io-client";
673
+ import {
674
+ buildJoinRoomResponseEventName,
675
+ socketEventNames,
676
+ tryCatch as tryCatch6
677
+ } from "@luckystack/core";
678
+ var DEFAULT_TIMEOUT_MS = 5e3;
679
+ var JOIN_RESPONSE_TIMEOUT_MS = 3e3;
680
+ var CONNECT_TIMEOUT_MS = 3e3;
681
+ var isJoinRoomResponse = (value) => typeof value === "object" && value !== null;
682
+ var isStreamChunkFrame = (value) => typeof value === "object" && value !== null;
683
+ var responseIndexCounter = 0;
684
+ var nextResponseIndex = () => {
685
+ responseIndexCounter += 1;
686
+ return responseIndexCounter;
687
+ };
688
+ var waitForConnect = (socket) => {
689
+ return new Promise((resolve, reject) => {
690
+ if (socket.connected) {
691
+ resolve();
692
+ return;
693
+ }
694
+ const timer = setTimeout(() => {
695
+ cleanup();
696
+ reject(new Error(`watchStream: socket failed to connect within ${CONNECT_TIMEOUT_MS}ms`));
697
+ }, CONNECT_TIMEOUT_MS);
698
+ const onConnect = () => {
699
+ cleanup();
700
+ resolve();
701
+ };
702
+ const onConnectError = (err) => {
703
+ cleanup();
704
+ reject(new Error(`watchStream: connect_error \u2014 ${err.message}`));
705
+ };
706
+ const cleanup = () => {
707
+ clearTimeout(timer);
708
+ socket.off("connect", onConnect);
709
+ socket.off("connect_error", onConnectError);
710
+ };
711
+ socket.on("connect", onConnect);
712
+ socket.on("connect_error", onConnectError);
713
+ });
714
+ };
715
+ var waitForJoinAck = (socket, responseIndex) => {
716
+ const eventName = buildJoinRoomResponseEventName(responseIndex);
717
+ return new Promise((resolve, reject) => {
718
+ const timer = setTimeout(() => {
719
+ socket.off(eventName, onAck);
720
+ reject(new Error(`watchStream: joinRoom ack timeout after ${JOIN_RESPONSE_TIMEOUT_MS}ms`));
721
+ }, JOIN_RESPONSE_TIMEOUT_MS);
722
+ const onAck = (payload) => {
723
+ clearTimeout(timer);
724
+ socket.off(eventName, onAck);
725
+ if (isJoinRoomResponse(payload) && payload.status === "error") {
726
+ reject(new Error(`watchStream: joinRoom rejected \u2014 ${payload.errorCode ?? "unknown"}`));
727
+ return;
728
+ }
729
+ resolve();
730
+ };
731
+ socket.on(eventName, onAck);
732
+ });
733
+ };
734
+ var openStreamWatcher = async (input) => {
735
+ const defaultTimeoutMs = input.defaultTimeoutMs ?? DEFAULT_TIMEOUT_MS;
736
+ const chunks = [];
737
+ const waiters = [];
738
+ const socket = createSocketIoClient(input.baseUrl, {
739
+ transports: ["websocket"],
740
+ forceNew: true,
741
+ reconnection: false,
742
+ auth: input.token ? { token: input.token } : void 0
743
+ });
744
+ const handleChunk = (raw) => {
745
+ if (!isStreamChunkFrame(raw)) return;
746
+ if (raw.status !== "stream") return;
747
+ if (raw.fullName !== input.routeFullName) return;
748
+ const chunk = raw;
749
+ chunks.push(chunk);
750
+ for (let i = waiters.length - 1; i >= 0; i--) {
751
+ const waiter = waiters[i];
752
+ if (!waiter) continue;
753
+ if (waiter.predicate(chunks)) {
754
+ waiters.splice(i, 1);
755
+ waiter.resolve();
756
+ }
757
+ }
758
+ };
759
+ socket.on(socketEventNames.sync, handleChunk);
760
+ await waitForConnect(socket);
761
+ const responseIndex = nextResponseIndex();
762
+ socket.emit(socketEventNames.joinRoom, {
763
+ group: input.roomCode,
764
+ responseIndex
765
+ });
766
+ const [joinError] = await tryCatch6(() => waitForJoinAck(socket, responseIndex));
767
+ if (joinError) {
768
+ socket.off(socketEventNames.sync, handleChunk);
769
+ socket.disconnect();
770
+ throw joinError;
771
+ }
772
+ let closed = false;
773
+ const close = () => {
774
+ if (closed) return Promise.resolve();
775
+ closed = true;
776
+ socket.off(socketEventNames.sync, handleChunk);
777
+ for (const waiter of waiters) waiter.resolve();
778
+ waiters.length = 0;
779
+ socket.disconnect();
780
+ return Promise.resolve();
781
+ };
782
+ const stopAt = (predicate, timeoutMs = defaultTimeoutMs) => {
783
+ if (predicate(chunks)) return Promise.resolve();
784
+ return new Promise((resolve, reject) => {
785
+ let settled = false;
786
+ const waiter = {
787
+ predicate,
788
+ resolve: () => {
789
+ if (settled) return;
790
+ settled = true;
791
+ clearTimeout(timer);
792
+ resolve();
793
+ }
794
+ };
795
+ const timer = setTimeout(() => {
796
+ if (settled) return;
797
+ settled = true;
798
+ const idx = waiters.indexOf(waiter);
799
+ if (idx !== -1) waiters.splice(idx, 1);
800
+ reject(new Error(
801
+ `watchStream.stopAt: predicate not satisfied within ${timeoutMs}ms (received ${chunks.length} chunks)`
802
+ ));
803
+ }, timeoutMs);
804
+ waiters.push(waiter);
805
+ });
806
+ };
807
+ const waitForCount = (n, timeoutMs = defaultTimeoutMs) => {
808
+ return stopAt((current) => current.length >= n, timeoutMs);
809
+ };
810
+ return {
811
+ chunks,
812
+ stopAt,
813
+ waitForCount,
814
+ close
815
+ };
816
+ };
817
+
818
+ // src/customTests.ts
819
+ var API_TEST_FILE_PATTERN = /_v(\d+)\.tests\.ts$/;
820
+ var SYNC_SERVER_TEST_FILE_PATTERN = /_server_v(\d+)\.tests\.ts$/;
821
+ var discoverCustomTestFiles = (srcDir = getSrcDir()) => {
822
+ if (!fs.existsSync(srcDir)) return [];
823
+ const found = [];
824
+ const stack = [srcDir];
825
+ while (stack.length > 0) {
826
+ const current = stack.pop();
827
+ if (current === void 0) break;
828
+ const entries = fs.readdirSync(current, { withFileTypes: true });
829
+ for (const entry of entries) {
830
+ const fullPath = path.join(current, entry.name);
831
+ if (entry.isDirectory()) {
832
+ stack.push(fullPath);
833
+ continue;
834
+ }
835
+ if (!entry.isFile() || !entry.name.endsWith(".tests.ts")) continue;
836
+ const parentDir = path.basename(current);
837
+ if (parentDir !== "_api" && parentDir !== "_sync") continue;
838
+ const pattern = parentDir === "_api" ? API_TEST_FILE_PATTERN : SYNC_SERVER_TEST_FILE_PATTERN;
839
+ const versionMatch = pattern.exec(entry.name);
840
+ if (!versionMatch) continue;
841
+ const version = `v${versionMatch[1]}`;
842
+ const namePart = entry.name.replace(parentDir === "_api" ? /_v\d+\.tests\.ts$/ : /_server_v\d+\.tests\.ts$/, "");
843
+ const pageDir = path.dirname(current);
844
+ const relativePage = path.relative(srcDir, pageDir).replaceAll("\\", "/") || "system";
845
+ found.push({
846
+ filePath: fullPath,
847
+ kind: parentDir === "_api" ? "api" : "sync",
848
+ route: { page: relativePage, name: namePart, version }
849
+ });
850
+ }
851
+ }
852
+ return found.toSorted((a, b) => a.filePath.localeCompare(b.filePath));
853
+ };
854
+ var AssertionError = class extends Error {
855
+ constructor(message) {
856
+ super(message);
857
+ this.name = "AssertionError";
858
+ }
859
+ };
860
+ var buildExpect = () => ({
861
+ eq: (actual, expected, message) => {
862
+ const ok = Object.is(actual, expected) || typeof actual === "object" && typeof expected === "object" && JSON.stringify(actual) === JSON.stringify(expected);
863
+ if (!ok) {
864
+ const prefix = message ? `${message}: ` : "";
865
+ throw new AssertionError(`${prefix}expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`);
866
+ }
867
+ },
868
+ ok: (value, message) => {
869
+ if (!value) throw new AssertionError(message ?? `expected truthy value, got ${JSON.stringify(value)}`);
870
+ },
871
+ throws: async (fn, message) => {
872
+ const [err] = await tryCatch7(fn);
873
+ if (!err) throw new AssertionError(message ?? "expected fn to throw, but it did not");
874
+ return err;
875
+ },
876
+ matches: (value, pattern, message) => {
877
+ if (!pattern.test(value)) {
878
+ const prefix = message ? `${message}: ` : "";
879
+ throw new AssertionError(`${prefix}"${value}" does not match ${pattern}`);
880
+ }
881
+ }
882
+ });
883
+ var extractErrorCode = (response) => {
884
+ if (!response || typeof response !== "object") return void 0;
885
+ const r = response;
886
+ if (typeof r.errorCode === "string" && r.errorCode.length > 0) return r.errorCode;
887
+ if (typeof r.status === "string" && r.status !== "success") return r.status;
888
+ return void 0;
889
+ };
890
+ var buildSessionHelpers = (state) => {
891
+ return {
892
+ login: async (user) => {
893
+ const { saveSession, getSession } = await import("@luckystack/login");
894
+ const userId = user?.id ?? `test-user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
895
+ const email = user?.email ?? `test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}@example.com`;
896
+ const name = user?.name ?? "Test User";
897
+ const token = `test-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
898
+ const sessionData = {
899
+ id: userId,
900
+ email,
901
+ name,
902
+ token,
903
+ provider: "credentials"
904
+ };
905
+ await saveSession(token, sessionData, true);
906
+ state.token = token;
907
+ state.userId = userId;
908
+ const persisted = await getSession(token);
909
+ state.csrfToken = persisted?.csrfToken ?? null;
910
+ return { token, userId };
911
+ },
912
+ logout: async () => {
913
+ if (!state.token) return;
914
+ const { deleteSession } = await import("@luckystack/login");
915
+ await deleteSession(state.token);
916
+ state.token = null;
917
+ state.userId = null;
918
+ state.csrfToken = null;
919
+ },
920
+ current: () => ({ token: state.token, userId: state.userId })
921
+ };
922
+ };
923
+ var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
924
+ return async (input) => {
925
+ const url = `${baseUrl.replace(/\/$/, "")}/api/${routePath}`;
926
+ const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
927
+ if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
928
+ if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
929
+ const init = { method, headers };
930
+ if (method !== "GET" && method !== "HEAD") init.body = JSON.stringify(input ?? {});
931
+ const response = await fetch(url, init);
932
+ const json = await response.json();
933
+ state.lastResponse = json;
934
+ return json;
935
+ };
936
+ };
937
+ var buildCallSync = (baseUrl, routePath, state, cookieName) => {
938
+ return async (input, opts) => {
939
+ const url = `${baseUrl.replace(/\/$/, "")}/sync/${routePath}`;
940
+ const headers = { "Content-Type": "application/json", Origin: new URL(baseUrl).origin };
941
+ if (state.token) headers.Cookie = `${cookieName}=${state.token}`;
942
+ if (state.csrfToken) headers[getCsrfConfig().headerName] = state.csrfToken;
943
+ const body = { data: input ?? {}, receiver: opts?.receiver };
944
+ const response = await fetch(url, {
945
+ method: "POST",
946
+ headers,
947
+ body: JSON.stringify(body)
948
+ });
949
+ const json = await response.json();
950
+ state.lastResponse = json;
951
+ return json;
952
+ };
953
+ };
954
+ var buildContext = (discovery, input) => {
955
+ const cookieName = input.sessionCookieName ?? getProjectConfig().http.sessionCookieName;
956
+ const routePath = `${discovery.route.page}/${discovery.route.name}/${discovery.route.version}`;
957
+ const routeFullName = `sync/${routePath}`;
958
+ const apiMethod = input.apiMethodMap?.[discovery.route.page]?.[discovery.route.name]?.[discovery.route.version] ?? "POST";
959
+ const state = { token: null, userId: null, csrfToken: null, lastResponse: null };
960
+ const prisma = getPrismaClient();
961
+ const watchers = [];
962
+ const watchStream = async (roomCode) => {
963
+ const watcher = await openStreamWatcher({
964
+ baseUrl: input.baseUrl,
965
+ roomCode,
966
+ token: state.token,
967
+ routeFullName
968
+ });
969
+ watchers.push(watcher);
970
+ return watcher;
971
+ };
972
+ const closeAllWatchers = async () => {
973
+ while (watchers.length > 0) {
974
+ const watcher = watchers.pop();
975
+ if (!watcher) continue;
976
+ const [closeError] = await tryCatch7(() => watcher.close());
977
+ if (closeError) {
978
+ }
979
+ }
980
+ };
981
+ const ctx = {
982
+ callApi: buildCallApi(input.baseUrl, routePath, state, cookieName, apiMethod),
983
+ callSync: buildCallSync(input.baseUrl, routePath, state, cookieName),
984
+ watchStream,
985
+ session: buildSessionHelpers(state),
986
+ prisma,
987
+ expect: buildExpect()
988
+ };
989
+ return { ctx, closeAllWatchers, state };
990
+ };
991
+ var runCustomTests = async (input) => {
992
+ const files = discoverCustomTestFiles();
993
+ const results = [];
994
+ for (const discovery of files) {
995
+ const routePath = `${discovery.route.page}/${discovery.route.name}/${discovery.route.version}`;
996
+ if (input.filter && !routePath.includes(input.filter)) continue;
997
+ const [importError, mod] = await tryCatch7(
998
+ () => import(pathToFileURL(discovery.filePath).href)
999
+ );
1000
+ if (importError || !mod) {
1001
+ const r = {
1002
+ routePath,
1003
+ caseName: "(import)",
1004
+ status: "fail",
1005
+ durationMs: 0,
1006
+ reason: `failed to import ${discovery.filePath}: ${importError?.message ?? "unknown"}`
1007
+ };
1008
+ results.push(r);
1009
+ input.onResult?.(r);
1010
+ continue;
1011
+ }
1012
+ const exported = mod.customTests;
1013
+ if (!Array.isArray(exported) || exported.length === 0) {
1014
+ continue;
1015
+ }
1016
+ for (const rawCase of exported) {
1017
+ const c = rawCase;
1018
+ if (typeof c.name !== "string" || typeof c.run !== "function") continue;
1019
+ const expectedToFail = typeof c.expectedToFail === "string" ? c.expectedToFail : void 0;
1020
+ const built = buildContext(discovery, input);
1021
+ const started = Date.now();
1022
+ const [runError] = await tryCatch7(async () => c.run(built.ctx));
1023
+ await tryCatch7(() => built.closeAllWatchers());
1024
+ const durationMs = Date.now() - started;
1025
+ let r;
1026
+ if (runError) {
1027
+ const errorCode = extractErrorCode(built.state.lastResponse);
1028
+ const reason = errorCode ? `${runError.message} (server: ${errorCode})` : runError.message;
1029
+ r = expectedToFail ? { routePath, caseName: c.name, status: "xfail", durationMs, reason, expectedToFail } : { routePath, caseName: c.name, status: "fail", durationMs, reason };
1030
+ } else {
1031
+ r = expectedToFail ? {
1032
+ routePath,
1033
+ caseName: c.name,
1034
+ status: "xpass",
1035
+ durationMs,
1036
+ reason: `marked expectedToFail ("${expectedToFail}") but passed \u2014 remove the marker`,
1037
+ expectedToFail
1038
+ } : { routePath, caseName: c.name, status: "pass", durationMs };
1039
+ }
1040
+ results.push(r);
1041
+ input.onResult?.(r);
1042
+ }
1043
+ }
1044
+ return {
1045
+ total: results.length,
1046
+ passed: results.filter((r) => r.status === "pass").length,
1047
+ failed: results.filter((r) => r.status === "fail").length,
1048
+ xfailed: results.filter((r) => r.status === "xfail").length,
1049
+ xpassed: results.filter((r) => r.status === "xpass").length,
1050
+ results
1051
+ };
1052
+ };
1053
+
1054
+ // src/runAllTests.ts
1055
+ import { clearAllRateLimits, getProjectConfig as getProjectConfig2 } from "@luckystack/core";
1056
+ var inputForEndpoint = (apiInputSchemas) => (endpoint) => {
1057
+ const schema = apiInputSchemas[endpoint.page]?.[endpoint.name]?.[endpoint.version];
1058
+ return schema ? sampleSchemaInput(schema) : {};
1059
+ };
1060
+ var matchesFilter = (endpoint, filter) => {
1061
+ if (!filter) return true;
1062
+ return `${endpoint.page}/${endpoint.name}/${endpoint.version}`.includes(filter);
1063
+ };
1064
+ var filterResults = (results, filter) => filter ? results.filter((r) => matchesFilter(r.endpoint, filter)) : results;
1065
+ var cloneSummary = (summary, filter) => {
1066
+ if (!filter) return summary;
1067
+ const results = filterResults(summary.results, filter);
1068
+ return {
1069
+ total: results.length,
1070
+ passed: results.filter((r) => r.status === "pass").length,
1071
+ failed: results.filter((r) => r.status === "fail").length,
1072
+ skipped: results.filter((r) => r.status === "skipped").length,
1073
+ results
1074
+ };
1075
+ };
1076
+ var runAllTests = async (input) => {
1077
+ const summary = { totalPassed: 0, totalFailed: 0 };
1078
+ const headers = {};
1079
+ if (input.authToken) {
1080
+ const cookieName = input.sessionCookieName ?? getProjectConfig2().http.sessionCookieName;
1081
+ headers.Cookie = `${cookieName}=${input.authToken}`;
1082
+ }
1083
+ const inputFor = inputForEndpoint(input.apiInputSchemas);
1084
+ if (!input.noSweep) {
1085
+ const contract = await runContractTests({
1086
+ apiMethodMap: input.apiMethodMap,
1087
+ baseUrl: input.baseUrl,
1088
+ skip: input.skip,
1089
+ headers,
1090
+ inputFor
1091
+ });
1092
+ summary.contract = cloneSummary(contract, input.filter);
1093
+ const auth = await runAuthEnforcementTests({
1094
+ apiMethodMap: input.apiMethodMap,
1095
+ apiMetaMap: input.apiMetaMap,
1096
+ baseUrl: input.baseUrl,
1097
+ skip: input.skip,
1098
+ inputFor
1099
+ });
1100
+ summary.auth = cloneSummary(auth, input.filter);
1101
+ if (!input.noRateLimit) {
1102
+ const rateLimit = await runRateLimitTests({
1103
+ apiMethodMap: input.apiMethodMap,
1104
+ apiMetaMap: input.apiMetaMap,
1105
+ baseUrl: input.baseUrl,
1106
+ skip: input.skip,
1107
+ headers,
1108
+ inputFor
1109
+ });
1110
+ summary.rateLimit = cloneSummary(rateLimit, input.filter);
1111
+ }
1112
+ if (!input.noFuzz) {
1113
+ const fuzz = await runFuzzTests({
1114
+ apiMethodMap: input.apiMethodMap,
1115
+ baseUrl: input.baseUrl,
1116
+ skip: input.skip,
1117
+ headers
1118
+ });
1119
+ summary.fuzz = cloneSummary(fuzz, input.filter);
1120
+ }
1121
+ }
1122
+ if (!input.noCustom) {
1123
+ try {
1124
+ await clearAllRateLimits();
1125
+ } catch {
1126
+ }
1127
+ const custom = await runCustomTests({
1128
+ baseUrl: input.baseUrl,
1129
+ sessionCookieName: input.sessionCookieName,
1130
+ filter: input.filter,
1131
+ //? Pass the generated method map so the harness invokes each API route
1132
+ //? with its declared HTTP method (e.g. logout is DELETE, not POST).
1133
+ apiMethodMap: input.apiMethodMap
1134
+ });
1135
+ summary.custom = custom;
1136
+ }
1137
+ summary.totalPassed = (summary.contract?.passed ?? 0) + (summary.auth?.passed ?? 0) + (summary.rateLimit?.passed ?? 0) + (summary.fuzz?.passed ?? 0) + (summary.custom?.passed ?? 0);
1138
+ summary.totalFailed = (summary.contract?.failed ?? 0) + (summary.auth?.failed ?? 0) + (summary.rateLimit?.failed ?? 0) + (summary.fuzz?.failed ?? 0) + (summary.custom?.failed ?? 0);
1139
+ return summary;
1140
+ };
1141
+ var useColor = (() => {
1142
+ if (process.env.NO_COLOR) return false;
1143
+ if (process.env.FORCE_COLOR) return true;
1144
+ return process.stdout.isTTY;
1145
+ })();
1146
+ var paint = (code, text) => useColor ? `\x1B[${code}m${text}\x1B[0m` : text;
1147
+ var green = (t) => paint("32", t);
1148
+ var red = (t) => paint("31", t);
1149
+ var yellow = (t) => paint("33", t);
1150
+ var dim = (t) => paint("2", t);
1151
+ var bold = (t) => paint("1", t);
1152
+ var xfailOf = (s) => s && "xfailed" in s ? s.xfailed : 0;
1153
+ var skippedOf = (s) => s && "skipped" in s ? s.skipped : 0;
1154
+ var formatLayerLine = (label, s) => {
1155
+ if (!s) return ` ${dim("\u2013")} ${label.padEnd(18)} ${dim("(layer skipped)")}`;
1156
+ const clean = s.failed === 0;
1157
+ const mark = clean ? green("\u2713") : red("\u2717");
1158
+ const head = clean ? green(`${s.passed}/${s.total} passed`) : `${green(`${s.passed}/${s.total} passed`)} ${red(`${s.failed}/${s.total} failed`)}`;
1159
+ const extras = [];
1160
+ const xf = xfailOf(s);
1161
+ const sk = skippedOf(s);
1162
+ if (xf) extras.push(yellow(`${xf} expected-fail`));
1163
+ if (sk) extras.push(dim(`${sk} skipped`));
1164
+ const extra = extras.length > 0 ? ` ${dim("\xB7")} ${extras.join(dim(", "))}` : "";
1165
+ return ` ${mark} ${label.padEnd(18)} ${head}${extra}`;
1166
+ };
1167
+ var sweepFailRows = (s) => (s?.results ?? []).filter((r) => r.status === "fail").map((r) => ({
1168
+ label: `${r.endpoint.page}/${r.endpoint.name}/${r.endpoint.version}`,
1169
+ reason: r.errorCode ? `${r.reason ?? "failed"} [${r.errorCode}]` : r.reason
1170
+ }));
1171
+ var sweepSkipRows = (layer, s) => (s?.results ?? []).filter((r) => r.status === "skipped").map((r) => ({
1172
+ label: `${layer}: ${r.endpoint.page}/${r.endpoint.name}/${r.endpoint.version}`,
1173
+ reason: r.reason
1174
+ }));
1175
+ var customRows = (s, status) => (s?.results ?? []).filter((r) => r.status === status).map((r) => ({ label: `${r.routePath} :: ${r.caseName}`, reason: r.reason }));
1176
+ var printSection = (title, heading, rows, bullet) => {
1177
+ if (rows.length === 0) return;
1178
+ console.log("");
1179
+ console.log(title(bold(`${heading} (${rows.length})`)));
1180
+ for (const row of rows) {
1181
+ console.log(` ${title(bullet)} ${row.label}`);
1182
+ if (row.reason) console.log(` ${dim(row.reason)}`);
1183
+ }
1184
+ };
1185
+ var logRunAllSummary = (summary) => {
1186
+ console.log("");
1187
+ console.log(formatLayerLine("contract", summary.contract));
1188
+ console.log(formatLayerLine("auth-enforcement", summary.auth));
1189
+ console.log(formatLayerLine("rate-limit", summary.rateLimit));
1190
+ console.log(formatLayerLine("fuzz", summary.fuzz));
1191
+ console.log(formatLayerLine("custom", summary.custom));
1192
+ const failed = [
1193
+ ...sweepFailRows(summary.contract),
1194
+ ...sweepFailRows(summary.auth),
1195
+ ...sweepFailRows(summary.rateLimit),
1196
+ ...sweepFailRows(summary.fuzz),
1197
+ ...customRows(summary.custom, "fail")
1198
+ ];
1199
+ printSection(red, "Failed \u2014 must be fixed (real bugs / wrong tests)", failed, "\u2717");
1200
+ printSection(yellow, "Expected failures \u2014 known issues, allowed to fail", customRows(summary.custom, "xfail"), "\u26A0");
1201
+ printSection(yellow, "Unexpectedly passed \u2014 remove the expectedToFail marker", customRows(summary.custom, "xpass"), "?");
1202
+ const skipped = [
1203
+ ...sweepSkipRows("contract", summary.contract),
1204
+ ...sweepSkipRows("auth", summary.auth),
1205
+ ...sweepSkipRows("rate-limit", summary.rateLimit),
1206
+ ...sweepSkipRows("fuzz", summary.fuzz)
1207
+ ];
1208
+ printSection(dim, "Skipped \u2014 not run (with reason)", skipped, "\u2013");
1209
+ const totalXfail = xfailOf(summary.contract) + xfailOf(summary.auth) + xfailOf(summary.rateLimit) + xfailOf(summary.fuzz) + xfailOf(summary.custom);
1210
+ const totalSkipped = skippedOf(summary.contract) + skippedOf(summary.auth) + skippedOf(summary.rateLimit) + skippedOf(summary.fuzz) + skippedOf(summary.custom);
1211
+ console.log("");
1212
+ const parts = [
1213
+ green(`${summary.totalPassed} passed`),
1214
+ summary.totalFailed > 0 ? red(`${summary.totalFailed} failed`) : dim("0 failed"),
1215
+ totalXfail > 0 ? yellow(`${totalXfail} expected-fail`) : dim("0 expected-fail"),
1216
+ totalSkipped > 0 ? yellow(`${totalSkipped} skipped`) : dim("0 skipped")
1217
+ ];
1218
+ console.log(`Summary: ${parts.join(dim(" \xB7 "))}`);
1219
+ console.log(dim(" legend: \u2717 red = must fix \xB7 \u26A0 yellow = known/allowed \xB7 \u2013 skipped (not run)"));
1220
+ };
1221
+
1222
+ // src/extensionRegistry.ts
1223
+ var layers = /* @__PURE__ */ new Map();
1224
+ var fixtures = /* @__PURE__ */ new Map();
1225
+ var reporter = null;
1226
+ var registerTestLayer = (layer) => {
1227
+ layers.set(layer.name, layer);
1228
+ };
1229
+ var listTestLayers = () => [...layers.values()];
1230
+ var registerTestFixture = (typeKey, fixture) => {
1231
+ fixtures.set(typeKey, fixture);
1232
+ };
1233
+ var getTestFixture = (typeKey) => fixtures.get(typeKey);
1234
+ var registerTestReporter = (newReporter) => {
1235
+ reporter = newReporter;
1236
+ };
1237
+ var getTestReporter = () => reporter;
1238
+ var resetTestExtensionsForTests = () => {
1239
+ layers.clear();
1240
+ fixtures.clear();
1241
+ reporter = null;
1242
+ };
1243
+ export {
1244
+ discoverCustomTestFiles,
1245
+ getTestFixture,
1246
+ getTestReporter,
1247
+ listTestLayers,
1248
+ logContractResult,
1249
+ logContractSummary,
1250
+ logRunAllSummary,
1251
+ openStreamWatcher,
1252
+ registerTestFixture,
1253
+ registerTestLayer,
1254
+ registerTestReporter,
1255
+ resetServerState,
1256
+ resetTestExtensionsForTests,
1257
+ runAllTests,
1258
+ runAuthEnforcementCheck,
1259
+ runAuthEnforcementTests,
1260
+ runContractCheck,
1261
+ runContractTests,
1262
+ runCustomTests,
1263
+ runFuzzCheck,
1264
+ runFuzzTests,
1265
+ runRateLimitCheck,
1266
+ runRateLimitTests,
1267
+ sampleSchemaInput,
1268
+ walkEndpoints
1269
+ };
1270
+ //# sourceMappingURL=index.js.map