@codeyam-editor/codeyam-editor 0.1.5 → 0.1.6
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 +1884 -210
- package/npm/codesign-plan.js +68 -0
- package/npm/empty-project-dir.js +92 -7
- package/npm/fleet-add-failure.js +252 -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 +114 -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-live-status.js +40 -8
- package/npm/fleet-memory.js +245 -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-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-running-plan.js +85 -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 +182 -6
- package/npm/scenario-check.js +282 -10
- 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 +286 -12
- 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-DgM0pTAx.js} +1 -1
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -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-BRrEr8Ng.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-BeQfa28g.js +9 -0
- package/ui/dist/assets/{ScenarioDataPanel-DYP7pi_n.css → ScenarioDataPanel-C1LukYI6.css} +1 -1
- package/ui/dist/assets/{ScenarioDataPanelFullPage-CBLTGC4-.js → ScenarioDataPanelFullPage-BLqRCxQX.js} +1 -1
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/{StepsSimulator-BFldWyoX.js → StepsSimulator-CymGgfbu.js} +1 -1
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-QsOiOG9x.js → index-DCHqRrwO.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-B7rI0u3O.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,83 @@ 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
|
+
// Build the Playwright cookie array that authenticates the capture context to
|
|
195
|
+
// the editor's token-gated control-API. Reads the on-box `.codeyam/session-token`
|
|
196
|
+
// (0600) and scopes a `cy_session` cookie to `localhost` (localhost cookies are
|
|
197
|
+
// port-agnostic, so the same cookie covers the harness origin on the control
|
|
198
|
+
// port and any `/api` call on the app-proxy port). Returns `[]` — a no-op — when
|
|
199
|
+
// there is no token file (a loopback bind that never persisted one) or no
|
|
200
|
+
// resolvable harness origin, so a laptop capture is completely unaffected.
|
|
201
|
+
// `deps` is injectable so the builder is unit-testable without disk.
|
|
202
|
+
function buildSessionTokenCookies({
|
|
203
|
+
readTokenFile = () => {
|
|
204
|
+
const p = path.join(process.cwd(), ".codeyam", "session-token");
|
|
205
|
+
return fs.existsSync(p) ? fs.readFileSync(p, "utf8").trim() : null;
|
|
206
|
+
},
|
|
207
|
+
harnessOrigin = resolveHarnessOrigin(),
|
|
208
|
+
} = {}) {
|
|
209
|
+
try {
|
|
210
|
+
const token = readTokenFile();
|
|
211
|
+
if (!token || !harnessOrigin) return [];
|
|
212
|
+
return [
|
|
213
|
+
{
|
|
214
|
+
name: SESSION_COOKIE,
|
|
215
|
+
value: token,
|
|
216
|
+
domain: "localhost",
|
|
217
|
+
path: "/",
|
|
218
|
+
httpOnly: true,
|
|
219
|
+
sameSite: "Lax",
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
} catch (_) {
|
|
223
|
+
// A missing/unreadable token file must never break capture on a loopback
|
|
224
|
+
// bind where the token isn't required anyway — degrade to no cookie.
|
|
225
|
+
return [];
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// True when a non-empty on-box `.codeyam/session-token` exists (the same
|
|
230
|
+
// 0600 file `buildSessionTokenCookies` reads). Used by the capability-skew
|
|
231
|
+
// guard to tell "no token, loopback bind, nothing to inject" (fine) apart from
|
|
232
|
+
// "a token exists but the capture produced no cookie" (a stale capture script
|
|
233
|
+
// heading into a guaranteed 401). Never throws — an unreadable file reports
|
|
234
|
+
// absent, degrading to today's behavior.
|
|
235
|
+
function onBoxSessionTokenExists() {
|
|
236
|
+
try {
|
|
237
|
+
const p = path.join(process.cwd(), ".codeyam", "session-token");
|
|
238
|
+
return fs.existsSync(p) && fs.readFileSync(p, "utf8").trim().length > 0;
|
|
239
|
+
} catch (_) {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Pure predicate behind the capability-skew guard: true when the capture WILL
|
|
245
|
+
// use the token-gated harness route (an origin resolved) yet produced NO
|
|
246
|
+
// `cy_session` cookie despite an on-box token existing — the signature of a
|
|
247
|
+
// generated `.codeyam/capture.js` stale relative to the harness route, heading
|
|
248
|
+
// into a guaranteed 401. Separated from the throw so the decision is
|
|
249
|
+
// unit-testable without a browser context. Returns false the moment any leg is
|
|
250
|
+
// absent (loopback bind with no harness origin, no token, or a cookie was
|
|
251
|
+
// produced), so a healthy capture never trips it.
|
|
252
|
+
function captureAuthSkewDetected({ harnessOrigin, cookieCount, tokenExists }) {
|
|
253
|
+
return Boolean(harnessOrigin) && cookieCount === 0 && Boolean(tokenExists);
|
|
254
|
+
}
|
|
255
|
+
|
|
183
256
|
const {
|
|
184
257
|
getInitScript,
|
|
185
258
|
handleConsoleMessage,
|
|
@@ -188,7 +261,7 @@ const {
|
|
|
188
261
|
} = require("./scenario-handlers");
|
|
189
262
|
|
|
190
263
|
const {
|
|
191
|
-
|
|
264
|
+
waitForHydration,
|
|
192
265
|
} = require("./scenario-interactivity");
|
|
193
266
|
|
|
194
267
|
// Read project-specific loading markers from `.codeyam/stack.json`
|
|
@@ -237,6 +310,27 @@ function scenarioScriptsLiveSocket(config) {
|
|
|
237
310
|
}
|
|
238
311
|
}
|
|
239
312
|
|
|
313
|
+
// Classify what a driven interaction actually did, from the DOM fingerprint on
|
|
314
|
+
// either side of it plus the page's hydration state. Pure, so the distinction
|
|
315
|
+
// this encodes is unit-tested without a browser.
|
|
316
|
+
//
|
|
317
|
+
// A click that changed nothing has two very different causes, and reporting both
|
|
318
|
+
// as "none" is what let a dead page masquerade as a flaky harness: a page that
|
|
319
|
+
// NEVER HYDRATED could not have responded to ANY interaction (no client JS is
|
|
320
|
+
// running), whereas a hydrated page with an unchanged DOM really does mean the
|
|
321
|
+
// control is inert. Keeping them apart is what tells a reader whether to go look
|
|
322
|
+
// at their component or at the environment.
|
|
323
|
+
//
|
|
324
|
+
// `hydrated` is the three-valued signal from `waitForHydration`: only a PROVEN
|
|
325
|
+
// dead page (`false`) earns "unhydrated". `null` means the wait could not judge
|
|
326
|
+
// (unknown framework, no controls, probe threw) and must keep reporting "none" —
|
|
327
|
+
// never invent a hydration fault we cannot prove.
|
|
328
|
+
function classifyInteractionEffect(beforeFingerprint, afterFingerprint, hydrated) {
|
|
329
|
+
if (beforeFingerprint !== afterFingerprint) return "changed";
|
|
330
|
+
if (hydrated === false) return "unhydrated";
|
|
331
|
+
return "none";
|
|
332
|
+
}
|
|
333
|
+
|
|
240
334
|
async function getDOMFingerprint(frame) {
|
|
241
335
|
try {
|
|
242
336
|
return await frame.evaluate(() => {
|
|
@@ -635,8 +729,24 @@ async function verifySeededStorageLanded(frame, config) {
|
|
|
635
729
|
// Returns the frame the flow ended on, so the caller's final screenshot and
|
|
636
730
|
// result URL reflect the last navigated route.
|
|
637
731
|
async function runFlowSteps(page, initialFrame, steps, ctx) {
|
|
638
|
-
const {
|
|
639
|
-
|
|
732
|
+
const {
|
|
733
|
+
url,
|
|
734
|
+
loadingMarkers,
|
|
735
|
+
navigation,
|
|
736
|
+
iframeBackground,
|
|
737
|
+
preflight,
|
|
738
|
+
harnessOrigin,
|
|
739
|
+
hydrationTimeoutMs = 10000,
|
|
740
|
+
settleMs,
|
|
741
|
+
} = ctx;
|
|
742
|
+
// Honor a caller-supplied `settleMs` for the in-flow stability windows,
|
|
743
|
+
// falling back to today's hardcoded defaults (5s for interaction steps, 10s
|
|
744
|
+
// for a navigate). This stops the field from being silently dropped — the
|
|
745
|
+
// exact trap that made a reporter's `settleMs: 8000` a no-op.
|
|
746
|
+
const navigateSettleMs =
|
|
747
|
+
typeof settleMs === "number" && settleMs > 0 ? settleMs : 10000;
|
|
748
|
+
const interactionSettleMs =
|
|
749
|
+
typeof settleMs === "number" && settleMs > 0 ? settleMs : 5000;
|
|
640
750
|
let frame = initialFrame;
|
|
641
751
|
|
|
642
752
|
for (let i = 0; i < steps.length; i++) {
|
|
@@ -655,14 +765,22 @@ async function runFlowSteps(page, initialFrame, steps, ctx) {
|
|
|
655
765
|
harnessOrigin,
|
|
656
766
|
});
|
|
657
767
|
frame = loadResult.frame;
|
|
658
|
-
await waitForStablePage(page, frame,
|
|
768
|
+
await waitForStablePage(page, frame, navigateSettleMs, loadingMarkers);
|
|
769
|
+
// A navigate loads a fresh route that must re-hydrate before the next
|
|
770
|
+
// fill/click, or the interaction lands on inert SSR markup (the same
|
|
771
|
+
// bug the top-level wait fixes, one route deeper). Bounded and
|
|
772
|
+
// stack-gated exactly like the top-level wait.
|
|
773
|
+
await waitForHydration(frame, {
|
|
774
|
+
url: target,
|
|
775
|
+
timeoutMs: hydrationTimeoutMs,
|
|
776
|
+
});
|
|
659
777
|
break;
|
|
660
778
|
}
|
|
661
779
|
case "click":
|
|
662
780
|
case "fill":
|
|
663
781
|
case "press":
|
|
664
782
|
await performInteraction(frame, step);
|
|
665
|
-
await waitForStablePage(page, frame,
|
|
783
|
+
await waitForStablePage(page, frame, interactionSettleMs, loadingMarkers);
|
|
666
784
|
break;
|
|
667
785
|
case "waitFor":
|
|
668
786
|
await waitForPredicate(frame, step);
|
|
@@ -709,7 +827,20 @@ async function runScenarioCheck(
|
|
|
709
827
|
const expectedConsoleErrors = !!(config && config.expectedConsoleErrors);
|
|
710
828
|
let interactionEffect = null;
|
|
711
829
|
let interactionRetried = false;
|
|
830
|
+
// Non-fatal warnings raised while resolving interaction/flow targets — e.g. a
|
|
831
|
+
// substring text match that hit more than one element. Surfaced on the result
|
|
832
|
+
// so the agent sees the ambiguity instead of a silently-wrong first-match.
|
|
833
|
+
const interactionWarnings = [];
|
|
712
834
|
const issues = [];
|
|
835
|
+
// Actionable set for self-healing: same-origin routes that returned 4xx with
|
|
836
|
+
// no scenario mock. A hermetic-proxy/upstream 404 is a *successful* HTTP
|
|
837
|
+
// response with status 404 (not a Playwright `requestfailed`), so the only
|
|
838
|
+
// place with method + path + status together is the `page.on("response")`
|
|
839
|
+
// collector below. De-duped by `"<METHOD> <path>"`; surfaced through
|
|
840
|
+
// `buildResult` so the Rust failure message can name each unmocked route and
|
|
841
|
+
// `stub-unmocked-routes` can add stub mocks for them.
|
|
842
|
+
const unmockedRoutes = [];
|
|
843
|
+
const unmockedRouteKeys = new Set();
|
|
713
844
|
// Origin of the captured page, used to classify failed requests: only a
|
|
714
845
|
// failure on the page's OWN origin should fail the capture (see
|
|
715
846
|
// `isCaptureFatalRequestFailure`). Derived from `config.url` exactly as
|
|
@@ -742,6 +873,45 @@ async function runScenarioCheck(
|
|
|
742
873
|
}
|
|
743
874
|
const context = await browser.newContext(contextOptions);
|
|
744
875
|
|
|
876
|
+
// Authenticate the capture context to the editor's token-gated control-API
|
|
877
|
+
// BEFORE any navigation. On a non-loopback bind the harness route requires the
|
|
878
|
+
// session token; the browser UI carries it as a cookie automatically but the
|
|
879
|
+
// headless capturer does not, so inject it from the on-box token file. Scoped
|
|
880
|
+
// to the SAME resolved harness origin the capture navigates to: when no harness
|
|
881
|
+
// origin resolves (missing server-state, or a test that passes
|
|
882
|
+
// `harnessOrigin: null`) there is nothing to authenticate to, so no cookie is
|
|
883
|
+
// added. The `addCookies` guard keeps a mock context that doesn't implement it
|
|
884
|
+
// from throwing (a real Playwright context always has it).
|
|
885
|
+
const sessionCookies = buildSessionTokenCookies({
|
|
886
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
887
|
+
});
|
|
888
|
+
// Capability-skew guard. When the capture WILL use the token-gated harness
|
|
889
|
+
// route (a harness origin resolved) AND an on-box session token exists, but no
|
|
890
|
+
// `cy_session` cookie was produced, the token injection is broken — the
|
|
891
|
+
// generated `.codeyam/capture.js` is stale relative to the harness route it
|
|
892
|
+
// must authenticate to. Proceeding would hit a guaranteed 401 and strand every
|
|
893
|
+
// component capture on a `#scenario-frame` timeout. Fail loudly at the source
|
|
894
|
+
// instead, naming the version skew and its fix — closing the gap between the
|
|
895
|
+
// harness route (present) and the capture-side auth (absent).
|
|
896
|
+
if (
|
|
897
|
+
captureAuthSkewDetected({
|
|
898
|
+
harnessOrigin: resolvedHarnessOrigin,
|
|
899
|
+
cookieCount: sessionCookies.length,
|
|
900
|
+
tokenExists: onBoxSessionTokenExists(),
|
|
901
|
+
})
|
|
902
|
+
) {
|
|
903
|
+
throw new Error(
|
|
904
|
+
`The capture harness origin ${resolvedHarnessOrigin} is token-gated and an ` +
|
|
905
|
+
`on-box \`.codeyam/session-token\` exists, but the capture produced no ` +
|
|
906
|
+
`\`cy_session\` cookie — the generated \`.codeyam/capture.js\` is stale ` +
|
|
907
|
+
`relative to the harness route (its session-token injection is missing or ` +
|
|
908
|
+
`broken). Regenerate the capture script / refresh the editor binary on this VM.`,
|
|
909
|
+
);
|
|
910
|
+
}
|
|
911
|
+
if (sessionCookies.length > 0 && typeof context.addCookies === "function") {
|
|
912
|
+
await context.addCookies(sessionCookies);
|
|
913
|
+
}
|
|
914
|
+
|
|
745
915
|
// Apply the scenario's merged `browserState` (cookies + request
|
|
746
916
|
// headers) to the capture context BEFORE the first navigation.
|
|
747
917
|
// Belt-and-suspenders with the proxy's `Set-Cookie` injection: the
|
|
@@ -793,6 +963,30 @@ async function runScenarioCheck(
|
|
|
793
963
|
pushIssue(issues, issue);
|
|
794
964
|
});
|
|
795
965
|
|
|
966
|
+
page.on("response", (response) => {
|
|
967
|
+
// Collect same-origin 4xx responses that no scenario mock covers — the set
|
|
968
|
+
// a stub mock would fix. Mirrors the console-guard predicates exactly so the
|
|
969
|
+
// reported set matches the guard's tolerances: a scenario that provokes
|
|
970
|
+
// errors by design (`expectedConsoleErrors`) reports none, and a route the
|
|
971
|
+
// scenario already declares an error mock for is the mock's intended 4xx,
|
|
972
|
+
// not a missing mock.
|
|
973
|
+
if (expectedConsoleErrors) return;
|
|
974
|
+
const req = response.request();
|
|
975
|
+
const reqUrl = req.url();
|
|
976
|
+
// Same-origin only — mirror the console/requestfailed origin gate so a
|
|
977
|
+
// cross-origin sub-resource 4xx is never reported as an unmocked route.
|
|
978
|
+
if (!isCaptureFatalRequestFailure(reqUrl, appOrigin)) return;
|
|
979
|
+
// The status-threshold, declared-mock, and path-extraction decision is the
|
|
980
|
+
// pure `unmockedRouteFrom` helper; the listener keeps only the stateful
|
|
981
|
+
// same-origin gate above and the dedup below.
|
|
982
|
+
const route = unmockedRouteFrom(req.method(), reqUrl, response.status(), httpMocks);
|
|
983
|
+
if (!route) return;
|
|
984
|
+
const key = `${route.method} ${route.path}`;
|
|
985
|
+
if (unmockedRouteKeys.has(key)) return;
|
|
986
|
+
unmockedRouteKeys.add(key);
|
|
987
|
+
unmockedRoutes.push(route);
|
|
988
|
+
});
|
|
989
|
+
|
|
796
990
|
page.on("requestfailed", (request) => {
|
|
797
991
|
// Per-scenario allowance: the broken-image fallback scenario's `<img>` 404
|
|
798
992
|
// is a SAME-origin request failure it exists to demonstrate, so
|
|
@@ -843,6 +1037,21 @@ async function runScenarioCheck(
|
|
|
843
1037
|
status: response.status(),
|
|
844
1038
|
}),
|
|
845
1039
|
);
|
|
1040
|
+
} else if (!response && config.navigation === "topLevel") {
|
|
1041
|
+
// A real top-level document load always yields a response. A null one
|
|
1042
|
+
// means the status could never be checked — and an unverified status is
|
|
1043
|
+
// not a passing status: a 500 whose error body renders text would
|
|
1044
|
+
// otherwise satisfy `hasContent` and pass as `ok`. Fail loudly instead.
|
|
1045
|
+
// The iframe path stays tolerant: there the app response is matched by
|
|
1046
|
+
// exact URL and is legitimately allowed to miss.
|
|
1047
|
+
pushIssue(
|
|
1048
|
+
issues,
|
|
1049
|
+
createIssue(
|
|
1050
|
+
"navigation",
|
|
1051
|
+
"Navigation response unavailable — could not verify the HTTP status of the top-level document",
|
|
1052
|
+
{ url },
|
|
1053
|
+
),
|
|
1054
|
+
);
|
|
846
1055
|
}
|
|
847
1056
|
|
|
848
1057
|
pushRedirectMismatchIssue(issues, url, frame, response, config);
|
|
@@ -862,6 +1071,15 @@ async function runScenarioCheck(
|
|
|
862
1071
|
typeof config.stableTimeoutMs === "number" && config.stableTimeoutMs > 0
|
|
863
1072
|
? config.stableTimeoutMs
|
|
864
1073
|
: 10000;
|
|
1074
|
+
// Hydration wait cap. Mirrors `stableTimeoutMs`: defaults to 10s, but a slow
|
|
1075
|
+
// Turbopack cold-compile route can extend it via `config.hydrationTimeoutMs`
|
|
1076
|
+
// without a code change. Bounded and near-zero on a healthy page (attaches in
|
|
1077
|
+
// a poll or two); only a genuinely dead page pays the full cap.
|
|
1078
|
+
const hydrationTimeoutMs =
|
|
1079
|
+
typeof config.hydrationTimeoutMs === "number" &&
|
|
1080
|
+
config.hydrationTimeoutMs > 0
|
|
1081
|
+
? config.hydrationTimeoutMs
|
|
1082
|
+
: 10000;
|
|
865
1083
|
const stableOutcome = await waitForStablePage(
|
|
866
1084
|
page,
|
|
867
1085
|
frame,
|
|
@@ -988,11 +1206,28 @@ async function runScenarioCheck(
|
|
|
988
1206
|
// to run before the screenshot. Stack-gated and fail-safe — see
|
|
989
1207
|
// scenario-interactivity.js — so backend / static / unknown-framework
|
|
990
1208
|
// captures are an automatic pass.
|
|
991
|
-
|
|
1209
|
+
// `hydration.hydrated` is also consulted below to classify interactionEffect:
|
|
1210
|
+
// the wait runs BEFORE any interaction, which is exactly the reading we want
|
|
1211
|
+
// — "was this page alive when we found it," not "did our click revive it."
|
|
1212
|
+
//
|
|
1213
|
+
// WAIT for hydration (not just detect it): the one-shot probe read the
|
|
1214
|
+
// attachment state before React had a chance to hydrate SSR markup, so the
|
|
1215
|
+
// subsequent fill/click landed on inert controls and the submit handler
|
|
1216
|
+
// never fired — yet the run reported success. Waiting here gates the
|
|
1217
|
+
// screenshot AND every interaction branch below (steps / interaction /
|
|
1218
|
+
// interactions[] all run after this line) on the client runtime actually
|
|
1219
|
+
// attaching. Stack-gated and fail-safe: a timed-out wait yields
|
|
1220
|
+
// `hydrated: false` (so a truly dead page still classifies `unhydrated`),
|
|
1221
|
+
// and a non-interactive stack returns instantly.
|
|
1222
|
+
const hydration = await waitForHydration(frame, {
|
|
992
1223
|
url: page.url() || url,
|
|
1224
|
+
timeoutMs: hydrationTimeoutMs,
|
|
1225
|
+
// `undefined` in production → waitForHydration reads .codeyam/stack.json;
|
|
1226
|
+
// a test injects a stack to force the interactive path without a real file.
|
|
1227
|
+
stack: config.stack,
|
|
993
1228
|
});
|
|
994
|
-
if (
|
|
995
|
-
pushIssue(issues,
|
|
1229
|
+
if (hydration.issue) {
|
|
1230
|
+
pushIssue(issues, hydration.issue);
|
|
996
1231
|
}
|
|
997
1232
|
|
|
998
1233
|
// Assert the injected seed actually landed in the capture browser, at rest
|
|
@@ -1020,6 +1255,9 @@ async function runScenarioCheck(
|
|
|
1020
1255
|
iframeBackground: config.iframeBackground,
|
|
1021
1256
|
preflight,
|
|
1022
1257
|
harnessOrigin: resolvedHarnessOrigin,
|
|
1258
|
+
warnings: interactionWarnings,
|
|
1259
|
+
hydrationTimeoutMs,
|
|
1260
|
+
settleMs: config.settleMs,
|
|
1023
1261
|
});
|
|
1024
1262
|
} else if (config.interaction) {
|
|
1025
1263
|
// Record fingerprint before interaction
|
|
@@ -1030,7 +1268,9 @@ async function runScenarioCheck(
|
|
|
1030
1268
|
// fill / press (expanded accordion, open modal) without editing app
|
|
1031
1269
|
// source. A no-match target throws here and is caught below as a failed
|
|
1032
1270
|
// capture with the candidate-labels hint — never a silent blank shot.
|
|
1033
|
-
await performInteraction(frame, config.interaction
|
|
1271
|
+
await performInteraction(frame, config.interaction, {
|
|
1272
|
+
warnings: interactionWarnings,
|
|
1273
|
+
});
|
|
1034
1274
|
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1035
1275
|
|
|
1036
1276
|
// Record fingerprint after interaction
|
|
@@ -1040,12 +1280,18 @@ async function runScenarioCheck(
|
|
|
1040
1280
|
if (beforeFingerprint === afterFingerprint && config.interaction.action === "click") {
|
|
1041
1281
|
interactionRetried = true;
|
|
1042
1282
|
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
1283
|
+
// The retry re-resolves the same target; suppress its ambiguity warning
|
|
1284
|
+
// (already captured on the first attempt) to avoid a duplicate.
|
|
1043
1285
|
await performInteraction(frame, config.interaction);
|
|
1044
1286
|
await waitForStablePage(page, frame, 5000, loadingMarkers);
|
|
1045
1287
|
afterFingerprint = await getDOMFingerprint(frame);
|
|
1046
1288
|
}
|
|
1047
1289
|
|
|
1048
|
-
interactionEffect =
|
|
1290
|
+
interactionEffect = classifyInteractionEffect(
|
|
1291
|
+
beforeFingerprint,
|
|
1292
|
+
afterFingerprint,
|
|
1293
|
+
hydration.hydrated,
|
|
1294
|
+
);
|
|
1049
1295
|
}
|
|
1050
1296
|
|
|
1051
1297
|
// Replay the scenario's PERSISTED interaction sequence (if any) in order,
|
|
@@ -1058,6 +1304,7 @@ async function runScenarioCheck(
|
|
|
1058
1304
|
await performInteractionSequence(page, frame, config.interactions, {
|
|
1059
1305
|
settleMs: 5000,
|
|
1060
1306
|
loadingMarkers,
|
|
1307
|
+
warnings: interactionWarnings,
|
|
1061
1308
|
});
|
|
1062
1309
|
}
|
|
1063
1310
|
|
|
@@ -1074,6 +1321,17 @@ async function runScenarioCheck(
|
|
|
1074
1321
|
await forceFinalVisualState(frame).catch(() => {});
|
|
1075
1322
|
}
|
|
1076
1323
|
|
|
1324
|
+
// Center an isolated-component wrapper that is not already centered. The
|
|
1325
|
+
// shot below is full-viewport with no element clip, so an isolation page
|
|
1326
|
+
// that does not center itself lands in the top-left corner. Unlike
|
|
1327
|
+
// forceFinalVisualState this is NOT gated on hasDeclaredInteractiveState:
|
|
1328
|
+
// an isolation page with a declared interaction still needs to be centered,
|
|
1329
|
+
// and moving the wrapper does not clobber its contents the way
|
|
1330
|
+
// animation-forcing does — so a replayed interaction sequence stays valid.
|
|
1331
|
+
if (outputPath && loaded) {
|
|
1332
|
+
await centerCaptureWrapper(frame).catch(() => {});
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1077
1335
|
if (outputPath && loaded) {
|
|
1078
1336
|
fs.mkdirSync(path.dirname(outputPath), { recursive: true });
|
|
1079
1337
|
await page.screenshot({ path: outputPath, fullPage: false });
|
|
@@ -1085,6 +1343,7 @@ async function runScenarioCheck(
|
|
|
1085
1343
|
issues,
|
|
1086
1344
|
outputPath,
|
|
1087
1345
|
url: frame.url() || url,
|
|
1346
|
+
unmockedRoutes,
|
|
1088
1347
|
});
|
|
1089
1348
|
|
|
1090
1349
|
if (config.interaction) {
|
|
@@ -1092,6 +1351,14 @@ async function runScenarioCheck(
|
|
|
1092
1351
|
result.interactionRetried = interactionRetried;
|
|
1093
1352
|
}
|
|
1094
1353
|
|
|
1354
|
+
// Surface any non-fatal interaction/flow warnings (e.g. an ambiguous
|
|
1355
|
+
// substring text match) so the CLI can print them and the agent can switch
|
|
1356
|
+
// to a precise selector. Only attached when present — a clean run is
|
|
1357
|
+
// byte-for-byte unchanged.
|
|
1358
|
+
if (interactionWarnings.length > 0) {
|
|
1359
|
+
result.warnings = interactionWarnings.slice();
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1095
1362
|
// `capture-state` mode: attach the read-only page-state snapshot so the
|
|
1096
1363
|
// backend can report localStorage + rendered text. Off by default, so a
|
|
1097
1364
|
// normal error-check capture is byte-for-byte unchanged.
|
|
@@ -1119,6 +1386,7 @@ async function runScenarioCheck(
|
|
|
1119
1386
|
issues,
|
|
1120
1387
|
outputPath,
|
|
1121
1388
|
url,
|
|
1389
|
+
unmockedRoutes,
|
|
1122
1390
|
});
|
|
1123
1391
|
} finally {
|
|
1124
1392
|
await browser.close();
|
|
@@ -1146,6 +1414,7 @@ async function main() {
|
|
|
1146
1414
|
|
|
1147
1415
|
module.exports = {
|
|
1148
1416
|
runScenarioCheck,
|
|
1417
|
+
classifyInteractionEffect,
|
|
1149
1418
|
mergeVisibleTextLength,
|
|
1150
1419
|
runFlowSteps,
|
|
1151
1420
|
dumpPageState,
|
|
@@ -1153,6 +1422,9 @@ module.exports = {
|
|
|
1153
1422
|
readStackLoadingMarkers,
|
|
1154
1423
|
scenarioScriptsLiveSocket,
|
|
1155
1424
|
applyBrowserState,
|
|
1425
|
+
buildSessionTokenCookies,
|
|
1426
|
+
captureAuthSkewDetected,
|
|
1427
|
+
SESSION_COOKIE,
|
|
1156
1428
|
isCrossOriginRequest,
|
|
1157
1429
|
isCaptureFatalRequestFailure,
|
|
1158
1430
|
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) {
|