@luckystack/test-runner 0.7.3 → 0.7.4

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/CHANGELOG.md CHANGED
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.7.4] - 2026-07-22
11
+
12
+ ### Added
13
+
14
+ - `resolveTestEnvironment({ loadProjectConfig? })` and `RunAllTestsInput.loadProjectConfig`
15
+ give the test process the same env prefix as server/ORM boot: load env files,
16
+ lazily read consumer config, then dynamically run optional secret-manager
17
+ resolution before any layer or Layer-5 test-module import.
18
+ - `resolveTestBaseUrl({ cwd?, fallbackUrl? })` centralizes live-test target
19
+ resolution: explicit `TEST_BASE_URL`, then the backend's actually-bound dev
20
+ port advertisement (only while its owner PID is alive), then the caller's
21
+ config-derived fallback.
22
+
23
+ ### Fixed
24
+
25
+ - Layer-5 tests that directly use Prisma/Redis no longer receive unresolved
26
+ values such as `DATABASE_URL_V1` merely because only the separate live-server
27
+ process ran secret-manager bootstrap. A configured resolver that cannot load
28
+ now fails before test execution with an actionable error.
29
+ - Direct `runCustomTests(...)` calls now have the same lazy config/env bootstrap
30
+ as `runAllTests`; both public orchestrators require the loader and fail closed
31
+ when an untyped caller omits it. The internal prepared entrypoint prevents a
32
+ second env load from replacing resolved secrets with pointers.
33
+
10
34
  ## [0.5.0] - 2026-07-11
11
35
 
12
36
  ### Changed
package/CLAUDE.md CHANGED
@@ -27,7 +27,7 @@ Generated-type-driven test layers voor LuckyStack APIs. Walkt elke endpoint uit
27
27
  | Function / Export | 1-regel | Deep doc |
28
28
  |---|---|---|
29
29
  | `runAllTests({ apiMethodMap, apiMetaMap, apiInputSchemas, baseUrl, ... })` | Orchestrator: draait contract + auth + rate-limit + fuzz + custom (Layer 5) in volgorde, levert een `RunAllTestsSummary`. | → docs/contract-tests.md |
30
- | `runCustomTests(input)` | Layer 5: draait per-route `<name>_v<N>.tests.ts` files naast de `_api/` / `_sync/` sources, levert een `RunCustomTestsSummary`. | → docs/contract-tests.md |
30
+ | `runCustomTests(input)` | Layer 5: draait per-route tests. Direct callers moeten lazy `loadProjectConfig` meegeven; env/secrets resolven vóór test-module imports. `runAllTests` gebruikt een interne already-prepared entrypoint om pointer-reload te voorkomen. | → docs/contract-tests.md |
31
31
  | `discoverCustomTestFiles(srcDir?)` | Walker die elke `<name>_v<N>.tests.ts` onder de configured src dir yields. | → docs/contract-tests.md |
32
32
  | `logRunAllSummary(summary)` | Pretty-print één `RunAllTestsSummary`. | → docs/extension-hooks.md |
33
33
  | `walkEndpoints(apiMethodMap)` | Genereert `EndpointDescriptor[]` uit de generated map | → docs/contract-tests.md |
@@ -40,6 +40,8 @@ Generated-type-driven test layers voor LuckyStack APIs. Walkt elke endpoint uit
40
40
  | `runFuzzCheck({ endpoint, baseUrl, headers? })` | Single-endpoint crash-resistance fuzz | → docs/fuzz-tests.md |
41
41
  | `runFuzzTests({ apiMethodMap, baseUrl, skip?, headers?, onResult? })` | Sweep fuzz layer (nightly CI) | → docs/fuzz-tests.md |
42
42
  | `resetServerState({ baseUrl, token? })` | POST naar `/_test/reset` om DB+Redis schoon te maken | → docs/rate-limit-tests.md |
