@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.
- package/npm/cli.js +5 -1
- package/npm/cloud.js +2079 -242
- package/npm/codesign-plan.js +68 -0
- package/npm/compose-conflict.js +59 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +361 -0
- package/npm/fleet-asset-version.js +37 -0
- package/npm/fleet-auth-health.js +143 -5
- package/npm/fleet-build-data.js +60 -5
- package/npm/fleet-cleanup.js +243 -9
- package/npm/fleet-destroy-safety.js +230 -13
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-env.js +86 -0
- package/npm/fleet-ghcr-secret.js +129 -0
- package/npm/fleet-git-maintenance.js +142 -0
- package/npm/fleet-host-op-limiter.js +68 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +28 -12
- package/npm/fleet-live-status.js +40 -8
- package/npm/fleet-memory.js +289 -9
- package/npm/fleet-plans.js +98 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +87 -11
- package/npm/fleet-probe-transport.js +155 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +234 -0
- package/npm/fleet-queue.js +58 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reset.js +32 -0
- package/npm/fleet-revision.js +104 -0
- package/npm/fleet-running-plan.js +115 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +19 -12
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +39 -8
- package/npm/fleet-stuck-status.js +13 -7
- package/npm/fleet-vm-access.js +170 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +136 -3
- package/npm/google-ingress.js +15 -0
- package/npm/ingress-invoke.js +478 -0
- package/npm/launch-config.js +302 -10
- package/npm/scenario-check.js +318 -12
- package/npm/scenario-handlers.js +85 -27
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-mocks.js +129 -26
- package/npm/scenario-playwright.js +330 -22
- package/npm/script-drift.js +72 -1
- package/npm/utils.js +37 -3
- package/package.json +5 -5
- package/ui/dist/assets/{ContainerInspector-D7VZkkaj.js → ContainerInspector-Cu1tSN-o.js} +1 -1
- package/ui/dist/assets/EditorShell--V2ExYQ6.js +260 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BoLHHFXH.js → Markdown-Chgdf3PW.js} +1 -1
- package/ui/dist/assets/ProjectLauncherScreen-Dmm6Z0mv.js +1 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/ScenarioDataPanel-DjprZq2L.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BqMxWVKW.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-DqLVVMYS.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-CjxWhkSy.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-VuyqBbGJ.js} +4 -4
- package/ui/dist/assets/{markdown-v0F0UEt3.js → markdown-C9tbsVHX.js} +1 -1
- package/ui/dist/assets/react-nrLBr15I.js +28 -0
- package/ui/dist/assets/{useEvents-DHkLJZR4.js → useEvents-S3Qk1Y_d.js} +1 -1
- package/ui/dist/assets/useServerIdentity-BdXy8TQv.js +1 -0
- package/ui/dist/index.html +4 -4
- package/ui/dist/open-source/codeyam-counter.png +0 -0
- package/ui/dist/open-source/el-carot.png +0 -0
- package/ui/dist/open-source/tabcommand.png +0 -0
- package/ui/dist/assets/EditorShell-DHhIaotd.css +0 -1
- package/ui/dist/assets/EditorShell-r-796N_W.js +0 -191
- package/ui/dist/assets/ProjectLauncherScreen-Ck-XWkQN.js +0 -1
- package/ui/dist/assets/ScenarioDataPanel-B9iDSUpA.js +0 -5
- package/ui/dist/assets/ScenarioRenderer-BhLfT-nX.js +0 -21
- package/ui/dist/assets/index-CIXZkwfB.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
package/npm/scenario-check.js
CHANGED
|
@@ -161,6 +161,7 @@ const {
|
|
|
161
161
|
const {
|
|
162
162
|
attachHttpMocks,
|
|
163
163
|
isDeclaredErrorMock,
|
|
164
|
+
unmockedRouteFrom,
|
|
164
165
|
} = require("./scenario-mocks");
|
|
165
166
|
|
|
166
167
|
const {
|
|
@@ -175,11 +176,112 @@ const {
|
|
|
175
176
|
scrollThroughDocument,
|
|
176
177
|
collectVisibleTextLength,
|
|
177
178
|
forceFinalVisualState,
|
|
179
|
+
centerCaptureWrapper,
|
|
178
180
|
performInteraction,
|
|
179
181
|
waitForPredicate,
|
|
180
182
|
performInteractionSequence,
|
|
181
183
|
} = require("./scenario-playwright");
|
|
182
184
|
|
|
185
|
+
// Name of the cookie the editor's control-API accepts the session token in —
|
|
186
|
+
// must match `SESSION_COOKIE` in crates/control-api/src/origin_guard.rs. On a
|
|
187
|
+
// non-loopback (cloud) bind the control-API requires this token on the harness
|
|
188
|
+
// route (`/__codeyam_harness`); without it the top-level harness navigation 401s
|
|
189
|
+
// and `#scenario-frame` never attaches, so EVERY capture times out. The browser
|
|
190
|
+
// UI carries this cookie automatically; the headless capturer does not, so we
|
|
191
|
+
// inject it here from the on-box 0600 token file.
|
|
192
|
+
const SESSION_COOKIE = "cy_session";
|
|
193
|
+
|
|
194
|
+
// The loopback names the editor can be addressed by. A cookie is scoped by
|
|
195
|
+
// HOST, and the jar does NOT treat these as interchangeable: a cookie stored for
|
|
196
|
+
// `localhost` is simply not sent to `127.0.0.1`. That matters because the two
|
|
197
|
+
// token-gated capture routes resolve their origin differently — the harness
|
|
198
|
+
// route via `resolveHarnessOrigin()` (typically `localhost`), the preview-proxy
|
|
199
|
+
// route via `PROXY_CAPTURE_LOOPBACK` in handlers.rs, which is deliberately
|
|
200
|
+
// IPv4-pinned to `127.0.0.1` so it matches the forwarder's own pinning. Scoping
|
|
201
|
+
// the cookie to `localhost` alone therefore authenticated the harness route and
|
|
202
|
+
// left every `/__codeyam_preview` capture with no credential, so `preview-verify`
|
|
203
|
+
// / `preview-interact` / `preview-flow` all 401'd on a non-loopback bind.
|
|
204
|
+
// Emitting one cookie per alias is the fix: same token, same on-box editor,
|
|
205
|
+
// whichever name the capture URL happens to use.
|
|
206
|
+
const LOOPBACK_COOKIE_DOMAINS = ["localhost", "127.0.0.1"];
|
|
207
|
+
|
|
208
|
+
// Extract the host from an origin string, or null when it is unparseable.
|
|
209
|
+
function originHost(origin) {
|
|
210
|
+
try {
|
|
211
|
+
return new URL(origin).hostname;
|
|
212
|
+
} catch (_) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Build the Playwright cookie array that authenticates the capture context to
|
|
218
|
+
// the editor's token-gated control-API. Reads the on-box `.codeyam/session-token`
|
|
219
|
+
// (0600) and emits a `cy_session` cookie for every host the capture might address
|
|
220
|
+
// the editor by (see `LOOPBACK_COOKIE_DOMAINS`); cookies are port-agnostic, so
|
|
221
|
+
// one per host covers the harness origin on the control port, the preview-proxy
|
|
222
|
+
// route, and any `/api` call on the app-proxy port. A non-loopback harness origin
|
|
223
|
+
// (a cloud tunnel domain) additionally gets its own host, since no loopback alias
|
|
224
|
+
// would match it. Returns `[]` — a no-op — when there is no token file (a
|
|
225
|
+
// loopback bind that never persisted one) or no resolvable harness origin, so a
|
|
226
|
+
// laptop capture is completely unaffected. `deps` is injectable so the builder is
|
|
227
|
+
// unit-testable without disk.
|
|
228
|
+
function buildSessionTokenCookies({
|
|
229
|
+
readTokenFile = () => {
|
|
230
|
+
const p = path.join(process.cwd(), ".codeyam", "session-token");
|
|
231
|
+
return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : null;
|
|
232
|
+
},
|
|
233
|
+
harnessOrigin = resolveHarnessOrigin(),
|
|
234
|
+
} = {}) {
|
|
235
|
+
try {
|
|
236
|
+
const token = readTokenFile();
|
|
237
|
+
if (!token || !harnessOrigin) return [];
|
|
238
|
+
const host = originHost(harnessOrigin);
|
|
239
|
+
const domains = [...LOOPBACK_COOKIE_DOMAINS];
|
|
240
|
+
// A tunnelled/cloud harness origin is not a loopback alias, so it needs its
|
|
241
|
+
// own entry or the harness navigation goes out uncredentialed.
|
|
242
|
+
if (host && !domains.includes(host)) domains.push(host);
|
|
243
|
+
return domains.map((domain) => ({
|
|
244
|
+
name: SESSION_COOKIE,
|
|
245
|
+
value: token,
|
|
246
|
+
domain,
|
|
247
|
+
path: "/",
|
|
248
|
+
httpOnly: true,
|
|
249
|
+
sameSite: "Lax",
|
|
250
|
+
}));
|
|
251
|
+
} catch (_) {
|
|
252
|
+
// A missing/unreadable token file must never break capture on a loopback
|
|
253
|
+
// bind where the token isn't required anyway — degrade to no cookie.
|
|
254
|
+
return [];
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// True when a non-empty on-box `.codeyam/session-token` exists (the same
|
|
259
|
+
// 0600 file `buildSessionTokenCookies` reads). Used by the capability-skew
|
|
260
|
+
// guard to tell "no token, loopback bind, nothing to inject" (fine) apart from
|
|
261
|
+
// "a token exists but the capture produced no cookie" (a stale capture script
|
|
262
|
+
// heading into a guaranteed 401). Never throws — an unreadable file reports
|
|
263
|
+
// absent, degrading to today's behavior.
|
|
264
|
+
function onBoxSessionTokenExists() {
|
|
265
|
+
try {
|
|
266
|
+
const p = path.join(process.cwd(), ".codeyam", "session-token");
|
|
267
|
+
return fs.existsSync(p) && fs.readFileSync(p, "utf8").trim().length > 0;
|
|
268
|
+
} catch (_) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Pure predicate behind the capability-skew guard: true when the capture WILL
|
|
274
|
+
// use the token-gated harness route (an origin resolved) yet produced NO
|
|
275
|
+
// `cy_session` cookie despite an on-box token existing — the signature of a
|
|
276
|
+
// generated `.codeyam/capture.js` stale relative to the harness route, heading
|
|
277
|
+
// into a guaranteed 401. Separated from the throw so the decision is
|
|
278
|
+
// unit-testable without a browser context. Returns false the moment any leg is
|
|
279
|
+
// absent (loopback bind with no harness origin, no token, or a cookie was
|
|
280
|
+
// produced), so a healthy capture never trips it.
|
|
281
|
+
function captureAuthSkewDetected({ harnessOrigin, cookieCount, tokenExists }) {
|
|
282
|
+
return Boolean(harnessOrigin) && cookieCount === 0 && Boolean(tokenExists);
|
|
283
|
+
}
|
|
284
|
+
|
|
183
285
|
const {
|
|
184
286
|
getInitScript,
|
|
185
287
|
handleConsoleMessage,
|
|
@@ -188,7 +290,7 @@ const {
|
|
|
188
290
|
} = require("./scenario-handlers");
|
|
189
291
|
|
|
190
292
|
const {
|
|
191
|
-
|
|
293
|
+
waitForHydration,
|
|
192
294
|
} = require("./scenario-interactivity");
|
|
193
295
|
|
|
194
296
|
// Read project-specific loading markers from `.codeyam/stack.json`
|
|
@@ -237,6 +339,27 @@ function scenarioScriptsLiveSocket(config) {
|
|
|
237
339
|
}
|
|
238
340
|
}
|
|
239
341
|
|
|
342
|
+
// Classify what a driven interaction actually did, from the DOM fingerprint on
|
|
343
|
+
// either side of it plus the page's hydration state. Pure, so the distinction
|
|
344
|
+
// this encodes is unit-tested without a browser.
|
|
345
|
+
//
|
|
346
|
+
// A click that changed nothing has two very different causes, and reporting both
|
|
347
|
+
// as "none" is what let a dead page masquerade as a flaky harness: a page that
|
|
348
|
+
// NEVER HYDRATED could not have responded to ANY interaction (no client JS is
|
|
349
|
+
// running), whereas a hydrated page with an unchanged DOM really does mean the
|
|
350
|
+
// control is inert. Keeping them apart is what tells a reader whether to go look
|
|
351
|
+
// at their component or at the environment.
|
|
352
|
+
//
|
|
353
|
+
// `hydrated` is the three-valued signal from `waitForHydration`: only a PROVEN
|
|
354
|
+
// dead page (`false`) earns "unhydrated". `null` means the wait could not judge
|
|
355
|
+
// (unknown framework, no controls, probe threw) and must keep reporting "none" —
|
|
356
|
+
// never invent a hydration fault we cannot prove.
|
|
357
|
+
function classifyInteractionEffect(beforeFingerprint, afterFingerprint, hydrated) {
|
|
358
|
+
if (beforeFingerprint !== afterFingerprint) return "changed";
|
|
359
|
+
if (hydrated === false) return "unhydrated";
|
|
360
|
+
return "none";
|
|
361
|
+
}
|
|
362
|
+
|
|
240
363
|
async function getDOMFingerprint(frame) {
|
|
241
364
|
try {
|
|
242
365
|
return await frame.evaluate(() => {
|
|
@@ -635,8 +758,24 @@ async function verifySeededStorageLanded(frame, config) {
|
|
|
635
758
|
// Returns the frame the flow ended on, so the caller's final screenshot and
|
|
636
759
|
// result URL reflect the last navigated route.
|
|
637
760
|
async function runFlowSteps(page, initialFrame, steps, ctx) {
|
|
638
|
-
const {
|
|
639
|
-
|
|
761
|
+
const {
|
|
762
|
+
url,
|
|
763
|
+
loadingMarkers,
|
|
764
|
+
navigation,
|
|
765
|
+
iframeBackground,
|
|
766
|
+
preflight,
|
|
767
|
+
harnessOrigin,
|
|
768
|
+
hydrationTimeoutMs = 10000,
|
|
769
|
+
settleMs,
|
|
770
|
+
} = ctx;
|
|
771
|
+
// Honor a caller-supplied `settleMs` for the in-flow stability windows,
|
|
772
|
+
// falling back to today's hardcoded defaults (5s for interaction steps, 10s
|
|
773
|
+
// for a navigate). This stops the field from being silently dropped — the
|
|
774
|
+
// exact trap that made a reporter's `settleMs: 8000` a no-op.
|
|
775
|
+
const navigateSettleMs =
|
|
776
|
+
typeof settleMs === "number" && settleMs > 0 ? settleMs : 10000;
|
|
777
|
+
const interactionSettleMs =
|
|
778
|
+
typeof settleMs === "number" && settleMs > 0 ? settleMs : 5000;
|
|
640
779
|
let frame = initialFrame;
|
|
641
780
|
|
|
642
781
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -655,14 +794,22 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
|
|
|
655
794
|
harnessOrigin,
|
|
656
795
|
});
|
|
657
796
|
frame = loadResult.frame;
|
|
658
|
-
await waitForStablePage(page, frame,
|
|
797
|
+
await waitForStablePage(page, frame, navigateSettleMs, loadingMarkers);
|
|
798
|
+
// A navigate loads a fresh route that must re-hydrate before the next
|
|
799
|
+
// fill/click, or the interaction lands on inert SSR markup (the same
|
|
800
|
+
// bug the top-level wait fixes, one route deeper). Bounded and
|
|
801
|
+
// stack-gated exactly like the top-level wait.
|
|
802
|
+
await waitForHydration(frame, {
|
|
803
|
+
url: target,
|
|
804
|
+
timeoutMs: hydrationTimeoutMs,
|
|
805
|
+
});
|
|
659
806
|
break;
|
|
660
807
|
}
|
|
661
808
|
case "click":
|
|
662
809
|
case "fill":
|
|
663
810
|
case "press":
|
|
664
811
|
await performInteraction(frame, step);
|
|
665
|
-
await waitForStablePage(page, frame,
|
|
812
|
+
await waitForStablePage(page, frame, interactionSettleMs, loadingMarkers);
|
|
666
813
|
break;
|
|
667
814
|
case "waitFor":
|
|
668
815
|
await waitForPredicate(frame, step);
|
|
@@ -699,9 +846,14 @@ async function runScenarioCheck(
|
|
|
699
846
|
config,
|
|
700
847
|
{ preflight = assertAppPortReachable, harnessOrigin } = {},
|
|
701
848
|
) {
|
|
702
|
-
const resolvedHarnessOrigin =
|
|
703
|
-
harnessOrigin !== undefined ? harnessOrigin : resolveHarnessOrigin();
|
|
704
849
|
const { url, outputPath, width, height, httpMocks = {} } = config;
|
|
850
|
+
// The harness host mirrors the capture target's host so the framed load stays
|
|
851
|
+
// SAME-SITE — see `resolveHarnessOrigin`. A cross-site frame withholds the
|
|
852
|
+
// SameSite=Lax `cy_session` cookie and every token-gated route 401s.
|
|
853
|
+
const resolvedHarnessOrigin =
|
|
854
|
+
harnessOrigin !== undefined
|
|
855
|
+
? harnessOrigin
|
|
856
|
+
: resolveHarnessOrigin({ targetUrl: url });
|
|
705
857
|
// Per-scenario capture-check allowances (see `CaptureAllowances` /
|
|
706
858
|
// `ScenarioDefinition` on the Rust side). Default false so the guards keep
|
|
707
859
|
// their strict behavior for every scenario that does not opt in.
|
|
@@ -709,7 +861,20 @@ async function runScenarioCheck(
|
|
|
709
861
|
const expectedConsoleErrors = !!(config && config.expectedConsoleErrors);
|
|
710
862
|
let interactionEffect = null;
|
|
711
863
|
let interactionRetried = false;
|
|
864
|
+
// Non-fatal warnings raised while resolving interaction/flow targets — e.g. a
|
|
865
|
+
// substring text match that hit more than one element. Surfaced on the result
|
|
866
|
+
// so the agent sees the ambiguity instead of a silently-wrong first-match.
|
|
867
|
+
const interactionWarnings = [];
|
|
712
868
|
const issues = [];
|
|
869
|
+
// Actionable set for self-healing: same-origin routes that returned 4xx with
|
|
870
|
+
// no scenario mock. A hermetic-proxy/upstream 404 is a *successful* HTTP
|
|
871
|
+
// response with status 404 (not a Playwright `requestfailed`), so the only
|
|
872
|
+
// place with method + path + status together is the `page.on("response")`
|
|
873
|
+
// collector below. De-duped by `"<METHOD> <path>"`; surfaced through
|
|
874
|
+
// `buildResult` so the Rust failure message can name each unmocked route and
|
|
875
|
+
// `stub-unmocked-routes` can add stub mocks for them.
|
|
876
|
+
const unmockedRoutes = [];
|
|
877
|
+
const unmockedRouteKeys = new Set();
|
|
713
878
|
// Origin of the captured page, used to classify failed requests: only a
|
|
714
879
|
// failure on the page's OWN origin should fail the capture (see
|
|
715
880
|
// `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as
|
|
@@ -742,6 +907,45 @@ async function runScenarioCheck(
|
|
|
742
907
|
}
|
|
743
908
|
const context = await browser.newContext(contextOptions);
|
|
744
909
|
|
|
910
|
+
// Authenticate the capture context to the editor's token-gated control-API
|
|
911
|
+
// BEFORE any navigation. On a non-loopback bind the harness route requires the
|
|
912
|
+
// session token; the browser UI carries it as a cookie automatically but the
|
|
913
|
+
// headless capturer does not, so inject it from the on-box token file. Scoped
|
|
914
|
+
// to the SAME resolved harness origin the capture navigates to: when no harness
|
|
915
|
+
// origin resolves (missing server-state, or a test that passes
|
|
916
|
+
// `harnessOrigin: null`) there is nothing to authenticate to, so no cookie is
|
|
917
|
+
// added. The `addCookies` guard keeps a mock context that doesn't implement it
|
|
918
|
+
// from throwing (a real Playwright context always has it).
|
|
919
|
+
const sessionCookies = buildSessionTokenCookies({
|
|
920
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
921
|
+
});
|
|
922
|
+
// Capability-skew guard. When the capture WILL use the token-gated harness
|
|
923
|
+
// route (a harness origin resolved) AND an on-box session token exists, but no
|
|
924
|
+
// `cy_session` cookie was produced, the token injection is broken — the
|
|
925
|
+
// generated `.codeyam/capture.js` is stale relative to the harness route it
|
|
926
|
+
// must authenticate to. Proceeding would hit a guaranteed 401 and strand every
|
|
927
|
+
// component capture on a `#scenario-frame` timeout. Fail loudly at the source
|
|
928
|
+
// instead, naming the version skew and its fix — closing the gap between the
|
|
929
|
+
// harness route (present) and the capture-side auth (absent).
|
|
930
|
+
if (
|
|
931
|
+
captureAuthSkewDetected({
|
|
932
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
933
|
+
cookieCount: sessionCookies.length,
|
|
934
|
+
tokenExists: onBoxSessionTokenExists(),
|
|
935
|
+
})
|
|
936
|
+
) {
|
|
937
|
+
throw new Error(
|
|
938
|
+
`The capture harness origin ${resolvedHarnessOrigin} is token-gated and an ` +
|
|
939
|
+
`on-box \`.codeyam/session-token\` exists, but the capture produced no ` +
|
|
940
|
+
`\`cy_session\` cookie — the generated \`.codeyam/capture.js\` is stale ` +
|
|
941
|
+
`relative to the harness route (its session-token injection is missing or ` +
|
|
942
|
+
`broken). Regenerate the capture script / refresh the editor binary on this VM.`,
|
|
943
|
+
);
|
|
944
|
+
}
|
|
945
|
+
if (sessionCookies.length > 0 && typeof context.addCookies === "function") {
|
|
946
|
+
await context.addCookies(sessionCookies);
|
|
947
|
+
}
|
|
948
|
+
|
|
745
949
|
// Apply the scenario's merged `browserState` (cookies + request
|
|
746
950
|
// headers) to the capture context BEFORE the first navigation.
|
|
747
951
|
// Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
|
|
@@ -793,6 +997,30 @@ async function runScenarioCheck(
|
|
|
793
997
|
pushIssue(issues, issue);
|
|
794
998
|
});
|
|
795
999
|
|
|
1000
|
+
page.on("response", (response) => {
|
|
1001
|
+
// Collect same-origin 4xx responses that no scenario mock covers — the set
|
|
1002
|
+
// a stub mock would fix. Mirrors the console-guard predicates exactly so the
|
|
1003
|
+
// reported set matches the guard's tolerances: a scenario that provokes
|
|
1004
|
+
// errors by design (`expectedConsoleErrors`) reports none, and a route the
|
|
1005
|
+
// scenario already declares an error mock for is the mock's intended 4xx,
|
|
1006
|
+
// not a missing mock.
|
|
1007
|
+
if (expectedConsoleErrors) return;
|
|
1008
|
+
const req = response.request();
|
|
1009
|
+
const reqUrl = req.url();
|
|
1010
|
+
// Same-origin only — mirror the console/requestfailed origin gate so a
|
|
1011
|
+
// cross-origin sub-resource 4xx is never reported as an unmocked route.
|
|
1012
|
+
if (!isCaptureFatalRequestFailure(reqUrl, appOrigin)) return;
|
|
1013
|
+
// The status-threshold, declared-mock, and path-extraction decision is the
|
|
1014
|
+
// pure `unmockedRouteFrom` helper; the listener keeps only the stateful
|
|
1015
|
+
// same-origin gate above and the dedup below.
|
|
1016
|
+
const route = unmockedRouteFrom(req.method(), reqUrl, response.status(), httpMocks);
|
|
1017
|
+
if (!route) return;
|
|
1018
|
+
const key = `${route.method} ${route.path}`;
|
|
1019
|
+
if (unmockedRouteKeys.has(key)) return;
|
|
1020
|
+
unmockedRouteKeys.add(key);
|
|
1021
|
+
unmockedRoutes.push(route);
|
|
1022
|
+
});
|
|
1023
|
+
|
|
796
1024
|
page.on("requestfailed", (request) => {
|
|
797
1025
|
// Per-scenario allowance: the broken-image fallback scenario's `<img>` 404
|
|
798
1026
|
// is a SAME-origin request failure it exists to demonstrate, so
|
|
@@ -843,6 +1071,21 @@ async function runScenarioCheck(
|
|
|
843
1071
|
status: response.status(),
|
|
844
1072
|
}),
|
|
845
1073
|
);
|
|
1074
|
+
} else if (!response && config.navigation === "topLevel") {
|
|
1075
|
+
// A real top-level document load always yields a response. A null one
|
|
1076
|
+
// means the status could never be checked — and an unverified status is
|
|
1077
|
+
// not a passing status: a 500 whose error body renders text would
|
|
1078
|
+
// otherwise satisfy `hasContent` and pass as `ok`. Fail loudly instead.
|
|
1079
|
+
// The iframe path stays tolerant: there the app response is matched by
|
|
1080
|
+
// exact URL and is legitimately allowed to miss.
|
|
1081
|
+
pushIssue(
|
|
1082
|
+
issues,
|
|
1083
|
+
createIssue(
|
|
1084
|
+
"navigation",
|
|
1085
|
+
"Navigation response unavailable — could not verify the HTTP status of the top-level document",
|
|
1086
|
+
{ url },
|
|
1087
|
+
),
|
|
1088
|
+
);
|
|
846
1089
|
}
|
|
847
1090
|
|
|
848
1091
|
pushRedirectMismatchIssue(issues, url, frame, response, config);
|
|
@@ -862,6 +1105,15 @@ async function runScenarioCheck(
|
|
|
862
1105
|
typeof config.stableTimeoutMs === "number" && config.stableTimeoutMs > 0
|
|
863
1106
|
? config.stableTimeoutMs
|
|
864
1107
|
: 10000;
|
|
1108
|
+
// Hydration wait cap. Mirrors `stableTimeoutMs`: defaults to 10s, but a slow
|
|
1109
|
+
// Turbopack cold-compile route can extend it via `config.hydrationTimeoutMs`
|
|
1110
|
+
// without a code change. Bounded and near-zero on a healthy page (attaches in
|
|
1111
|
+
// a poll or two); only a genuinely dead page pays the full cap.
|
|
1112
|
+
const hydrationTimeoutMs =
|
|
1113
|
+
typeof config.hydrationTimeoutMs === "number" &&
|
|
1114
|
+
config.hydrationTimeoutMs > 0
|
|
1115
|
+
? config.hydrationTimeoutMs
|
|
1116
|
+
: 10000;
|
|
865
1117
|
const stableOutcome = await waitForStablePage(
|
|
866
1118
|
page,
|
|
867
1119
|
frame,
|
|
@@ -988,11 +1240,28 @@ async function runScenarioCheck(
|
|
|
988
1240
|
// to run before the screenshot. Stack-gated and fail-safe — see
|
|
989
1241
|
// scenario-interactivity.js — so backend / static / unknown-framework
|
|
990
1242
|
// captures are an automatic pass.
|
|
991
|
-
|
|
1243
|
+
// `hydration.hydrated` is also consulted below to classify interactionEffect:
|
|
1244
|
+
// the wait runs BEFORE any interaction, which is exactly the reading we want
|
|
1245
|
+
// — "was this page alive when we found it," not "did our click revive it."
|
|
1246
|
+
//
|
|
1247
|
+
// WAIT for hydration (not just detect it): the one-shot probe read the
|
|
1248
|
+
// attachment state before React had a chance to hydrate SSR markup, so the
|
|
1249
|
+
// subsequent fill/click landed on inert controls and the submit handler
|
|
1250
|
+
// never fired — yet the run reported success. Waiting here gates the
|
|
1251
|
+
// screenshot AND every interaction branch below (steps / interaction /
|
|
1252
|
+
// interactions[] all run after this line) on the client runtime actually
|
|
1253
|
+
// attaching. Stack-gated and fail-safe: a timed-out wait yields
|
|
1254
|
+
// `hydrated: false` (so a truly dead page still classifies `unhydrated`),
|
|
1255
|
+
// and a non-interactive stack returns instantly.
|
|
1256
|
+
const hydration = await waitForHydration(frame, {
|
|
992
1257
|
url: page.url() || url,
|
|
1258
|
+
timeoutMs: hydrationTimeoutMs,
|
|
1259
|
+
// `undefined` in production → waitForHydration reads .codeyam/stack.json;
|
|
1260
|
+
// a test injects a stack to force the interactive path without a real file.
|
|
1261
|
+
stack: config.stack,
|
|
993
1262
|
});
|
|
994
|
-
if (
|
|
995
|
-
pushIssue(issues,
|
|
1263
|
+
if (hydration.issue) {
|
|
1264
|
+
pushIssue(issues, hydration.issue);
|
|
996
1265
|
}
|
|
997
1266
|
|
|
998
1267
|
// Assert the injected seed actually landed in the capture browser, at rest
|
|
@@ -1020,6 +1289,9 @@ async function runScenarioCheck(
|
|
|
1020
1289
|
iframeBackground: config.iframeBackground,
|
|
1021
1290
|
preflight,
|
|
1022
1291
|
harnessOrigin: resolvedHarnessOrigin,
|
|
1292
|
+
warnings: interactionWarnings,
|
|
1293
|
+
hydrationTimeoutMs,
|
|
1294
|
+
settleMs: config.settleMs,
|
|
1023
1295
|
});
|
|
1024
1296
|
} else if (config.interaction) {
|
|
1025
1297
|
// Record fingerprint before interaction
|
|
@@ -1030,7 +1302,9 @@ async function runScenarioCheck(
|
|
|
1030
1302
|
// fill / press (expanded accordion, open modal) without editing app
|
|
1031
1303
|
// source. A no-match target throws here and is caught below as a failed
|
|
1032
1304
|
// capture with the candidate-labels hint — never a silent blank shot.
|
|
1033
|
-
await performInteraction(frame, config.interaction
|
|
1305
|
+
await performInteraction(frame, config.interaction, {
|
|
1306
|
+
warnings: interactionWarnings,
|
|
1307
|
+
});
|
|
1034
1308
|
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1035
1309
|
|
|
1036
1310
|
// Record fingerprint after interaction
|
|
@@ -1040,12 +1314,18 @@ async function runScenarioCheck(
|
|
|
1040
1314
|
if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") {
|
|
1041
1315
|
interactionRetried = true;
|
|
1042
1316
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1317
|
+
// The retry re-resolves the same target; suppress its ambiguity warning
|
|
1318
|
+
// (already captured on the first attempt) to avoid a duplicate.
|
|
1043
1319
|
await performInteraction(frame, config.interaction);
|
|
1044
1320
|
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1045
1321
|
afterFingerprint = await getDOMFingerprint(frame);
|
|
1046
1322
|
}
|
|
1047
1323
|
|
|
1048
|
-
interactionEffect =
|
|
1324
|
+
interactionEffect = classifyInteractionEffect(
|
|
1325
|
+
beforeFingerprint,
|
|
1326
|
+
afterFingerprint,
|
|
1327
|
+
hydration.hydrated,
|
|
1328
|
+
);
|
|
1049
1329
|
}
|
|
1050
1330
|
|
|
1051
1331
|
// Replay the scenario's PERSISTED interaction sequence (if any) in order,
|
|
@@ -1058,6 +1338,7 @@ async function runScenarioCheck(
|
|
|
1058
1338
|
await performInteractionSequence(page, frame, config.interactions, {
|
|
1059
1339
|
settleMs: 5000,
|
|
1060
1340
|
loadingMarkers,
|
|
1341
|
+
warnings: interactionWarnings,
|
|
1061
1342
|
});
|
|
1062
1343
|
}
|
|
1063
1344
|
|
|
@@ -1074,6 +1355,17 @@ async function runScenarioCheck(
|
|
|
1074
1355
|
await forceFinalVisualState(frame).catch(() => {});
|
|
1075
1356
|
}
|
|
1076
1357
|
|
|
1358
|
+
// Center an isolated-component wrapper that is not already centered. The
|
|
1359
|
+
// shot below is full-viewport with no element clip, so an isolation page
|
|
1360
|
+
// that does not center itself lands in the top-left corner. Unlike
|
|
1361
|
+
// forceFinalVisualState this is NOT gated on hasDeclaredInteractiveState:
|
|
1362
|
+
// an isolation page with a declared interaction still needs to be centered,
|
|
1363
|
+
// and moving the wrapper does not clobber its contents the way
|
|
1364
|
+
// animation-forcing does — so a replayed interaction sequence stays valid.
|
|
1365
|
+
if (outputPath && loaded) {
|
|
1366
|
+
await centerCaptureWrapper(frame).catch(() => {});
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1077
1369
|
if (outputPath && loaded) {
|
|
1078
1370
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
1079
1371
|
await page.screenshot({ path: outputPath, fullPage: false });
|
|
@@ -1085,6 +1377,7 @@ async function runScenarioCheck(
|
|
|
1085
1377
|
issues,
|
|
1086
1378
|
outputPath,
|
|
1087
1379
|
url: frame.url() || url,
|
|
1380
|
+
unmockedRoutes,
|
|
1088
1381
|
});
|
|
1089
1382
|
|
|
1090
1383
|
if (config.interaction) {
|
|
@@ -1092,6 +1385,14 @@ async function runScenarioCheck(
|
|
|
1092
1385
|
result.interactionRetried = interactionRetried;
|
|
1093
1386
|
}
|
|
1094
1387
|
|
|
1388
|
+
// Surface any non-fatal interaction/flow warnings (e.g. an ambiguous
|
|
1389
|
+
// substring text match) so the CLI can print them and the agent can switch
|
|
1390
|
+
// to a precise selector. Only attached when present — a clean run is
|
|
1391
|
+
// byte-for-byte unchanged.
|
|
1392
|
+
if (interactionWarnings.length > 0) {
|
|
1393
|
+
result.warnings = interactionWarnings.slice();
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1095
1396
|
// `capture-state` mode: attach the read-only page-state snapshot so the
|
|
1096
1397
|
// backend can report localStorage + rendered text. Off by default, so a
|
|
1097
1398
|
// normal error-check capture is byte-for-byte unchanged.
|
|
@@ -1119,6 +1420,7 @@ async function runScenarioCheck(
|
|
|
1119
1420
|
issues,
|
|
1120
1421
|
outputPath,
|
|
1121
1422
|
url,
|
|
1423
|
+
unmockedRoutes,
|
|
1122
1424
|
});
|
|
1123
1425
|
} finally {
|
|
1124
1426
|
await browser.close();
|
|
@@ -1146,6 +1448,7 @@ async function main() {
|
|
|
1146
1448
|
|
|
1147
1449
|
module.exports = {
|
|
1148
1450
|
runScenarioCheck,
|
|
1451
|
+
classifyInteractionEffect,
|
|
1149
1452
|
mergeVisibleTextLength,
|
|
1150
1453
|
runFlowSteps,
|
|
1151
1454
|
dumpPageState,
|
|
@@ -1153,6 +1456,9 @@ module.exports = {
|
|
|
1153
1456
|
readStackLoadingMarkers,
|
|
1154
1457
|
scenarioScriptsLiveSocket,
|
|
1155
1458
|
applyBrowserState,
|
|
1459
|
+
buildSessionTokenCookies,
|
|
1460
|
+
captureAuthSkewDetected,
|
|
1461
|
+
SESSION_COOKIE,
|
|
1156
1462
|
isCrossOriginRequest,
|
|
1157
1463
|
isCaptureFatalRequestFailure,
|
|
1158
1464
|
stripMarkerHeaders,
|
package/npm/scenario-handlers.js
CHANGED
|
@@ -39,44 +39,90 @@ function insecureContextAdvisory(text) {
|
|
|
39
39
|
});
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
// The pathname suffix identifying the editor's OWN terminal socket. Matched by
|
|
43
|
+
// suffix rather than full URL because the terminal is proxied through varying
|
|
44
|
+
// origins (localhost, a container IP, a tunnel host) and varying ports.
|
|
45
|
+
const TERMINAL_SOCKET_PATH_SUFFIX = "/ws/terminal";
|
|
46
|
+
|
|
42
47
|
// `allowWebSocket` keeps the real `WebSocket` for scenarios that script a
|
|
43
48
|
// `/ws/terminal` transcript (or a WS stream): those captures NEED the socket to
|
|
44
49
|
// connect so the server replays the scripted agent state into the frame. The
|
|
45
|
-
//
|
|
46
|
-
// OTHER capture (a component that opens `/ws/terminal` with no scripted
|
|
50
|
+
// interception below exists to silence the live terminal's reconnect loop on
|
|
51
|
+
// EVERY OTHER capture (a component that opens `/ws/terminal` with no scripted
|
|
47
52
|
// playback would otherwise screenshot the "Reconnecting…" overlay); for a
|
|
48
53
|
// scripted scenario the server holds the socket open after replay, so there is
|
|
49
54
|
// no reconnect spam to silence and stubbing only hides the very state we are
|
|
50
55
|
// trying to capture. The flag is computed by the capture orchestrator from the
|
|
51
56
|
// scenario's `mocks.transcripts` / `mocks.streams` — see
|
|
52
57
|
// `scenarioScriptsLiveSocket` in scenario-check.js.
|
|
58
|
+
//
|
|
59
|
+
// The interception is SCOPED BY URL and never blanket-replaces the global
|
|
60
|
+
// constructor. A dead global `WebSocket` also kills the framework's HMR socket,
|
|
61
|
+
// and on a dev-mode client bootstrap that runs through that socket (validated on
|
|
62
|
+
// Next.js 16 + turbopack) React then never hydrates in the capture browser —
|
|
63
|
+
// silently, with no console error. Every interactive capture of such an app
|
|
64
|
+
// reads as inert: clicks land on un-hydrated SSR markup and `interactionEffect`
|
|
65
|
+
// comes back `none`/`unhydrated`. Letting HMR connect costs nothing in capture
|
|
66
|
+
// noise, because `handleConsoleMessage` below already drops
|
|
67
|
+
// "WebSocket connection to" errors.
|
|
53
68
|
function getInitScript(allowWebSocket = false) {
|
|
54
69
|
const webSocketStub = allowWebSocket
|
|
55
70
|
? ""
|
|
56
71
|
: `
|
|
57
|
-
//
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
72
|
+
// Intercept ONLY the editor's own terminal socket during capture, to
|
|
73
|
+
// prevent terminal reconnection spam. Every other URL — notably the
|
|
74
|
+
// framework's HMR socket, which dev-mode hydration depends on — gets the
|
|
75
|
+
// real WebSocket.
|
|
76
|
+
(() => {
|
|
77
|
+
const RealWebSocket = window.WebSocket;
|
|
78
|
+
if (!RealWebSocket) return;
|
|
79
|
+
class StubWebSocket {
|
|
80
|
+
static CONNECTING = 0;
|
|
81
|
+
static OPEN = 1;
|
|
82
|
+
static CLOSING = 2;
|
|
83
|
+
static CLOSED = 3;
|
|
84
|
+
readyState = 3;
|
|
85
|
+
url = "";
|
|
86
|
+
onopen = null;
|
|
87
|
+
onclose = null;
|
|
88
|
+
onerror = null;
|
|
89
|
+
onmessage = null;
|
|
90
|
+
send() {}
|
|
91
|
+
close() {}
|
|
92
|
+
addEventListener() {}
|
|
93
|
+
removeEventListener() {}
|
|
94
|
+
dispatchEvent() { return false; }
|
|
95
|
+
constructor(url) {
|
|
96
|
+
this.url = String(url);
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
if (this.onerror) this.onerror(new Event("error"));
|
|
99
|
+
if (this.onclose) this.onclose(new CloseEvent("close"));
|
|
100
|
+
}, 0);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const isTerminalSocket = (url) => {
|
|
104
|
+
const raw = String(url);
|
|
105
|
+
try {
|
|
106
|
+
return new URL(raw, window.location.href).pathname.endsWith(
|
|
107
|
+
${JSON.stringify(TERMINAL_SOCKET_PATH_SUFFIX)}
|
|
108
|
+
);
|
|
109
|
+
} catch (_) {
|
|
110
|
+
return raw.includes(${JSON.stringify(TERMINAL_SOCKET_PATH_SUFFIX)});
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
function CodeyamWebSocket(url, protocols) {
|
|
114
|
+
if (isTerminalSocket(url)) return new StubWebSocket(url);
|
|
115
|
+
return protocols === undefined
|
|
116
|
+
? new RealWebSocket(url)
|
|
117
|
+
: new RealWebSocket(url, protocols);
|
|
78
118
|
}
|
|
79
|
-
|
|
119
|
+
CodeyamWebSocket.prototype = RealWebSocket.prototype;
|
|
120
|
+
CodeyamWebSocket.CONNECTING = 0;
|
|
121
|
+
CodeyamWebSocket.OPEN = 1;
|
|
122
|
+
CodeyamWebSocket.CLOSING = 2;
|
|
123
|
+
CodeyamWebSocket.CLOSED = 3;
|
|
124
|
+
window.WebSocket = CodeyamWebSocket;
|
|
125
|
+
})();`;
|
|
80
126
|
return `
|
|
81
127
|
window.__codeyamUnhandledRejections = [];
|
|
82
128
|
window.addEventListener("unhandledrejection", (event) => {
|
|
@@ -98,10 +144,16 @@ function handleConsoleMessage(message) {
|
|
|
98
144
|
const advisory = insecureContextAdvisory(text);
|
|
99
145
|
if (advisory) return advisory;
|
|
100
146
|
|
|
101
|
-
// Ignore known dev-server WebSocket/HMR errors from Vite proxy
|
|
147
|
+
// Ignore known dev-server WebSocket/HMR errors from Vite proxy, plus the
|
|
148
|
+
// crxjs/Vite dynamic-import reload race: dev loaders `import()` the app entry
|
|
149
|
+
// with a `?t=<timestamp>` cache-buster, and a rapid scenario-reactivation
|
|
150
|
+
// reload aborts the in-flight import, logging "TypeError: Failed to fetch
|
|
151
|
+
// dynamically imported module" (Chrome) or "error loading dynamically
|
|
152
|
+
// imported module" (Vite). Benign — the entry reloads on the next nav.
|
|
102
153
|
if (
|
|
103
154
|
text.includes("WebSocket connection to") ||
|
|
104
|
-
text.includes("Unsupported Media Type")
|
|
155
|
+
text.includes("Unsupported Media Type") ||
|
|
156
|
+
text.includes("dynamically imported module")
|
|
105
157
|
) {
|
|
106
158
|
return null;
|
|
107
159
|
}
|
|
@@ -122,7 +174,13 @@ function handleConsoleMessage(message) {
|
|
|
122
174
|
return null;
|
|
123
175
|
}
|
|
124
176
|
|
|
125
|
-
|
|
177
|
+
// Attach the offending resource URL the console message already exposes, so
|
|
178
|
+
// the capture-failure message can name the unmocked route (e.g.
|
|
179
|
+
// `GET /api/questions/<id>/research`) instead of an opaque "Failed to load
|
|
180
|
+
// resource". `message.location().url` is the resource that produced the
|
|
181
|
+
// error; `format_issue` renders it as `[<url>]` when populated.
|
|
182
|
+
const url = message.location && message.location().url;
|
|
183
|
+
return createIssue("console", text, url ? { url } : {});
|
|
126
184
|
}
|
|
127
185
|
|
|
128
186
|
function handlePageError(error) {
|