@codeyam-editor/codeyam-editor 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/npm/cli.js +5 -1
  2. package/npm/cloud.js +2079 -242
  3. package/npm/codesign-plan.js +68 -0
  4. package/npm/compose-conflict.js +59 -0
  5. package/npm/empty-project-dir.js +92 -7
  6. package/npm/fleet-add-failure.js +361 -0
  7. package/npm/fleet-asset-version.js +37 -0
  8. package/npm/fleet-auth-health.js +143 -5
  9. package/npm/fleet-build-data.js +60 -5
  10. package/npm/fleet-cleanup.js +243 -9
  11. package/npm/fleet-destroy-safety.js +230 -13
  12. package/npm/fleet-early-roster.js +87 -0
  13. package/npm/fleet-effect-poll.js +110 -0
  14. package/npm/fleet-env.js +86 -0
  15. package/npm/fleet-ghcr-secret.js +129 -0
  16. package/npm/fleet-git-maintenance.js +142 -0
  17. package/npm/fleet-host-op-limiter.js +68 -0
  18. package/npm/fleet-iap-token.js +165 -0
  19. package/npm/fleet-job-badge.js +28 -12
  20. package/npm/fleet-live-status.js +40 -8
  21. package/npm/fleet-memory.js +289 -9
  22. package/npm/fleet-plans.js +98 -4
  23. package/npm/fleet-pool.js +75 -16
  24. package/npm/fleet-prewarm.js +87 -11
  25. package/npm/fleet-probe-transport.js +155 -0
  26. package/npm/fleet-project-drift.js +58 -0
  27. package/npm/fleet-public-url.js +234 -0
  28. package/npm/fleet-queue.js +58 -1
  29. package/npm/fleet-reachability-debounce.js +69 -0
  30. package/npm/fleet-reachability.js +80 -0
  31. package/npm/fleet-reset.js +32 -0
  32. package/npm/fleet-revision.js +104 -0
  33. package/npm/fleet-running-plan.js +115 -0
  34. package/npm/fleet-session-token-cache.js +65 -0
  35. package/npm/fleet-setup-steps.js +19 -12
  36. package/npm/fleet-status-refs.js +96 -0
  37. package/npm/fleet-strand.js +39 -8
  38. package/npm/fleet-stuck-status.js +13 -7
  39. package/npm/fleet-vm-access.js +170 -0
  40. package/npm/fleet-vm-probe-target.js +282 -0
  41. package/npm/fleet-vm-readiness.js +136 -3
  42. package/npm/google-ingress.js +15 -0
  43. package/npm/ingress-invoke.js +478 -0
  44. package/npm/launch-config.js +302 -10
  45. package/npm/scenario-check.js +318 -12
  46. package/npm/scenario-handlers.js +85 -27
  47. package/npm/scenario-interactivity.js +145 -6
  48. package/npm/scenario-issues.js +13 -1
  49. package/npm/scenario-mocks.js +129 -26
  50. package/npm/scenario-playwright.js +330 -22
  51. package/npm/script-drift.js +72 -1
  52. package/npm/utils.js +37 -3
  53. package/package.json +5 -5
  54. package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
  55. package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
  56. package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
  57. package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
  58. package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
  59. package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
  60. package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
  61. package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
  62. package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
  63. package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
  64. package/ui/dist/assets/index-CjxWhkSy.css +1 -0
  65. package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
  66. package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
  67. package/ui/dist/assets/react-nrLBr15I.js +28 -0
  68. package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
  69. package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
  70. package/ui/dist/index.html +4 -4
  71. package/ui/dist/open-source/codeyam-counter.png +0 -0
  72. package/ui/dist/open-source/el-carot.png +0 -0
  73. package/ui/dist/open-source/tabcommand.png +0 -0
  74. package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
  75. package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
  76. package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
  77. package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
  78. package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
  79. package/ui/dist/assets/index-CIXZkwfB.css +0 -1
  80. package/ui/dist/assets/react-CSS0HapR.js +0 -28
@@ -19,13 +19,36 @@ const { createIssue } = require("./scenario-issues");
19
19
  // reliable in-page attachment signal are ALSO a conservative pass: we never