43
+ | `resolveTestBaseUrl({ cwd?, fallbackUrl? })` | Resolves `TEST_BASE_URL` → actually-bound dev port advertisement → caller fallback, so an auto-hop does not create false connection failures. | → docs/contract-tests.md |
44
+ | `resolveTestEnvironment({ loadProjectConfig? })` | Laadt `.env`-lagen en resolveert optionele secret-manager-pointers in het testproces vóór DB/Redis-backed Layer-5 modules importeren. | → docs/contract-tests.md |
43
45
  | `sampleSchemaInput(schema, options?)` | Genereert Zod-valid sample payload uit een schema. `options.stringPrefix` tagt UNCONSTRAINED strings (finding #98) — format/checked strings blijven ongeprefixt zodat ze valid blijven. | → docs/contract-tests.md |
44
46
  | `createTestDataMarker()` / `TEST_DATA_PREFIX` | Run-unique `lstest_<uuid>_` marker (+ vaste `lstest_` prefix-constant) waarmee `runAllTests` gegenereerde test-strings identificeerbaar maakt. | → docs/contract-tests.md |
45
47
  | `logContractResult(result)` | Pretty-print één `ContractCheckResult` | → docs/extension-hooks.md |
@@ -76,6 +78,8 @@ Generated-type-driven test layers voor LuckyStack APIs. Walkt elke endpoint uit
76
78
 
77
79
  ## Config keys (env vars + register* slots)
78
80
 
81
+ - `TEST_BASE_URL` (env, test process) — explicit target override consumed first by `resolveTestBaseUrl`; otherwise it reads `node_modules/.luckystack/dev-server.json` and then uses the supplied config-derived fallback.
82
+ - `RunAllTestsInput.loadProjectConfig` / `RunCustomTestsInput.loadProjectConfig` — verplichte lazy loader voor de default export van consumer `config.ts`. De runner laadt eerst `.env`/`.env.local`, leest daarna deze config en draait bij een niet-lege `secretManager.url` `initSecretManager({...config, source:'remote'})` vóór custom test imports. `runAllTests` doet dit één keer voor alle lagen; directe Layer-5-calls doen het zelf. Een ontbrekende loader of geconfigureerd-maar-ontbrekende secret-manager is een harde fout.
79
83
  - `NODE_ENV` (env, server-side) — `/_test/reset` is automatisch beschikbaar wanneer NIET `production`.
80
84
  - `TEST_RESET_TOKEN` (env, optional, server-side) — wanneer gezet moet `resetServerState({ token })` deze meesturen als `X-Test-Reset-Token`. Verplicht voor staging/preview deploys die het endpoint over het netwerk exposen.
81
85
  - `RunRateLimitTestsInput.maxRateLimitToTest` (default `50`) — endpoints met hogere `rateLimit` worden geskipt om duizenden requests in CI te vermijden.
@@ -90,7 +94,7 @@ Generated-type-driven test layers voor LuckyStack APIs. Walkt elke endpoint uit
90
94
  - **Required**: `zod@^4.0.0` (peer), `socket.io-client@^4.8.0` (peer — `streamWatcher.ts` opent een tweede socket voor `ctx.watchStream`), `@luckystack/core@^0.1.0` (dependency — levert de helpers die de runner consumeert: `tryCatch`/`tryCatchSync`, `getProjectConfig`/`getCsrfConfig`, `getSrcDir` (default src-dir voor custom-test discovery in `customTests.ts`), `getPrismaClient` (Layer-5 `ctx.prisma`) en `clearAllRateLimits`. De `apiMethodMap` zelf wordt door de consumer als argument doorgegeven, niet uit een pad gelezen).
91
95
  - **Required server-side**: `@luckystack/server` met `/_test/reset` endpoint gemount (default ingebouwd, gated op `NODE_ENV !== 'production'`).
92
96
  - **Required generated artefact**: `apiMethodMap.generated.ts` (en `apiMetaMap.generated.ts` voor de auth/rate-limit layers), geproduceerd door `@luckystack/devkit`.
93
- - **Optional**: geen — fixtures, reporters en custom layers zijn pure-JS opt-in.
97
+ - **Optional**: `@luckystack/secret-manager@^0.7.3` (optional peer) — alleen dynamisch geladen wanneer `loadProjectConfig` een niet-lege `config.secretManager.url` teruggeeft. Zonder secret-managerconfig blijft de runner een gewone lokale-env flow.
94
98
 
95
99
  ## Related
96
100
 
package/README.md CHANGED
@@ -14,29 +14,31 @@ Run all four layers against a running server:
14
14
 
15
15
  ```ts
16
16
  import {
17
- runContractTests,
18
- runAuthEnforcementTests,
19
- runRateLimitTests,
20
- runFuzzTests,
21
- resetServerState,
22
- logContractSummary,
17
+ runAllTests,
18
+ resolveTestBaseUrl,
19
+ logRunAllSummary,
23
20
  } from '@luckystack/test-runner';
24
- import { apiMethodMap } from './src/_sockets/apiMethodMap.generated';
25
- import { apiMetaMap } from './src/_sockets/apiMetaMap.generated';
26
-
27
- const baseUrl = process.env.TEST_BASE_URL ?? 'http://127.0.0.1:80';
28
- const contract = await runContractTests({ apiMethodMap, baseUrl });
29
- logContractSummary(contract);
30
-
31
- const auth = await runAuthEnforcementTests({ apiMethodMap, apiMetaMap, baseUrl });
32
- const rate = await runRateLimitTests({ apiMethodMap, apiMetaMap, baseUrl });
33
- const fuzz = await runFuzzTests({ apiMethodMap, baseUrl });
34
-
35
- await resetServerState({ baseUrl });
21
+ import { apiInputSchemas } from './src/_sockets/apiInputSchemas.generated';
22
+ import { apiMetaMap, apiMethodMap } from './src/_sockets/apiTypes.generated';
23
+ import { ports } from './config.ports';
24
+
25
+ const baseUrl = resolveTestBaseUrl({ fallbackUrl: `http://localhost:${ports.backend}` });
26
+ const summary = await runAllTests({
27
+ apiMethodMap,
28
+ apiMetaMap,
29
+ apiInputSchemas,
30
+ baseUrl,
31
+ // Loaded only after .env/.env.local. If config.secretManager.url is set,
32
+ // pointers are resolved in THIS process before Layer-5 tests touch Prisma.
33
+ loadProjectConfig: async () => (await import('./config')).default,
34
+ });
35
+ logRunAllSummary(summary);
36
36
  ```
37
37
 
38
38
  Each layer is independently runnable — you can skip the ones that don't apply or rearrange them.
39
39
 
40
+ `runAllTests` always loads the normal env-file layers and requires `loadProjectConfig` as above, even in projects that currently use only local env values. A configured resolver is loaded dynamically and runs in fail-fast remote mode before custom test modules are imported. Direct `runCustomTests` callers must pass the same callback. Both public paths reject a missing loader so a later secret-manager rollout cannot silently expose pointer values. This matters because the live server resolving its own `DATABASE_URL` does not mutate the separate test process.
41
+
40
42
  ## Layers
41
43
 
42
44
  | Layer | What it asserts |
@@ -63,6 +65,7 @@ The fuzz layer is intentionally exhaustive and slow — wire it into nightly CI
63
65
  | `runRateLimitCheck(input)` / `runRateLimitTests(input)` | Rate-limit layer. |
64
66
  | `runFuzzCheck(input)` / `runFuzzTests(input)` | Fuzz layer. |
65
67
  | `resetServerState(input)` | POST to `/_test/reset`. |
68
+ | `resolveTestEnvironment({ loadProjectConfig? })` | Load env layers and optional secret-manager pointers for the test process; `runAllTests` calls it automatically. |
66
69
  | `sampleSchemaInput(schema)` | Generate a Zod-valid sample payload. |
67
70
  | `logContractResult` / `logContractSummary` | Pretty-printers. |
68
71
 
@@ -75,8 +78,8 @@ Types: `EndpointDescriptor`, `HttpMethod`, `ContractCheckResult`, `RunContractSu
75
78
 
76
79
  ## Dependencies
77
80
 
78
- - Peer (canonical ranges, standardized 2026-05-07):
79
- - `zod@^3.25.0`
81
+ - Required peers: `zod@^4.0.0`, `socket.io-client@^4.8.0`.
82
+ - Optional peers: `@luckystack/login`, `@luckystack/secret-manager` (loaded only for their corresponding features).
80
83
 
81
84
  ## License
82
85
 
package/dist/index.d.ts CHANGED
@@ -199,6 +199,30 @@ interface ResetServerStateInput {
199
199
  }
200
200
  declare const resetServerState: ({ baseUrl, token, requestTimeoutMs }: ResetServerStateInput) => Promise<boolean>;
201
201
 
202
+ interface ResolveTestBaseUrlOptions {
203
+ /** Project root containing node_modules/.luckystack/dev-server.json. */
204
+ cwd?: string;
205
+ /** Used when no explicit override or valid live advertisement exists. */
206
+ fallbackUrl?: string;
207
+ }
208
+ declare const resolveTestBaseUrl: (options?: ResolveTestBaseUrlOptions) => string;
209
+
210
+ type MaybePromise<T> = T | Promise<T>;
211
+ interface ResolveTestEnvironmentInput {
212
+ /**
213
+ * Loads the consumer's default-exported `config.ts` object after `.env` files
214
+ * are available. Keeping this as a callback avoids hardcoding a consumer path
215
+ * inside the package and keeps `@luckystack/secret-manager` optional.
216
+ */
217
+ loadProjectConfig?: () => MaybePromise<unknown>;
218
+ }
219
+ /**
220
+ * Load the project's env files and resolve optional secret-manager pointers for
221
+ * the TEST process. Call before importing integration-test modules that touch a
222
+ * database, Redis, or another env-backed SDK.
223
+ */
224
+ declare const resolveTestEnvironment: (input?: ResolveTestEnvironmentInput) => Promise<void>;
225
+
202
226
  interface SampleSchemaOptions {
203
227
  /**
204
228
  * Marker prefix applied to generated UNCONSTRAINED string values so any rows
@@ -298,6 +322,12 @@ interface RunCustomTestsInput {
298
322
  * HTTP method (e.g. logout is DELETE). Falls back to POST when absent.
299
323
  */
300
324
  apiMethodMap?: ApiMethodMap$1;
325
+ /**
326
+ * Lazy consumer config for direct Layer-5 runs. Required so the public path
327
+ * cannot silently skip a configured secret manager; `runAllTests` prepares
328
+ * the environment separately before using the internal Layer-5 entrypoint.
329
+ */
330
+ loadProjectConfig: NonNullable<ResolveTestEnvironmentInput['loadProjectConfig']>;
301
331
  onResult?: (result: CustomTestResult) => void;
302
332
  }
303
333
  interface DiscoveredTestFile {
@@ -358,6 +388,12 @@ declare const runCustomTests: (input: RunCustomTestsInput) => Promise<RunCustomT
358
388
  type ApiInputSchemas = Partial<Record<string, Partial<Record<string, Partial<Record<string, ZodType | undefined>>>>>>;
359
389
  interface RunAllTestsInput {
360
390
  apiMethodMap: ApiMethodMap$1;
391
+ /**
392
+ * Lazy loader for the consumer's default `config.ts` export. Required so
393
+ * runAllTests cannot silently skip a configured secret manager; resolution
394
+ * happens in THIS process before any layer or env-backed client is touched.
395
+ */
396
+ loadProjectConfig: NonNullable<ResolveTestEnvironmentInput['loadProjectConfig']>;
361
397
  apiMetaMap: ApiMetaMap;
362
398
  apiInputSchemas: ApiInputSchemas;
363
399
  baseUrl: string;
@@ -510,4 +546,4 @@ declare const LAYER_KEYS: {
510
546
  readonly custom: "custom";
511
547
  };
512
548
 
513
- export { type ApiMetaEntry, type ApiMetaMap, type ApiMethodMap$1 as ApiMethodMap, type AuthEnforcementCheckInput, type ContractCheckInput, type ContractCheckResult, type CsrfEnforcementCheckInput, type CustomTestCase, type CustomTestResult, type EndpointDescriptor, type FuzzCheckInput, type HttpMethod, LAYER_KEYS, type OpenStreamWatcherInput, type RateLimitCheckInput, type ResetServerStateInput, type RunAllTestsInput, type RunAllTestsSummary, type RunAuthEnforcementTestsInput, type RunContractSummary, type RunContractTestsInput, type RunCsrfEnforcementTestsInput, type RunCustomTestsInput, type RunCustomTestsSummary, type RunFuzzTestsInput, type RunRateLimitTestsInput, type RunRegisteredLayersInput, type SampleSchemaOptions, type StreamChunkFrame, type StreamWatcher, type SyncMethodMap, TEST_DATA_PREFIX, type TestContext, type TestExpect, type TestFixture, type TestLayer, type TestLayerInput, type TestLayerResult, type TestReporter, type TestResult, type TestSummary, createTestDataMarker, discoverCustomTestFiles, getTestFixture, getTestReporter, listTestLayers, logContractResult, logContractSummary, logRunAllSummary, openStreamWatcher, registerTestFixture, registerTestLayer, registerTestReporter, resetServerState, resetTestExtensionsForTests, runAllTests, runAuthEnforcementCheck, runAuthEnforcementTests, runContractCheck, runContractTests, runCsrfEnforcementCheck, runCsrfEnforcementTests, runCustomTests, runFuzzCheck, runFuzzTests, runRateLimitCheck, runRateLimitTests, runRegisteredLayers, sampleSchemaInput, walkEndpoints, walkSyncEndpoints };
549
+ export { type ApiMetaEntry, type ApiMetaMap, type ApiMethodMap$1 as ApiMethodMap, type AuthEnforcementCheckInput, type ContractCheckInput, type ContractCheckResult, type CsrfEnforcementCheckInput, type CustomTestCase, type CustomTestResult, type EndpointDescriptor, type FuzzCheckInput, type HttpMethod, LAYER_KEYS, type OpenStreamWatcherInput, type RateLimitCheckInput, type ResetServerStateInput, type ResolveTestBaseUrlOptions, type ResolveTestEnvironmentInput, type RunAllTestsInput, type RunAllTestsSummary, type RunAuthEnforcementTestsInput, type RunContractSummary, type RunContractTestsInput, type RunCsrfEnforcementTestsInput, type RunCustomTestsInput, type RunCustomTestsSummary, type RunFuzzTestsInput, type RunRateLimitTestsInput, type RunRegisteredLayersInput, type SampleSchemaOptions, type StreamChunkFrame, type StreamWatcher, type SyncMethodMap, TEST_DATA_PREFIX, type TestContext, type TestExpect, type TestFixture, type TestLayer, type TestLayerInput, type TestLayerResult, type TestReporter, type TestResult, type TestSummary, createTestDataMarker, discoverCustomTestFiles, getTestFixture, getTestReporter, listTestLayers, logContractResult, logContractSummary, logRunAllSummary, openStreamWatcher, registerTestFixture, registerTestLayer, registerTestReporter, resetServerState, resetTestExtensionsForTests, resolveTestBaseUrl, resolveTestEnvironment, runAllTests, runAuthEnforcementCheck, runAuthEnforcementTests, runContractCheck, runContractTests, runCsrfEnforcementCheck, runCsrfEnforcementTests, runCustomTests, runFuzzCheck, runFuzzTests, runRateLimitCheck, runRateLimitTests, runRegisteredLayers, sampleSchemaInput, walkEndpoints, walkSyncEndpoints };
package/dist/index.js CHANGED
@@ -694,6 +694,83 @@ var runFuzzTests = async (input) => {
694
694
  return calculateSummary(results);
695
695
  };
696
696
 
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
+ }
708
+ };
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)}`;
720
+ }
721
+ } catch {
722
+ }
723
+ return options.fallbackUrl ?? "http://localhost:80";
724
+ };
725
+
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`);
731
+ }
732
+ };
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 };
750
+ };
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
+ });
772
+ };
773
+
697
774
  // src/schemaSampleInput.ts
