@codeyam-editor/codeyam-editor 0.1.4 → 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-branch-switch.js +1 -1
- package/npm/cloud.js +3166 -150
- package/npm/codesign-plan.js +68 -0
- package/npm/container.js +3 -0
- package/npm/disk-pressure.js +78 -0
- package/npm/editor-dev.js +96 -15
- package/npm/editor.js +85 -37
- 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 +213 -0
- package/npm/fleet-auth.js +6 -1
- package/npm/fleet-build-data.js +109 -5
- package/npm/fleet-cleanup.js +232 -16
- package/npm/fleet-destroy-safety.js +272 -0
- package/npm/fleet-early-roster.js +87 -0
- package/npm/fleet-effect-poll.js +110 -0
- package/npm/fleet-effect-status.js +33 -32
- 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-host.js +74 -0
- package/npm/fleet-iap-token.js +165 -0
- package/npm/fleet-job-badge.js +1 -1
- package/npm/fleet-launchd.js +171 -0
- package/npm/fleet-live-status.js +155 -0
- package/npm/fleet-memory.js +245 -9
- package/npm/fleet-plans.js +233 -4
- package/npm/fleet-pool.js +75 -16
- package/npm/fleet-prewarm.js +241 -0
- package/npm/fleet-project-drift.js +58 -0
- package/npm/fleet-public-url.js +394 -0
- package/npm/fleet-queue.js +95 -1
- package/npm/fleet-reachability-debounce.js +69 -0
- package/npm/fleet-reachability.js +80 -0
- package/npm/fleet-reconcile.js +43 -1
- package/npm/fleet-reset.js +330 -13
- package/npm/fleet-roster.js +6 -8
- package/npm/fleet-running-plan.js +85 -0
- package/npm/fleet-self-update.js +142 -0
- package/npm/fleet-session-token-cache.js +65 -0
- package/npm/fleet-setup-steps.js +112 -0
- package/npm/fleet-sleep-status.js +33 -0
- package/npm/fleet-status-refs.js +96 -0
- package/npm/fleet-strand.js +181 -0
- package/npm/fleet-stuck-status.js +117 -0
- package/npm/fleet-vm-access.js +404 -0
- package/npm/fleet-vm-probe-target.js +282 -0
- package/npm/fleet-vm-readiness.js +237 -0
- package/npm/fleet-vm-slugs.js +116 -0
- package/npm/google-ingress.js +226 -0
- package/npm/ingress-invoke.js +770 -0
- package/npm/launch-config.js +386 -9
- package/npm/scenario-check.js +880 -37
- package/npm/scenario-handlers.js +154 -30
- package/npm/scenario-interactivity.js +145 -6
- package/npm/scenario-issues.js +13 -1
- package/npm/scenario-metrics.js +116 -1
- package/npm/scenario-mocks.js +144 -21
- package/npm/scenario-playwright.js +649 -38
- package/npm/script-drift.js +152 -0
- package/npm/utils.js +365 -52
- package/npm/vm-urls.js +69 -8
- package/package.json +5 -5
- package/ui/dist/assets/ContainerInspector-DgM0pTAx.js +2 -0
- package/ui/dist/assets/EditorShell-B7Q06uJ_.js +252 -0
- package/ui/dist/assets/EditorShell-DP5UY20G.css +1 -0
- package/ui/dist/assets/{Markdown-BHBqz-Mj.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-BLqRCxQX.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CIAxfHPh.js +21 -0
- package/ui/dist/assets/StepsSimulator-CymGgfbu.js +1 -0
- package/ui/dist/assets/index-BygCiJSb.css +1 -0
- package/ui/dist/assets/{index-BuK0lizt.js → index-DCHqRrwO.js} +9 -9
- 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-S3Qk1Y_d.js +1 -0
- package/ui/dist/assets/useServerIdentity-B7rI0u3O.js +1 -0
- package/ui/dist/codeyam-mark.svg +4 -0
- package/ui/dist/codeyam-wordmark.svg +5 -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/ContainerInspector-C8EkH4PP.js +0 -2
- package/ui/dist/assets/EditorShell-tMMdarZD.js +0 -187
- package/ui/dist/assets/ScenarioDataPanel-xWYGZOpo.js +0 -5
- package/ui/dist/assets/ScenarioDataPanelFullPage-5G6KuZgw.js +0 -1
- package/ui/dist/assets/ScenarioRenderer-CSdhX1Ak.js +0 -1
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +0 -1
- package/ui/dist/assets/index-BGqairFV.css +0 -1
- package/ui/dist/assets/react-CSS0HapR.js +0 -28
- package/ui/dist/assets/useEvents-DggWrtHe.js +0 -1
package/npm/scenario-handlers.js
CHANGED
|
@@ -1,6 +1,128 @@
|
|
|
1
1
|
const { createIssue } = require("./scenario-issues");
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
// Known substrings that mean the app refused to initialize because it was loaded
|
|
4
|
+
// from an *insecure context* — a bare dotted-quad IP rather than HTTPS or the
|
|
5
|
+
// hostname `localhost`. This is a whole CLASS of failure (Sveltia CMS's
|
|
6
|
+
// "only works with HTTPS or localhost", anything gating on `isSecureContext`,
|
|
7
|
+
// `crypto.subtle`, service workers, `Secure` cookies, WebAuthn), not one app.
|
|
8
|
+
// Matching reclassifies the error as an actionable `insecure-host` advisory
|
|
9
|
+
// instead of a generic console/page failure that surfaces as a bare
|
|
10
|
+
// `screenshot=null` with no explanation. Substrings are matched
|
|
11
|
+
// case-insensitively; keep the set small and well-known.
|
|
12
|
+
const INSECURE_CONTEXT_SIGNATURES = [
|
|
13
|
+
"only works with https or localhost",
|
|
14
|
+
"issecurecontext",
|
|
15
|
+
"secure context",
|
|
16
|
+
"requires https",
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// The single actionable message the `insecure-host` advisory carries. Names the
|
|
20
|
+
// class of failure and the fix (the preview origin is `localhost` by default;
|
|
21
|
+
// stubborn apps can opt into HTTPS) rather than echoing one app's raw error.
|
|
22
|
+
const INSECURE_HOST_ADVISORY_MESSAGE =
|
|
23
|
+
"The app refused to run because it was loaded from an insecure context " +
|
|
24
|
+
"(a bare IP). It requires a secure context — HTTPS or the hostname " +
|
|
25
|
+
"`localhost`. The preview origin is `localhost` by default; if this persists, " +
|
|
26
|
+
"the app may require HTTPS even on localhost — enable `proxy.httpsPreview` in " +
|
|
27
|
+
".codeyam/editor.json.";
|
|
28
|
+
|
|
29
|
+
// Reclassify an error string as an `insecure-host` advisory when it matches a
|
|
30
|
+
// known insecure-context signature, else null. Pure; the original text is kept
|
|
31
|
+
// as the `contextSnippet` so the operator can still see what the app logged.
|
|
32
|
+
function insecureContextAdvisory(text) {
|
|
33
|
+
if (typeof text !== "string" || text.length === 0) return null;
|
|
34
|
+
const lower = text.toLowerCase();
|
|
35
|
+
const matched = INSECURE_CONTEXT_SIGNATURES.some((sig) => lower.includes(sig));
|
|
36
|
+
if (!matched) return null;
|
|
37
|
+
return createIssue("insecure-host", INSECURE_HOST_ADVISORY_MESSAGE, {
|
|
38
|
+
contextSnippet: text,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
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
|
+
|
|
47
|
+
// `allowWebSocket` keeps the real `WebSocket` for scenarios that script a
|
|
48
|
+
// `/ws/terminal` transcript (or a WS stream): those captures NEED the socket to
|
|
49
|
+
// connect so the server replays the scripted agent state into the frame. The
|
|
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
|
|
52
|
+
// playback would otherwise screenshot the "Reconnecting…" overlay); for a
|
|
53
|
+
// scripted scenario the server holds the socket open after replay, so there is
|
|
54
|
+
// no reconnect spam to silence and stubbing only hides the very state we are
|
|
55
|
+
// trying to capture. The flag is computed by the capture orchestrator from the
|
|
56
|
+
// scenario's `mocks.transcripts` / `mocks.streams` — see
|
|
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.
|
|
68
|
+
function getInitScript(allowWebSocket = false) {
|
|
69
|
+
const webSocketStub = allowWebSocket
|
|
70
|
+
? ""
|
|
71
|
+
: `
|
|
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);
|
|
118
|
+
}
|
|
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
|
+
})();`;
|
|
4
126
|
return `
|
|
5
127
|
window.__codeyamUnhandledRejections = [];
|
|
6
128
|
window.addEventListener("unhandledrejection", (event) => {
|
|
@@ -9,41 +131,29 @@ function getInitScript() {
|
|
|
9
131
|
reason instanceof Error ? reason.message : String(reason);
|
|
10
132
|
window.__codeyamUnhandledRejections.push(message);
|
|
11
133
|
});
|
|
12
|
-
|
|
13
|
-
// Stub WebSocket during capture to prevent terminal reconnection spam.
|
|
14
|
-
window.WebSocket = class StubWebSocket {
|
|
15
|
-
static CONNECTING = 0;
|
|
16
|
-
static OPEN = 1;
|
|
17
|
-
static CLOSING = 2;
|
|
18
|
-
static CLOSED = 3;
|
|
19
|
-
readyState = 3;
|
|
20
|
-
onopen = null;
|
|
21
|
-
onclose = null;
|
|
22
|
-
onerror = null;
|
|
23
|
-
onmessage = null;
|
|
24
|
-
send() {}
|
|
25
|
-
close() {}
|
|
26
|
-
addEventListener() {}
|
|
27
|
-
removeEventListener() {}
|
|
28
|
-
dispatchEvent() { return false; }
|
|
29
|
-
constructor() {
|
|
30
|
-
setTimeout(() => {
|
|
31
|
-
if (this.onerror) this.onerror(new Event("error"));
|
|
32
|
-
if (this.onclose) this.onclose(new CloseEvent("close"));
|
|
33
|
-
}, 0);
|
|
34
|
-
}
|
|
35
|
-
};
|
|
134
|
+
${webSocketStub}
|
|
36
135
|
`;
|
|
37
136
|
}
|
|
38
137
|
|
|
39
138
|
function handleConsoleMessage(message) {
|
|
40
139
|
if (message.type() !== "error") return null;
|
|
41
140
|
const text = message.text();
|
|
42
|
-
|
|
43
|
-
//
|
|
141
|
+
|
|
142
|
+
// An insecure-context refusal wins over the generic `console` classification:
|
|
143
|
+
// it is the targeted, actionable signal for the secure-context app class.
|
|
144
|
+
const advisory = insecureContextAdvisory(text);
|
|
145
|
+
if (advisory) return advisory;
|
|
146
|
+
|
|
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.
|
|
44
153
|
if (
|
|
45
154
|
text.includes("WebSocket connection to") ||
|
|
46
|
-
text.includes("Unsupported Media Type")
|
|
155
|
+
text.includes("Unsupported Media Type") ||
|
|
156
|
+
text.includes("dynamically imported module")
|
|
47
157
|
) {
|
|
48
158
|
return null;
|
|
49
159
|
}
|
|
@@ -64,11 +174,22 @@ function handleConsoleMessage(message) {
|
|
|
64
174
|
return null;
|
|
65
175
|
}
|
|
66
176
|
|
|
67
|
-
|
|
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 } : {});
|
|
68
184
|
}
|
|
69
185
|
|
|
70
186
|
function handlePageError(error) {
|
|
71
|
-
|
|
187
|
+
const text = error.message || String(error);
|
|
188
|
+
// A secure-context guard often throws at boot rather than logging — surface
|
|
189
|
+
// the same actionable advisory for the pageerror path.
|
|
190
|
+
const advisory = insecureContextAdvisory(text);
|
|
191
|
+
if (advisory) return advisory;
|
|
192
|
+
return createIssue("pageerror", text);
|
|
72
193
|
}
|
|
73
194
|
|
|
74
195
|
function handleRequestFailed(request) {
|
|
@@ -95,4 +216,7 @@ module.exports = {
|
|
|
95
216
|
handleConsoleMessage,
|
|
96
217
|
handlePageError,
|
|
97
218
|
handleRequestFailed,
|
|
219
|
+
insecureContextAdvisory,
|
|
220
|
+
INSECURE_CONTEXT_SIGNATURES,
|
|
221
|
+
INSECURE_HOST_ADVISORY_MESSAGE,
|
|
98
222
|
};
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
382
|
+
probeHydrationState,
|
|
383
|
+
waitForHydration,
|
|
245
384
|
};
|
package/npm/scenario-issues.js
CHANGED
|
@@ -17,7 +17,14 @@ function pushIssue(issues, issue) {
|
|
|
17
17
|
}
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
-
function buildResult({
|
|
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
|
|
package/npm/scenario-metrics.js
CHANGED
|
@@ -25,6 +25,34 @@ function hasLoadingMarkers(text, extraMarkers = []) {
|
|
|
25
25
|
|
|
26
26
|
function hasRenderableContent(state) {
|
|
27
27
|
if (!state) return false;
|
|
28
|
+
|
|
29
|
+
// Reveal-suppression guard. A scroll-gated entrance animation starts its
|
|
30
|
+
// content at `opacity:0` and reveals it via an IntersectionObserver wired
|
|
31
|
+
// from the app shell. Captured in isolation (no shell), the observer never
|
|
32
|
+
// fires, so the text renders into the DOM — `bodyTextLength`/`rootTextLength`
|
|
33
|
+
// are non-zero — yet stays visually invisible and the screenshot is blank.
|
|
34
|
+
// `visibleTextLength` is the length of text actually PAINTED to the frame
|
|
35
|
+
// (computed-style opacity/visibility aware). When the DOM carries text but
|
|
36
|
+
// none of it is visible AND there is no visible media, the frame is
|
|
37
|
+
// near-blank: report no content so the blank gate rejects it instead of
|
|
38
|
+
// waving through an empty shell. `visibleTextLength` is optional — older
|
|
39
|
+
// capture configs (and stubbed test targets) omit it, in which case the
|
|
40
|
+
// original DOM-presence behavior below is preserved byte-for-byte.
|
|
41
|
+
const domTextLength = Math.max(
|
|
42
|
+
state.bodyTextLength || 0,
|
|
43
|
+
state.rootTextLength || 0,
|
|
44
|
+
);
|
|
45
|
+
const hasVisibleMedia =
|
|
46
|
+
(state.loadedImageCount || 0) > 0 || (state.mediaBboxCount || 0) > 0;
|
|
47
|
+
if (
|
|
48
|
+
typeof state.visibleTextLength === "number" &&
|
|
49
|
+
domTextLength > 0 &&
|
|
50
|
+
state.visibleTextLength === 0 &&
|
|
51
|
+
!hasVisibleMedia
|
|
52
|
+
) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
|
|
28
56
|
if (
|
|
29
57
|
state.rootChildCount > 0 ||
|
|
30
58
|
state.rootTextLength > 0 ||
|
|
@@ -40,7 +68,17 @@ function hasRenderableContent(state) {
|
|
|
40
68
|
function describeBlankReason(state) {
|
|
41
69
|
if (!state) return "no content state collected";
|
|
42
70
|
const parts = [];
|
|
43
|
-
|
|
71
|
+
// Distinguish "no text in the DOM at all" from "text rendered but nothing is
|
|
72
|
+
// visible" — the latter is the reveal-suppression symptom (opacity:0
|
|
73
|
+
// entrance content whose observer never fired in isolation), which points
|
|
74
|
+
// the author at a different fix than a genuinely empty page.
|
|
75
|
+
if (
|
|
76
|
+
typeof state.visibleTextLength === "number" &&
|
|
77
|
+
state.bodyTextLength > 0 &&
|
|
78
|
+
state.visibleTextLength === 0
|
|
79
|
+
) {
|
|
80
|
+
parts.push("text rendered but not visible (reveal-suppressed?)");
|
|
81
|
+
} else if (!(state.bodyTextLength > 0)) {
|
|
44
82
|
parts.push("no text");
|
|
45
83
|
}
|
|
46
84
|
const imageCount = state.imageCount || 0;
|
|
@@ -69,6 +107,16 @@ const ERROR_PATTERNS = [
|
|
|
69
107
|
"Scenario Error",
|
|
70
108
|
];
|
|
71
109
|
|
|
110
|
+
// The DOM attribute codeyam's own ScenarioRenderer stamps on its error and
|
|
111
|
+
// seed-error fallback frames. Error detection anchors on this marker rather
|
|
112
|
+
// than scanning the whole page body for ERROR_PATTERNS: a legitimately-mocked
|
|
113
|
+
// scenario whose *content* quotes one of those harness phrases (a journal entry
|
|
114
|
+
// describing the error component, say) must not be flagged as a failed capture.
|
|
115
|
+
// Arbitrary client apps that never render a codeyam ScenarioRenderer never emit
|
|
116
|
+
// the marker, so their pages are treated as healthy regardless of body text —
|
|
117
|
+
// these patterns were always codeyam-harness-specific, never generic.
|
|
118
|
+
const SCENARIO_ERROR_MARKER = "data-codeyam-scenario-error";
|
|
119
|
+
|
|
72
120
|
function hasErrorPatterns(text) {
|
|
73
121
|
return ERROR_PATTERNS.some((pattern) => text.includes(pattern));
|
|
74
122
|
}
|
|
@@ -95,14 +143,81 @@ function buildErrorContextSnippet(text, pattern) {
|
|
|
95
143
|
return `${prefix}${slice}${suffix}`;
|
|
96
144
|
}
|
|
97
145
|
|
|
146
|
+
// Classify a scenario-error fallback into a `{ matchedPattern, contextSnippet }`
|
|
147
|
+
// from the marker info collected off the page. `marker` is the
|
|
148
|
+
// `{ reason, text }` read from the `[data-codeyam-scenario-error]` element, or
|
|
149
|
+
// null when no such element is present. Returns null when there is no marked
|
|
150
|
+
// fallback — the page is healthy and no error-content failure is raised.
|
|
151
|
+
//
|
|
152
|
+
// When the marked fallback text contains a known ERROR_PATTERN (the render-error
|
|
153
|
+
// path renders `Component "X" not found in registry`), that pattern is named so
|
|
154
|
+
// the downstream Rust classifier can still extract the component; otherwise the
|
|
155
|
+
// marker's `reason` attribute classifies it (the seed-error fallback, whose
|
|
156
|
+
// "Seed Error" copy is intentionally not an ERROR_PATTERN). Because the text is
|
|
157
|
+
// scoped to the fallback element — not the whole body — content elsewhere on the
|
|
158
|
+
// page can quote these phrases freely without tripping detection.
|
|
159
|
+
function findScenarioError(marker) {
|
|
160
|
+
// The capture passes the `frame.evaluate` result of querying the marker
|
|
161
|
+
// element: either null (no fallback rendered) or a `{reason, text}` object.
|
|
162
|
+
// Anything else (a bare string, a primitive) is not a marked error — guard
|
|
163
|
+
// so a non-object never reads as a present marker and false-flags the page.
|
|
164
|
+
if (!marker || typeof marker !== "object") return null;
|
|
165
|
+
const text = marker.text || "";
|
|
166
|
+
const reason = marker.reason || "";
|
|
167
|
+
const matchedPattern = findErrorPattern(text) || reason || "scenario error";
|
|
168
|
+
const contextSnippet =
|
|
169
|
+
buildErrorContextSnippet(text, matchedPattern) ||
|
|
170
|
+
(text ? text.replace(/\s+/g, " ").trim().slice(0, 200) : null);
|
|
171
|
+
return { matchedPattern, contextSnippet };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Build the non-blocking "this page looks client-fetched" advisory from the two
|
|
175
|
+
// settle signals the capture already computes: `stableOutcome` from
|
|
176
|
+
// waitForStablePage (did the page settle, and was a loading marker still up at
|
|
177
|
+
// the cap?) and `networkOutcome` from waitForNetworkQuiet (did in-flight network
|
|
178
|
+
// go quiet, or did the bounded wait cap out?). Captures settle within a bounded
|
|
179
|
+
// window so a never-idle stream can't hang them — which means a slower
|
|
180
|
+
// client-side fetch lands AFTER the window and the screenshot catches the
|
|
181
|
+
// loading/initial state instead of the populated one. When either signal fires,
|
|
182
|
+
// name the cause and the two reliable fixes (server-render the data, or drive a
|
|
183
|
+
// props-driven isolated component scenario) so the agent reaches for those up
|
|
184
|
+
// front instead of rediscovering the constraint by trial and error. Returns the
|
|
185
|
+
// advisory string, or null when the page settled cleanly (the SSR / props-driven
|
|
186
|
+
// happy path — no advisory, so a healthy capture stays noise-free).
|
|
187
|
+
function buildSettleAdvisory(stableOutcome, networkOutcome) {
|
|
188
|
+
const causes = [];
|
|
189
|
+
if (
|
|
190
|
+
stableOutcome &&
|
|
191
|
+
stableOutcome.stabilized === false &&
|
|
192
|
+
stableOutcome.hadLoadingMarkers === true
|
|
193
|
+
) {
|
|
194
|
+
causes.push("a loading marker was still on the page");
|
|
195
|
+
}
|
|
196
|
+
if (networkOutcome && networkOutcome.quiet === false) {
|
|
197
|
+
causes.push("network requests were still in flight");
|
|
198
|
+
}
|
|
199
|
+
if (causes.length === 0) return null;
|
|
200
|
+
return (
|
|
201
|
+
`When this page was captured, ${causes.join(" and ")} — it likely fetches ` +
|
|
202
|
+
`its data on the client. Captures settle within a bounded window, so data ` +
|
|
203
|
+
`that arrives via a client-side fetch after that window renders as the ` +
|
|
204
|
+
`loading/initial state, not the populated state. To capture a populated ` +
|
|
205
|
+
`state, server-render the data (SSR/RSC) or drive a props-driven isolated ` +
|
|
206
|
+
`component scenario.`
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
98
210
|
module.exports = {
|
|
99
211
|
hasLoadingMarkers,
|
|
100
212
|
hasRenderableContent,
|
|
213
|
+
buildSettleAdvisory,
|
|
101
214
|
describeBlankReason,
|
|
102
215
|
shouldStopWaitingForImages,
|
|
103
216
|
hasErrorPatterns,
|
|
104
217
|
findErrorPattern,
|
|
218
|
+
findScenarioError,
|
|
105
219
|
buildErrorContextSnippet,
|
|
106
220
|
ERROR_PATTERNS,
|
|
221
|
+
SCENARIO_ERROR_MARKER,
|
|
107
222
|
ERROR_CONTEXT_RADIUS,
|
|
108
223
|
};
|