20
20
  // flag a page we cannot prove is dead, so a healthy Svelte / Solid / vanilla
21
21
  // page is never a false negative. A new framework detector is a new entry in
22
- // `detectors` below plus a `KNOWN_FRAMEWORKS` mapping — not an edit here.
22
+ // `detectors` below plus a `KNOWN_FRAMEWORKS` mapping — not an edit here; a
23
+ // meta-framework that hydrates an already-detected runtime is a new row in
24
+ // `META_FRAMEWORK_ALIASES`.
23
25
 
24
26
  // Frameworks we have an in-page attachment detector for. Inference only
25
27
  // resolves to one of these; an unrecognised framework yields `null`, which
26
28
  // the caller treats as "cannot determine" (conservative pass).
27
29
  const KNOWN_FRAMEWORKS = ["react", "vue"];
28
30
 
31
+ // Meta-frameworks mapped to the underlying runtime whose hydration detector
32
+ // applies. These do not carry their runtime's name in their stack identity: a
33
+ // Next.js app is `id: "nextjs-prisma-sqlite"`, `name: "Next.js + Prisma +
34
+ // SQLite"` — nothing contains the literal "react". Without this map the
35
+ // substring scan below returns `null`, the capture skips the hydration gate,
36
+ // and a genuinely un-hydrated page is reported as an inert control rather than
37
+ // as hydration never having run.
38
+ //
39
+ // Saying "Next hydrates React" is a fact about the runtime, not a framework
40
+ // assumption, so this stays consistent with the data-driven design above: a new
41
+ // meta-framework is a new row here, not a new branch in the capture flow.
42
+ //
43
+ // Matched with word boundaries rather than bare substrings so an unrelated name
44
+ // like "NextGen CLI" or "Remixer" cannot masquerade as a meta-framework.
45
+ const META_FRAMEWORK_ALIASES = [
46
+ { pattern: /\bnext(\.js|js)?\b/, framework: "react" },
47
+ { pattern: /\bremix\b/, framework: "react" },
48
+ { pattern: /\bgatsby\b/, framework: "react" },
49
+ { pattern: /\bnuxt(\.js|js)?\b/, framework: "vue" },
50
+ ];
51
+
29
52
  // Read `.codeyam/stack.json` relative to the capture script's cwd (the project
30
53
  // dir — `scenario_check.rs` sets `.current_dir(project_dir)`). Never throws: a
31
54
  // missing or malformed file yields `null` so the probe degrades to a no-op