698
775
  var innerDef = (node) => node ? node.def ?? node._def : void 0;
699
776
  var sampleForDef = (def, options) => {
@@ -859,17 +936,17 @@ var logContractSummary = (summary) => {
859
936
 
860
937
  // src/customTests.ts
861
938
  import crypto from "crypto";
862
- import fs from "fs";
863
- import path from "path";
939
+ import fs2 from "fs";
940
+ import path2 from "path";
864
941
  import { pathToFileURL } from "url";
865
- import { getCsrfConfig, getPrismaClient, getProjectConfig as getProjectConfig3, getSrcDir, tryCatch as tryCatch4, tryCatchSync } from "@luckystack/core";
942
+ import { getCsrfConfig, getPrismaClient, getProjectConfig as getProjectConfig3, getSrcDir, tryCatch as tryCatch5, tryCatchSync } from "@luckystack/core";
866
943
 
867
944
  // src/streamWatcher.ts
868
945
  import { io as createSocketIoClient } from "socket.io-client";
869
946
  import {
870
947
  buildJoinRoomResponseEventName,
871
948
  socketEventNames,
872
- tryCatch as tryCatch3,
949
+ tryCatch as tryCatch4,
873
950
  getProjectConfig as getProjectConfig2
874
951
  } from "@luckystack/core";
875
952
  var DEFAULT_TIMEOUT_MS = 5e3;
@@ -956,7 +1033,7 @@ var openStreamWatcher = async (input) => {
956
1033
  }
957
1034
  };
958
1035
  socket.on(socketEventNames.sync, handleChunk);
959
- const [connectError] = await tryCatch3(() => waitForConnect(socket));
1036
+ const [connectError] = await tryCatch4(() => waitForConnect(socket));
960
1037
  if (connectError) {
961
1038
  socket.off(socketEventNames.sync, handleChunk);
962
1039
  socket.disconnect();
@@ -967,7 +1044,7 @@ var openStreamWatcher = async (input) => {
967
1044
  group: input.roomCode,
968
1045
  responseIndex
969
1046
  });
970
- const [joinError] = await tryCatch3(() => waitForJoinAck(socket, responseIndex));
1047
+ const [joinError] = await tryCatch4(() => waitForJoinAck(socket, responseIndex));
971
1048
  if (joinError) {
972
1049
  socket.off(socketEventNames.sync, handleChunk);
973
1050
  socket.disconnect();
@@ -1024,36 +1101,36 @@ var API_TEST_FILE_PATTERN = /_v(\d+)\.tests\.ts$/;
1024
1101
  var SYNC_SERVER_TEST_FILE_PATTERN = /_server_v(\d+)\.tests\.ts$/;
1025
1102
  var DEFAULT_CALL_TIMEOUT_MS = 1e4;
1026
1103
  var discoverCustomTestFiles = (srcDir = getSrcDir()) => {
1027
- if (!fs.existsSync(srcDir)) return [];
1104
+ if (!fs2.existsSync(srcDir)) return [];
1028
1105
  const found = [];
1029
1106
  const stack = [srcDir];
1030
1107
  const visited = /* @__PURE__ */ new Set();
1031
1108
  while (stack.length > 0) {
1032
1109
  const current = stack.pop();
1033
1110
  if (current === void 0) break;
1034
- const [realError, realPath] = tryCatchSync(() => fs.realpathSync(current));
1111
+ const [realError, realPath] = tryCatchSync(() => fs2.realpathSync(current));
1035
1112
  const canonical = realError || realPath === null ? current : realPath;
1036
1113
  if (visited.has(canonical)) continue;
1037
1114
  visited.add(canonical);
1038
- const [readError, entries] = tryCatchSync(() => fs.readdirSync(current, { withFileTypes: true }));
1115
+ const [readError, entries] = tryCatchSync(() => fs2.readdirSync(current, { withFileTypes: true }));
1039
1116
  if (readError || entries === null) continue;
1040
1117
  for (const entry of entries) {
1041
- const fullPath = path.join(current, entry.name);
1118
+ const fullPath = path2.join(current, entry.name);
1042
1119
  if (entry.isSymbolicLink()) continue;
1043
1120
  if (entry.isDirectory()) {
1044
1121
  stack.push(fullPath);
1045
1122
  continue;
1046
1123
  }
1047
1124
  if (!entry.isFile() || !entry.name.endsWith(".tests.ts")) continue;
1048
- const parentDir = path.basename(current);
1125
+ const parentDir = path2.basename(current);
1049
1126
  if (parentDir !== "_api" && parentDir !== "_sync") continue;
1050
1127
  const pattern = parentDir === "_api" ? API_TEST_FILE_PATTERN : SYNC_SERVER_TEST_FILE_PATTERN;
1051
1128
  const versionMatch = pattern.exec(entry.name);
1052
1129
  if (!versionMatch) continue;
1053
1130
  const version = `v${versionMatch[1]}`;
1054
1131
  const namePart = entry.name.replace(parentDir === "_api" ? /_v\d+\.tests\.ts$/ : /_server_v\d+\.tests\.ts$/, "");
1055
- const pageDir = path.dirname(current);
1056
- const relativePage = path.relative(srcDir, pageDir).replaceAll("\\", "/") || "system";
1132
+ const pageDir = path2.dirname(current);
1133
+ const relativePage = path2.relative(srcDir, pageDir).replaceAll("\\", "/") || "system";
1057
1134
  found.push({
1058
1135
  filePath: fullPath,
1059
1136
  kind: parentDir === "_api" ? "api" : "sync",
@@ -1081,7 +1158,7 @@ var buildExpect = () => ({
1081
1158
  if (!value) throw new AssertionError(message ?? `expected truthy value, got ${JSON.stringify(value)}`);
1082
1159
  },
1083
1160
  throws: async (fn, message) => {
1084
- const [err] = await tryCatch4(fn);
1161
+ const [err] = await tryCatch5(fn);
1085
1162
  if (!err) throw new AssertionError(message ?? "expected fn to throw, but it did not");
1086
1163
  return err;
1087
1164
  },
@@ -1133,7 +1210,7 @@ var buildSessionHelpers = (state) => {
1133
1210
  };
1134
1211
  };
1135
1212
  var parseResponse = async (response, state) => {
1136
- const [textError, text] = await tryCatch4(() => response.text());
1213
+ const [textError, text] = await tryCatch5(() => response.text());
1137
1214
  if (textError || text === null) {
1138
1215
  state.lastResponse = null;
1139
1216
  throw new Error(`failed to read response body (HTTP ${response.status}): ${textError?.message ?? "unknown"}`);
@@ -1159,7 +1236,7 @@ var buildCallApi = (baseUrl, routePath, state, cookieName, method) => {
1159
1236
  }, DEFAULT_CALL_TIMEOUT_MS);
1160
1237
  const init = { method, headers, signal: controller.signal };
1161
1238
  if (method !== "GET" && method !== "HEAD") init.body = JSON.stringify(input ?? {});
1162
- const [fetchError, response] = await tryCatch4(() => fetch(url, init));
1239
+ const [fetchError, response] = await tryCatch5(() => fetch(url, init));
1163
1240
  clearTimeout(timeoutHandle);
1164
1241
  if (fetchError || !response) {
1165
1242
  state.lastResponse = null;
@@ -1179,7 +1256,7 @@ var buildCallSync = (baseUrl, routePath, state, cookieName) => {
1179
1256
  const timeoutHandle = setTimeout(() => {
1180
1257
  controller.abort();
1181
1258
  }, DEFAULT_CALL_TIMEOUT_MS);
1182
- const [fetchError, response] = await tryCatch4(() => fetch(url, {
1259
+ const [fetchError, response] = await tryCatch5(() => fetch(url, {
1183
1260
  method: "POST",
1184
1261
  headers,
1185
1262
  body: JSON.stringify(body),
@@ -1214,7 +1291,7 @@ var buildContext = (discovery, input) => {
1214
1291
  while (watchers.length > 0) {
1215
1292
  const watcher = watchers.pop();
1216
1293
  if (!watcher) continue;
1217
- const [closeError] = await tryCatch4(() => watcher.close());
1294
+ const [closeError] = await tryCatch5(() => watcher.close());
1218
1295
  if (closeError) {
1219
1296
  }
1220
1297
  }
@@ -1234,17 +1311,17 @@ var buildContext = (discovery, input) => {
1234
1311
  };
1235
1312
  return { ctx, closeAllWatchers, state };
1236
1313
  };
1237
- var runCustomTests = async (input) => {
1314
+ var runCustomTestsAfterEnvironmentPrepared = async (input) => {
1238
1315
  const srcDir = getSrcDir();
1239
1316
  const files = discoverCustomTestFiles(srcDir);
1240
1317
  const results = [];
1241
1318
  for (const discovery of files) {
1242
1319
  const routePath = `${discovery.route.page}/${discovery.route.name}/${discovery.route.version}`;
1243
1320
  if (input.filter && !routePath.includes(input.filter)) continue;
1244
- const [realPathErr, realFilePath] = tryCatchSync(() => fs.realpathSync(discovery.filePath));
1321
+ const [realPathErr, realFilePath] = tryCatchSync(() => fs2.realpathSync(discovery.filePath));
1245
1322
  const resolvedPath = realPathErr ? discovery.filePath : realFilePath ?? discovery.filePath;
1246
- const relToSrc = path.relative(srcDir, resolvedPath);
1247
- if (relToSrc.startsWith("..") || path.isAbsolute(relToSrc)) {
1323
+ const relToSrc = path2.relative(srcDir, resolvedPath);
1324
+ if (relToSrc.startsWith("..") || path2.isAbsolute(relToSrc)) {
1248
1325
  const r = {
1249
1326
  routePath,
1250
1327
  caseName: "(import)",
@@ -1256,7 +1333,7 @@ var runCustomTests = async (input) => {
1256
1333
  input.onResult?.(r);
1257
1334
  continue;
1258
1335
  }
1259
- const [importError, mod] = await tryCatch4(
1336
+ const [importError, mod] = await tryCatch5(
1260
1337
  () => import(pathToFileURL(resolvedPath).href)
1261
1338
  );
1262
1339
  if (importError || !mod) {
@@ -1281,11 +1358,11 @@ var runCustomTests = async (input) => {
1281
1358
  const expectedToFail = typeof c.expectedToFail === "string" ? c.expectedToFail : void 0;
1282
1359
  const built = buildContext(discovery, input);
1283
1360
  const started = Date.now();
1284
- const [runError] = await tryCatch4(async () => c.run(built.ctx));
1285
- await tryCatch4(() => built.closeAllWatchers());
1361
+ const [runError] = await tryCatch5(async () => c.run(built.ctx));
1362
+ await tryCatch5(() => built.closeAllWatchers());
1286
1363
  const residualToken = built.state.token;
1287
1364
  if (residualToken) {
1288
- await tryCatch4(async () => {
1365
+ await tryCatch5(async () => {
1289
1366
  const { deleteSession } = await import("@luckystack/login");
1290
1367
  await deleteSession(residualToken);
1291
1368
  });
@@ -1319,6 +1396,11 @@ var runCustomTests = async (input) => {
1319
1396
  results
1320
1397
  };
1321
1398
  };
1399
+ var runCustomTests = async (input) => {
1400
+ assertProjectConfigLoader(input, "runCustomTests");
1401
+ await resolveTestEnvironment({ loadProjectConfig: input.loadProjectConfig });
1402
+ return runCustomTestsAfterEnvironmentPrepared(input);
1403
+ };
1322
1404
 
1323
1405
  // src/runAllTests.ts
1324
1406
  import { clearAllRateLimits, getCsrfConfig as getCsrfConfig2, getProjectConfig as getProjectConfig4 } from "@luckystack/core";
@@ -1436,7 +1518,7 @@ var runCustomLayer = async (input, summary) => {
1436
1518
  await clearAllRateLimits();
1437
1519
  } catch {
1438
1520
  }
1439
- const custom = await runCustomTests({
1521
+ const custom = await runCustomTestsAfterEnvironmentPrepared({
1440
1522
  baseUrl: input.baseUrl,
1441
1523
  sessionCookieName: input.sessionCookieName,
1442
1524
  filter: input.filter,
@@ -1472,6 +1554,8 @@ var computeTotals = (summary) => {
1472
1554
  summary.totalFailed = failed + (summary.custom?.failed ?? 0);
1473
1555
  };
1474
1556
  var runAllTests = async (input) => {
1557
+ assertProjectConfigLoader(input, "runAllTests");
1558
+ await resolveTestEnvironment({ loadProjectConfig: input.loadProjectConfig });
1475
1559
  const summary = { totalPassed: 0, totalFailed: 0 };
1476
1560
  if (!input.noSweep) {
1477
1561
  const hasRoutes = Object.keys(input.apiMethodMap).length > 0;
@@ -1607,7 +1691,7 @@ var resetTestExtensionsForTests = () => {
1607
1691
  };
1608
1692
 
1609
1693
  // src/runRegisteredLayers.ts
1610
- import { tryCatch as tryCatch5, tryCatchSync as tryCatchSync2 } from "@luckystack/core";
1694
+ import { tryCatch as tryCatch6, tryCatchSync as tryCatchSync2 } from "@luckystack/core";
1611
1695
  var isLoopbackHost = (host) => host === "localhost" || host === "::1" || host === "[::1]" || /^127\.\d+\.\d+\.\d+$/.test(host);
1612
1696
  var postWebhook = async (summary) => {
1613
1697
  const reporter2 = getTestReporter();
@@ -1625,7 +1709,7 @@ var postWebhook = async (summary) => {
1625
1709
  const timeoutHandle = setTimeout(() => {
1626
1710
  controller.abort();
1627
1711
  }, 1e4);
1628
- await tryCatch5(() => fetch(webhookUrl, {
1712
+ await tryCatch6(() => fetch(webhookUrl, {
1629
1713
  method: "POST",
1630
1714
  headers,
1631
1715
  body: JSON.stringify(summary),
@@ -1643,7 +1727,7 @@ var runRegisteredLayers = async (input) => {
1643
1727
  for (const endpoint of endpoints) {
1644
1728
  if (shouldSkip(endpoint, skip)) continue;
1645
1729
  const started = Date.now();
1646
- const [runError, layerResult] = await tryCatch5(async () => layer.run({ endpoint: endpoint.fullPath, method: endpoint.method, authToken: input.authToken }));
1730
+ const [runError, layerResult] = await tryCatch6(async () => layer.run({ endpoint: endpoint.fullPath, method: endpoint.method, authToken: input.authToken }));
1647
1731
  const durationMs = Date.now() - started;
1648
1732
  const result = runError || !layerResult ? {
1649
1733
  layer: layer.name,
@@ -1660,7 +1744,7 @@ var runRegisteredLayers = async (input) => {
1660
1744
  metadata: layerResult.metadata
1661
1745
  };
1662
1746
  results.push(result);
1663
- await tryCatch5(async () => reporter2?.onResult?.(result));
1747
+ await tryCatch6(async () => reporter2?.onResult?.(result));
1664
1748
  }
1665
1749
  }
1666
1750
  const summary = {
@@ -1670,7 +1754,7 @@ var runRegisteredLayers = async (input) => {
1670
1754
  failed: results.filter((r) => !r.passed).length,
1671
1755
  results
1672
1756
  };
1673
- await tryCatch5(async () => reporter2?.onSummary?.(summary));
1757
+ await tryCatch6(async () => reporter2?.onSummary?.(summary));
1674
1758
  await postWebhook(summary);
1675
1759
  return summary;
1676
1760
  };
@@ -1691,6 +1775,8 @@ export {
1691
1775
  registerTestReporter,
1692
1776
  resetServerState,
1693
1777
  resetTestExtensionsForTests,
1778
+ resolveTestBaseUrl,
1779
+ resolveTestEnvironment,
1694
1780
  runAllTests,
1695
1781
  runAuthEnforcementCheck,
1696
1782
  runAuthEnforcementTests,