@@ -51,6 +74,9 @@ function inferFramework(stack) {
51
74
  .filter((s) => typeof s === "string")
52
75
  .join(" ")
53
76
  .toLowerCase();
77
+ for (const { pattern, framework } of META_FRAMEWORK_ALIASES) {
78
+ if (pattern.test(haystack)) return framework;
79
+ }
54
80
  for (const fw of KNOWN_FRAMEWORKS) {
55
81
  if (haystack.includes(fw)) return fw;
56
82
  }
@@ -214,24 +240,136 @@ function interpretHydration({
214
240
  // project's stack.json unless the caller injects a `stack`), short-circuit when
215
241
  // no client runtime is expected, collect the in-page state, and interpret it.
216
242
  // Never throws — a probe failure must not break an otherwise-good capture.
217
- async function probeInteractivity(frame, { url, stack } = {}) {
243
+ //
244
+ // Returns `{ hydrated, issue }`:
245
+ // hydrated — `true` (runtime demonstrably attached), `false` (PROVEN dead:
246
+ // framework-owned controls rendered but nothing attached), or `null`
247
+ // ("cannot determine" — no client runtime expected, no control to probe, no
248
+ // detector for the framework, or the probe threw).
249
+ // issue — the `hydration` issue to surface, or `null` to pass.
250
+ //
251
+ // `hydrated` is deliberately three-valued rather than a bare boolean: the
252
+ // capture flow branches a page to `interactionEffect: "unhydrated"` ONLY on a
253
+ // proven `false`. A `null` must never be read as "dead" — that would turn every
254
+ // unknown-framework page into a false hydration failure.
255
+ async function probeHydrationState(frame, { url, stack } = {}) {
218
256
  const descriptor = stack !== undefined ? stack : readStackJson();
219
257
  const { expectInteractive, framework } =
220
258
  resolveInteractivityExpectation(descriptor);
221
- if (!expectInteractive) return null;
259
+ if (!expectInteractive) return { hydrated: null, issue: null };
222
260
  let state;
223
261
  try {
224
262
  state = await collectHydrationState(frame, { framework });
225
263
  } catch (_) {
226
- return null;
264
+ return { hydrated: null, issue: null };
227
265
  }
228
- return interpretHydration({
266
+ const issue = interpretHydration({
229
267
  expectInteractive: true,
230
268
  controlCount: state.controlCount,
231
269
  frameworkAttached: state.frameworkAttached,
232
270
  framework,
233
271
  url,
234
272
  });
273
+ // `frameworkAttached` is already the three-valued signal `hydrated` needs —
274
+ // pass it through rather than re-deriving it from the presence of an issue,
275
+ // which would conflate "no issue" (a pass) with "hydrated" (a positive).
276
+ return { hydrated: state.frameworkAttached, issue };
277
+ }
278
+
279
+ // Sleep for `ms` — a promisified `setTimeout`, so the poll loop yields the
280
+ // event loop between ticks instead of spinning.
281
+ function sleep(ms) {
282
+ return new Promise((resolve) => setTimeout(resolve, ms));
283
+ }
284
+
285
+ // Bounded WAIT for hydration — the polling generalization of
286
+ // `probeHydrationState`. Where the probe reads the attachment state once,
287
+ // this re-polls `collectHydrationState` until the framework demonstrably
288
+ // attaches, the state becomes unjudgeable, or a wall-clock cap elapses. It is
289
+ // the seam the capture flow calls so that every screenshot and every driven
290
+ // interaction happens AFTER the client runtime has attached, not on inert SSR
291
+ // markup (the bug where a fill/click landed before React hydrated, the handler
292
+ // never fired, yet the run reported success).
293
+ //
294
+ // Stack-agnostic by construction: it resolves the same expectation as the
295
+ // probe, so a non-interactive stack (backend/static/CLI, or an explicit
296
+ // `capture.interactivity === false`) returns immediately with `hydrated: null`
297
+ // and never waits. An unknown framework (no detector) reads
298
+ // `frameworkAttached === null` on the first poll and also passes instantly — we
299
+ // never block on a page we cannot judge.
300
+ //
301
+ // Never fails closed: a timeout yields `hydrated: false` and lets the existing
302
+ // `interpretHydration` path decide whether to surface the loud `hydration`
303
+ // issue; a thrown probe (detached frame mid-nav) is caught and treated as a
304
+ // pass. The wait can never hang a capture (hard cap) and never turns a page we
305
+ // cannot prove dead into a failure.
306
+ //
307
+ // Honest limitation: the poll can only judge a page that has already rendered
308
+ // framework-owned controls. `waitForStablePage` runs first (its `rootUnpainted`
309
+ // guard waits for the SPA root to paint), so by the time this runs, content is
310
+ // present for the SSR case this guards; a pure-CSR shell that renders zero
311
+ // controls reads `null` and passes instantly, unchanged from today.
312
+ //
313
+ // Returns `{ hydrated, issue, timedOut, waitedMs }` where `hydrated` is the
314
+ // same three-valued signal `probeHydrationState` returns.
315
+ async function waitForHydration(
316
+ frame,
317
+ { url, stack, framework, timeoutMs = 10000, pollIntervalMs = 150 } = {},
318
+ ) {
319
+ let resolvedFramework;
320
+ let expectInteractive;
321
+ if (typeof framework === "string" && framework.length > 0) {
322
+ expectInteractive = true;
323
+ resolvedFramework = framework.toLowerCase();
324
+ } else {
325
+ const descriptor = stack !== undefined ? stack : readStackJson();
326
+ const resolution = resolveInteractivityExpectation(descriptor);
327
+ expectInteractive = resolution.expectInteractive;
328
+ resolvedFramework = resolution.framework;
329
+ }
330
+ if (!expectInteractive) {
331
+ return { hydrated: null, issue: null, timedOut: false, waitedMs: 0 };
332
+ }
333
+
334
+ const start = Date.now();
335
+ let state = { controlCount: 0, frameworkAttached: null };
336
+ let timedOut = false;
337
+ for (;;) {
338
+ try {
339
+ state = await collectHydrationState(frame, {
340
+ framework: resolvedFramework,
341
+ });
342
+ } catch (_) {
343
+ // Detached frame mid-navigation (or any probe throw): treat as "cannot
344
+ // determine" and pass — a probe failure must never fail a capture.
345
+ state = { controlCount: 0, frameworkAttached: null };
346
+ break;
347
+ }
348
+ // `true` (attached) and `null` (cannot judge) are both terminal — the
349
+ // conservative "can't prove dead" signal the probe already trusts. Only a
350
+ // proven `false` keeps us waiting.
351
+ if (state.frameworkAttached !== false) break;
352
+ // Stop before a sleep that would overrun the cap, so the wait stays bounded.
353
+ if (Date.now() - start + pollIntervalMs >= timeoutMs) {
354
+ timedOut = true;
355
+ break;
356
+ }
357
+ await sleep(pollIntervalMs);
358
+ }
359
+
360
+ const issue = interpretHydration({
361
+ expectInteractive: true,
362
+ controlCount: state.controlCount,
363
+ frameworkAttached: state.frameworkAttached,
364
+ framework: resolvedFramework,
365
+ url,
366
+ });
367
+ return {
368
+ hydrated: state.frameworkAttached,
369
+ issue,
370
+ timedOut,
371
+ waitedMs: Date.now() - start,
372
+ };
235
373
  }
236
374
 
237
375
  module.exports = {
@@ -241,5 +379,6 @@ module.exports = {
241
379
  resolveInteractivityExpectation,
242
380
  collectHydrationState,
243
381
  interpretHydration,
244
- probeInteractivity,
382
+ probeHydrationState,
383
+ waitForHydration,
245
384
  };
@@ -17,7 +17,14 @@ function pushIssue(issues, issue) {
17
17
  }
18
18
  }
19
19
 
20
- function buildResult({ loaded, hasContent, issues, outputPath, url }) {
20
+ function buildResult({
21
+ loaded,
22
+ hasContent,
23
+ issues,
24
+ outputPath,
25
+ url,
26
+ unmockedRoutes = [],
27
+ }) {
21
28
  return {
22
29
  ok: loaded && hasContent && issues.length === 0,
23
30
  loaded,
@@ -25,6 +32,11 @@ function buildResult({ loaded, hasContent, issues, outputPath, url }) {
25
32
  url,
26
33
  outputPath: outputPath ?? null,
27
34
  issues,
35
+ // Diagnostic-only: same-origin 4xx routes with no scenario mock. Does NOT
36
+ // affect `ok` — the paired console error already fails the capture; this is
37
+ // the actionable route list the failure message and `stub-unmocked-routes`
38
+ // consume. Defaults to `[]` so callers that omit it are unchanged.
39
+ unmockedRoutes,
28
40
  };
29
41
  }
30
42
 
@@ -1,45 +1,124 @@
1
- function normalizeMockCandidates(url) {
1
+ // Route matcher shared, semantically, with the injected live-preview shim
2
+ // (crates/proxy-http/src/fetch_patch.js::matchUrl / matchPath / matchQuerySpec).
3
+ // This is a VERBATIM port of that shim's matcher — the two interception paths
4
+ // MUST agree on which requests a mock route covers. When they drifted (the
5
+ // shim gained `:param` + query-spec parity but this capture-harness matcher was
6
+ // left doing exact string matching), a parameterized or query-spec'd mock
7
+ // rendered in the Live Preview but escaped Playwright's `page.route` during
8
+ // capture and rendered an empty list. `scenario-mocks.test.js` pins the two to
9
+ // identical verdicts on a shared fixture table so a future divergence is a
10
+ // test failure, not another lost build session.
11
+ //
12
+ // `:param` path segments match any single non-empty segment; a `?k=v` / `?k=*`
13
+ // query-spec suffix constrains the query as a subset match (`k=*` matches any
14
+ // value; an empty spec matches any query). Full-URL patterns (`http://…`) match
15
+ // by prefix on the raw URL. Mirrors crates/mock-engine's server matcher.
16
+ function matchUrl(pattern, url) {
17
+ // Full-URL patterns (external APIs) match by prefix on the raw URL.
18
+ if (pattern.startsWith("http://") || pattern.startsWith("https://")) {
19
+ return url.indexOf(pattern) === 0;
20
+ }
21
+
22
+ let reqPath = url;
23
+ let reqQuery = "";
2
24
  try {
3
- const parsed = new URL(url);
4
- return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname];
25
+ const u = new URL(url);
26
+ reqPath = u.pathname;
27
+ reqQuery = u.search.replace(/^\?/, "");
5
28
  } catch {
6
- return [url];
29
+ const qi = url.indexOf("?");
30
+ if (qi >= 0) {
31
+ reqPath = url.slice(0, qi);
32
+ reqQuery = url.slice(qi + 1);
33
+ }
34
+ }
35
+
36
+ const patParts = pattern.split("?");
37
+ if (!matchPath(patParts[0], reqPath)) return false;
38
+ return matchQuerySpec(patParts.length > 1 ? patParts[1] : "", reqQuery);
39
+ }
40
+
41
+ // Segment-wise path match with `:param` wildcards. A `:name` segment matches
42
+ // any single non-empty segment; every other segment must be equal, and the
43
+ // segment counts must match — no implicit prefix match, same as the server.
44
+ function matchPath(pattern, path) {
45
+ const pp = pattern.split("/");
46
+ const rp = path.split("/");
47
+ if (pp.length !== rp.length) return false;
48
+ for (let i = 0; i < pp.length; i++) {
49
+ if (pp[i].charAt(0) === ":") {
50
+ if (rp[i] === "") return false;
51
+ continue;
52
+ }
53
+ if (pp[i] !== rp[i]) return false;
7
54
  }
55
+ return true;
56
+ }
57
+
58
+ // Query-spec match. An empty spec matches any query (a path-only route still
59
+ // matches a request that carries query params). Otherwise every `k=v` pair in
60
+ // the spec must be present in the request query; `k=*` matches any value.
61
+ function matchQuerySpec(spec, query) {
62
+ if (!spec) return true;
63
+ const want = new URLSearchParams(spec);
64
+ const have = new URLSearchParams(query);
65
+ let ok = true;
66
+ want.forEach((v, k) => {
67
+ const got = have.get(k);
68
+ if (got === null) {
69
+ ok = false;
70
+ return;
71
+ }
72
+ if (v !== "*" && got !== v) ok = false;
73
+ });
74
+ return ok;
75
+ }
76
+
77
+ // Split a mock key (`"<METHOD> <route>"`, e.g. `"GET /api/plans"`) into its
78
+ // method and route pattern. Returns null for a malformed key with no separator.
79
+ function splitMockKey(key) {
80
+ const spaceIdx = key.indexOf(" ");
81
+ if (spaceIdx === -1) return null;
82
+ return { method: key.slice(0, spaceIdx), route: key.slice(spaceIdx + 1) };
8
83
  }
9
84
 
10
85
  function findHttpMock(httpMocks, request) {
11
86
  const method = request.method().toUpperCase();
12
- const candidates = normalizeMockCandidates(request.url());
13
- for (const candidate of candidates) {
14
- const key = `${method} ${candidate}`;
15
- if (httpMocks[key]) {
16
- return httpMocks[key];
17
- }
87
+ const url = request.url();
88
+ for (const [key, mock] of Object.entries(httpMocks)) {
89
+ const parsed = splitMockKey(key);
90
+ if (!parsed) continue;
91
+ // Method stays an exact check; only the route is matched by pattern.
92
+ if (parsed.method.toUpperCase() !== method) continue;
93
+ if (matchUrl(parsed.route, url)) return mock;
18
94
  }
19
95
  return null;
20
96
  }
21
97
 
22
- // The set of path/URL targets declared by the mock keys. A key is
23
- // `"<METHOD> <target>"` (e.g. `"GET /api/plans"`); we strip the method so the
98
+ // The set of path/URL route patterns declared by the mock keys. A key is
99
+ // `"<METHOD> <route>"` (e.g. `"GET /api/plans"`); we strip the method so the
24
100
  // route matcher can decide whether a request *could* match any mock without
25
101
  // knowing the method (the handler re-checks method via findHttpMock).
26
102
  function mockedTargets(httpMocks) {
27
103
  const targets = new Set();
28
104
  for (const key of Object.keys(httpMocks)) {
29
- const spaceIdx = key.indexOf(" ");
30
- if (spaceIdx === -1) continue;
31
- targets.add(key.slice(spaceIdx + 1));
105
+ const parsed = splitMockKey(key);
106
+ if (!parsed) continue;
107
+ targets.add(parsed.route);
32
108
  }
33
109
  return targets;
34
110
  }
35
111
 
36
- // True when a request URL matches one of the declared mock targets. Accepts
37
- // either a string or a WHATWG URL (Playwright's URL-matcher passes a URL).
112
+ // True when a request URL matches one of the declared mock route patterns under
113
+ // the shared route matcher (`:param` + query-spec parity), not exact string
114
+ // equality. Accepts either a string or a WHATWG URL (Playwright's URL-matcher
115
+ // passes a URL).
38
116
  function requestTargetsMock(targets, url) {
39
117
  const href = typeof url === "string" ? url : url.href;
40
- return normalizeMockCandidates(href).some((candidate) =>
41
- targets.has(candidate),
42
- );
118
+ for (const target of targets) {
119
+ if (matchUrl(target, href)) return true;
120
+ }
121
+ return false;
43
122
  }
44
123
 
45
124
  async function attachHttpMocks(page, httpMocks) {
@@ -106,22 +185,46 @@ async function attachHttpMocks(page, httpMocks) {
106
185
  // produces. Console errors carry no HTTP method, so any method's mock on a
107
186
  // matching target counts.
108
187
  function isDeclaredErrorMock(httpMocks, url) {
109
- const candidates = normalizeMockCandidates(url);
110
188
  for (const [key, mock] of Object.entries(httpMocks || {})) {
111
- const spaceIdx = key.indexOf(" ");
112
- if (spaceIdx === -1) continue;
113
- const target = key.slice(spaceIdx + 1);
114
- if (!candidates.includes(target)) continue;
189
+ const parsed = splitMockKey(key);
190
+ if (!parsed) continue;
191
+ // Method-agnostic on purpose: console errors carry no HTTP method, so any
192
+ // method's error mock on a matching route counts. Route matching uses the
193
+ // shared matcher so a `:param`/query-spec error mock is recognised too.
194
+ if (!matchUrl(parsed.route, url)) continue;
115
195
  if ((mock.status || 200) >= 400) return true;
116
196
  }
117
197
  return false;
118
198
  }
119
199
 
200
+ // Given a same-origin response's method/url/status and the scenario's declared
201
+ // mocks, return the `{method, path, status}` to record as an unmocked route, or
202
+ // `null` if it should be ignored: a sub-4xx response, or one already covered by
203
+ // a declared error mock (status >= 400, whose 4xx is the mock's intended
204
+ // behavior). The caller applies the same-origin and `expectedConsoleErrors`
205
+ // gates before calling. `path` is the URL pathname — the route half of the
206
+ // `"<METHOD> <path>"` stub-mock key; a malformed URL falls back to the raw
207
+ // string so the key is still well-formed.
208
+ function unmockedRouteFrom(method, url, status, httpMocks) {
209
+ if (status < 400) return null;
210
+ if (isDeclaredErrorMock(httpMocks, url)) return null;
211
+ let path = url;
212
+ try {
213
+ path = new URL(url).pathname;
214
+ } catch {
215
+ /* malformed URL — fall back to the raw string for the stub key */
216
+ }
217
+ return { method: String(method).toUpperCase(), path, status };
218
+ }
219
+
120
220
  module.exports = {
121
- normalizeMockCandidates,
221
+ matchUrl,
222
+ matchPath,
223
+ matchQuerySpec,
122
224
  findHttpMock,
123
225
  mockedTargets,
124
226
  requestTargetsMock,
125
227
  attachHttpMocks,
126
228
  isDeclaredErrorMock,
229
+ unmockedRouteFrom,
127
230
  };