@codeyam-editor/codeyam-editor 0.1.1 → 0.1.2
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 -2
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +1521 -0
- package/npm/container.js +22 -3
- package/npm/editor-dev.js +286 -36
- package/npm/editor.js +98 -35
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-cleanup.js +124 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-memory.js +127 -0
- package/npm/fleet-pool-policy.js +78 -0
- package/npm/fleet-pool.js +401 -0
- package/npm/fleet-queue.js +40 -0
- package/npm/fleet-reconcile.js +36 -0
- package/npm/fleet-reset.js +144 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +544 -0
- package/npm/preflight-guidance.js +68 -0
- package/npm/scenario-check.js +298 -14
- package/npm/scenario-handlers.js +16 -5
- package/npm/scenario-interactivity.js +199 -0
- package/npm/scenario-metrics.js +16 -2
- package/npm/scenario-mocks.js +62 -25
- package/npm/scenario-playwright.js +169 -6
- package/npm/utils.js +507 -21
- package/npm/vm-urls.js +117 -0
- package/package.json +8 -7
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-DWaeBxgj.js +186 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanel-zJSXtXL4.js +5 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-C0YHQB-T.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CgDxuigm.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-BUYNFqKK.js +27 -0
- package/ui/dist/assets/index-DVr5rY2d.css +1 -0
- package/ui/dist/assets/markdown-v0F0UEt3.js +37 -0
- package/ui/dist/assets/react-CSS0HapR.js +28 -0
- package/ui/dist/assets/useEvents-DggWrtHe.js +1 -0
- package/ui/dist/assets/xterm--24IGk-x.js +9 -0
- package/ui/dist/favicon.ico +0 -0
- package/ui/dist/favicon.png +0 -0
- package/ui/dist/index.html +16 -0
- package/npm/postinstall.js +0 -132
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const path = require("path");
|
|
3
|
+
const { createIssue } = require("./scenario-issues");
|
|
4
|
+
|
|
5
|
+
// Hydration / interactivity probe for the headless capture browser.
|
|
6
|
+
//
|
|
7
|
+
// Background: a page can return HTTP 200, render visible content, log no
|
|
8
|
+
// console errors, and still be completely dead — the client framework never
|
|
9
|
+
// hydrated, so every button and handler is inert. The status / render /
|
|
10
|
+
// console / image gates all pass and the broken page sails through to the
|
|
11
|
+
// user (the catalog whose filter buttons did nothing). This probe asserts the
|
|
12
|
+
// page actually became interactive before the capture gate reports success,
|
|
13
|
+
// so a non-hydrating page can no longer pass `client-errors`.
|
|
14
|
+
//
|
|
15
|
+
// Stack assumption: WHICH framework's hydration we look for is data-driven
|
|
16
|
+
// from `.codeyam/stack.json` (or an explicit override) — never hardcoded into
|
|
17
|
+
// the capture flow. Stacks with no client runtime (backend services, CLIs,
|
|
18
|
+
// static HTML) are a documented no-op pass. Frameworks for which we have no
|
|
19
|
+
// reliable in-page attachment signal are ALSO a conservative pass: we never
|
|
20
|
+
// flag a page we cannot prove is dead, so a healthy Svelte / Solid / vanilla
|
|
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.
|
|
23
|
+
|
|
24
|
+
// Frameworks we have an in-page attachment detector for. Inference only
|
|
25
|
+
// resolves to one of these; an unrecognised framework yields `null`, which
|
|
26
|
+
// the caller treats as "cannot determine" (conservative pass).
|
|
27
|
+
const KNOWN_FRAMEWORKS = ["react", "vue"];
|
|
28
|
+
|
|
29
|
+
// Read `.codeyam/stack.json` relative to the capture script's cwd (the project
|
|
30
|
+
// dir — `scenario_check.rs` sets `.current_dir(project_dir)`). Never throws: a
|
|
31
|
+
// missing or malformed file yields `null` so the probe degrades to a no-op
|
|
32
|
+
// rather than breaking a capture.
|
|
33
|
+
function readStackJson() {
|
|
34
|
+
try {
|
|
35
|
+
const raw = fs.readFileSync(path.join(".codeyam", "stack.json"), "utf8");
|
|
36
|
+
return JSON.parse(raw);
|
|
37
|
+
} catch (_) {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Scan a stack descriptor's identity fields for a framework we can probe.
|
|
43
|
+
// Pure — no I/O — so the matching rules are unit-tested without a stack.json.
|
|
44
|
+
function inferFramework(stack) {
|
|
45
|
+
if (!stack) return null;
|
|
46
|
+
const haystack = [
|
|
47
|
+
stack.id,
|
|
48
|
+
stack.name,
|
|
49
|
+
...(Array.isArray(stack.technologies) ? stack.technologies : []),
|
|
50
|
+
]
|
|
51
|
+
.filter((s) => typeof s === "string")
|
|
52
|
+
.join(" ")
|
|
53
|
+
.toLowerCase();
|
|
54
|
+
for (const fw of KNOWN_FRAMEWORKS) {
|
|
55
|
+
if (haystack.includes(fw)) return fw;
|
|
56
|
+
}
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Decide, from a stack descriptor, whether the capture should expect a
|
|
61
|
+
// hydrated client runtime and which framework to probe for. Pure.
|
|
62
|
+
//
|
|
63
|
+
// `capture.interactivity === false` is an explicit opt-out for stacks that
|
|
64
|
+
// render no client runtime. `capture.interactivity.framework` is an explicit
|
|
65
|
+
// override when inference can't see the framework in the identity fields.
|
|
66
|
+
// Otherwise we infer: a known client framework, or `routing.type ===
|
|
67
|
+
// "client-side"`, implies a runtime that must hydrate; backend / static / CLI
|
|
68
|
+
// stacks match neither and no-op.
|
|
69
|
+
function resolveInteractivityExpectation(stack) {
|
|
70
|
+
const capture = (stack && stack.capture) || {};
|
|
71
|
+
if (capture.interactivity === false) {
|
|
72
|
+
return { expectInteractive: false, framework: null };
|
|
73
|
+
}
|
|
74
|
+
const explicit =
|
|
75
|
+
capture.interactivity && typeof capture.interactivity === "object"
|
|
76
|
+
? capture.interactivity.framework
|
|
77
|
+
: null;
|
|
78
|
+
if (typeof explicit === "string" && explicit.length > 0) {
|
|
79
|
+
return { expectInteractive: true, framework: explicit.toLowerCase() };
|
|
80
|
+
}
|
|
81
|
+
const framework = inferFramework(stack);
|
|
82
|
+
const routingType =
|
|
83
|
+
stack && stack.routing && typeof stack.routing.type === "string"
|
|
84
|
+
? stack.routing.type
|
|
85
|
+
: null;
|
|
86
|
+
const expectInteractive = framework != null || routingType === "client-side";
|
|
87
|
+
return { expectInteractive, framework };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Run the in-page detection inside the loaded frame. Read-only: it inspects
|
|
91
|
+
// DOM-node properties left by a framework's hydration but never clicks or
|
|
92
|
+
// mutates anything, so it is safe to run before the screenshot is taken.
|
|
93
|
+
//
|
|
94
|
+
// Returns `{ controlCount, frameworkAttached }` where `frameworkAttached` is
|
|
95
|
+
// `true` (runtime demonstrably attached), `false` (controls exist but no
|
|
96
|
+
// attachment signal — the dead-hydration case), or `null` (no detector for
|
|
97
|
+
// this framework → cannot judge).
|
|
98
|
+
async function collectHydrationState(frame, { framework } = {}) {
|
|
99
|
+
return frame.evaluate((fw) => {
|
|
100
|
+
const SELECTOR =
|
|
101
|
+
'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, summary, [onclick]';
|
|
102
|
+
const controls = Array.from(document.querySelectorAll(SELECTOR));
|
|
103
|
+
|
|
104
|
+
// Per-framework attachment detectors. Each returns true when the framework
|
|
105
|
+
// has demonstrably attached its client runtime to a live interactive node.
|
|
106
|
+
// The presence of a detector for `fw` is itself the signal that we CAN
|
|
107
|
+
// judge this framework; the default (no detector) means "cannot determine".
|
|
108
|
+
const detectors = {
|
|
109
|
+
react: (els) =>
|
|
110
|
+
els.some(
|
|
111
|
+
(el) =>
|
|
112
|
+
!!el &&
|
|
113
|
+
Object.keys(el).some(
|
|
114
|
+
(k) =>
|
|
115
|
+
k.startsWith("__reactFiber$") ||
|
|
116
|
+
k.startsWith("__reactProps$") ||
|
|
117
|
+
k.startsWith("__reactContainer$"),
|
|
118
|
+
),
|
|
119
|
+
),
|
|
120
|
+
vue: (els) =>
|
|
121
|
+
els.some(
|
|
122
|
+
(el) =>
|
|
123
|
+
!!el &&
|
|
124
|
+
(el.__vue__ != null ||
|
|
125
|
+
el.__vnode != null ||
|
|
126
|
+
el.__vueParentComponent != null),
|
|
127
|
+
) || !!document.querySelector("[data-v-app]"),
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
const detector = detectors[fw];
|
|
131
|
+
const frameworkAttached = detector ? detector(controls) : null;
|
|
132
|
+
return { controlCount: controls.length, frameworkAttached };
|
|
133
|
+
}, framework || null);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// Turn a collected state into a `hydration` issue, or `null` to pass. Pure, so
|
|
137
|
+
// every branch is unit-tested without a browser.
|
|
138
|
+
//
|
|
139
|
+
// Pass when: no client runtime expected (no-op stacks); no interactive control
|
|
140
|
+
// to probe (a static content page in a client app is legitimately inert); the
|
|
141
|
+
// framework attached; OR we couldn't determine attachment (`null` — never
|
|
142
|
+
// false-positive). Flag only the proven-dead case: controls exist AND the
|
|
143
|
+
// framework's runtime is demonstrably not attached.
|
|
144
|
+
function interpretHydration({
|
|
145
|
+
expectInteractive,
|
|
146
|
+
controlCount,
|
|
147
|
+
frameworkAttached,
|
|
148
|
+
framework,
|
|
149
|
+
url,
|
|
150
|
+
}) {
|
|
151
|
+
if (!expectInteractive) return null;
|
|
152
|
+
if (!(controlCount > 0)) return null;
|
|
153
|
+
if (frameworkAttached !== false) return null;
|
|
154
|
+
const fw = framework || "the client framework";
|
|
155
|
+
const plural = controlCount === 1 ? "" : "s";
|
|
156
|
+
return createIssue(
|
|
157
|
+
"hydration",
|
|
158
|
+
`Page rendered ${controlCount} interactive control${plural} but ${fw} never attached ` +
|
|
159
|
+
`event handlers — the page is not interactive (hydration did not run). Client JS may ` +
|
|
160
|
+
`not be executing; check the preview proxy and the browser console. Run ` +
|
|
161
|
+
"`codeyam-editor editor diagnose-preview --path <route>` to pinpoint a proxy " +
|
|
162
|
+
`HTML-injection blocker.`,
|
|
163
|
+
{ url: url ?? null },
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Orchestrator called from the capture flow: resolve the expectation (from the
|
|
168
|
+
// project's stack.json unless the caller injects a `stack`), short-circuit when
|
|
169
|
+
// no client runtime is expected, collect the in-page state, and interpret it.
|
|
170
|
+
// Never throws — a probe failure must not break an otherwise-good capture.
|
|
171
|
+
async function probeInteractivity(frame, { url, stack } = {}) {
|
|
172
|
+
const descriptor = stack !== undefined ? stack : readStackJson();
|
|
173
|
+
const { expectInteractive, framework } =
|
|
174
|
+
resolveInteractivityExpectation(descriptor);
|
|
175
|
+
if (!expectInteractive) return null;
|
|
176
|
+
let state;
|
|
177
|
+
try {
|
|
178
|
+
state = await collectHydrationState(frame, { framework });
|
|
179
|
+
} catch (_) {
|
|
180
|
+
return null;
|
|
181
|
+
}
|
|
182
|
+
return interpretHydration({
|
|
183
|
+
expectInteractive: true,
|
|
184
|
+
controlCount: state.controlCount,
|
|
185
|
+
frameworkAttached: state.frameworkAttached,
|
|
186
|
+
framework,
|
|
187
|
+
url,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
module.exports = {
|
|
192
|
+
KNOWN_FRAMEWORKS,
|
|
193
|
+
readStackJson,
|
|
194
|
+
inferFramework,
|
|
195
|
+
resolveInteractivityExpectation,
|
|
196
|
+
collectHydrationState,
|
|
197
|
+
interpretHydration,
|
|
198
|
+
probeInteractivity,
|
|
199
|
+
};
|
package/npm/scenario-metrics.js
CHANGED
|
@@ -5,8 +5,22 @@ const LOADING_MARKERS = [
|
|
|
5
5
|
"disconnected",
|
|
6
6
|
];
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
8
|
+
// `extraMarkers` are project-supplied (from stack.json `capture.loadingMarkers`)
|
|
9
|
+
// because an app's own loading copy ("Loading…", "Please wait") is
|
|
10
|
+
// app-specific and must NOT be hardcoded into the shared harness — only the
|
|
11
|
+
// four codeyam-harness markers above are universal. Matching is
|
|
12
|
+
// case-insensitive so "Loading…" and "loading…" both count; without the
|
|
13
|
+
// project markers a stable app loading screen looks "ready" to
|
|
14
|
+
// waitForStablePage and gets captured mid-hydration.
|
|
15
|
+
function hasLoadingMarkers(text, extraMarkers = []) {
|
|
16
|
+
if (!text) return false;
|
|
17
|
+
const lower = text.toLowerCase();
|
|
18
|
+
const markers = LOADING_MARKERS.concat(
|
|
19
|
+
Array.isArray(extraMarkers) ? extraMarkers : [],
|
|
20
|
+
);
|
|
21
|
+
return markers.some(
|
|
22
|
+
(marker) => marker && lower.includes(String(marker).toLowerCase()),
|
|
23
|
+
);
|
|
10
24
|
}
|
|
11
25
|
|
|
12
26
|
function hasRenderableContent(state) {
|
package/npm/scenario-mocks.js
CHANGED
|
@@ -19,41 +19,76 @@ function findHttpMock(httpMocks, request) {
|
|
|
19
19
|
return null;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
// The set of path/URL targets declared by the mock keys. A key is
|
|
23
|
+
// `"<METHOD> <target>"` (e.g. `"GET /api/plans"`); we strip the method so the
|
|
24
|
+
// route matcher can decide whether a request *could* match any mock without
|
|
25
|
+
// knowing the method (the handler re-checks method via findHttpMock).
|
|
26
|
+
function mockedTargets(httpMocks) {
|
|
27
|
+
const targets = new Set();
|
|
28
|
+
for (const key of Object.keys(httpMocks)) {
|
|
29
|
+
const spaceIdx = key.indexOf(" ");
|
|
30
|
+
if (spaceIdx === -1) continue;
|
|
31
|
+
targets.add(key.slice(spaceIdx + 1));
|
|
32
|
+
}
|
|
33
|
+
return targets;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// True when a request URL matches one of the declared mock targets. Accepts
|
|
37
|
+
// either a string or a WHATWG URL (Playwright's URL-matcher passes a URL).
|
|
38
|
+
function requestTargetsMock(targets, url) {
|
|
39
|
+
const href = typeof url === "string" ? url : url.href;
|
|
40
|
+
return normalizeMockCandidates(href).some((candidate) =>
|
|
41
|
+
targets.has(candidate),
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
22
45
|
async function attachHttpMocks(page, httpMocks) {
|
|
23
46
|
if (!httpMocks || Object.keys(httpMocks).length === 0) return;
|
|
24
47
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
// Intercept ONLY requests whose path matches a declared mock — not every
|
|
49
|
+
// request. A blanket `page.route("**/*")` intercepts the dev server's ESM
|
|
50
|
+
// module/script/style requests too, and routing them through
|
|
51
|
+
// `route.continue()` breaks Vite dev-mode module loading: the lazy app
|
|
52
|
+
// chunks never resolve and the SPA renders blank. Scoping the matcher to
|
|
53
|
+
// mocked targets lets those requests load natively while still mocking the API.
|
|
54
|
+
const targets = mockedTargets(httpMocks);
|
|
55
|
+
await page.route(
|
|
56
|
+
(url) => requestTargetsMock(targets, url),
|
|
57
|
+
async (route) => {
|
|
58
|
+
const mock = findHttpMock(httpMocks, route.request());
|
|
59
|
+
if (!mock) {
|
|
60
|
+
await route.continue();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
31
63
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
64
|
+
const headers = { ...(mock.headers || {}) };
|
|
65
|
+
let body;
|
|
66
|
+
if (mock.body !== undefined) {
|
|
67
|
+
body =
|
|
68
|
+
typeof mock.body === "string"
|
|
69
|
+
? mock.body
|
|
70
|
+
: JSON.stringify(mock.body);
|
|
71
|
+
const hasContentType = Object.keys(headers).some(
|
|
72
|
+
(key) => key.toLowerCase() === "content-type",
|
|
73
|
+
);
|
|
74
|
+
if (!hasContentType) {
|
|
75
|
+
headers["content-type"] = "application/json";
|
|
76
|
+
}
|
|
42
77
|
}
|
|
43
|
-
}
|
|
44
78
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
79
|
+
await route.fulfill({
|
|
80
|
+
status: mock.status || 200,
|
|
81
|
+
headers,
|
|
82
|
+
body,
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
);
|
|
51
86
|
|
|
52
87
|
// Disable the in-page fetch mock by returning an empty active-mocks.json.
|
|
53
88
|
// The HTML injects a script that synchronously loads this file and
|
|
54
89
|
// monkey-patches window.fetch, which would bypass Playwright's route
|
|
55
|
-
// interception. This route is registered AFTER
|
|
56
|
-
// (Playwright uses LIFO ordering for route handlers).
|
|
90
|
+
// interception. This route is registered AFTER the mock matcher so it takes
|
|
91
|
+
// priority for that path (Playwright uses LIFO ordering for route handlers).
|
|
57
92
|
await page.route("**/active-mocks.json", async (route) => {
|
|
58
93
|
await route.fulfill({
|
|
59
94
|
status: 200,
|
|
@@ -66,5 +101,7 @@ async function attachHttpMocks(page, httpMocks) {
|
|
|
66
101
|
module.exports = {
|
|
67
102
|
normalizeMockCandidates,
|
|
68
103
|
findHttpMock,
|
|
104
|
+
mockedTargets,
|
|
105
|
+
requestTargetsMock,
|
|
69
106
|
attachHttpMocks,
|
|
70
107
|
};
|
|
@@ -2,6 +2,92 @@ const {
|
|
|
2
2
|
hasLoadingMarkers,
|
|
3
3
|
shouldStopWaitingForImages,
|
|
4
4
|
} = require("./scenario-metrics");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
|
|
7
|
+
// PROTOTYPE (improve35 capture diagnostics): append a per-phase timing line to
|
|
8
|
+
// a file so we can see WHERE a slow/timed-out capture spends its budget — even
|
|
9
|
+
// when the editor kills this script on timeout (stderr is lost then, but the
|
|
10
|
+
// file survives because each line is flushed synchronously). The cwd is the
|
|
11
|
+
// project dir (scenario_check.rs sets `.current_dir(project_dir)`), so this
|
|
12
|
+
// lands at `<project>/.codeyam/logs/capture-timing.log`. Diagnostics only —
|
|
13
|
+
// never throws, never affects the capture result.
|
|
14
|
+
function logCaptureTiming(phase, data) {
|
|
15
|
+
try {
|
|
16
|
+
const line = `[${new Date().toISOString()}] [capture-timing] phase=${phase} ${JSON.stringify(
|
|
17
|
+
data,
|
|
18
|
+
)}\n`;
|
|
19
|
+
fs.appendFileSync(".codeyam/logs/capture-timing.log", line);
|
|
20
|
+
} catch (_) {
|
|
21
|
+
/* diagnostics must never break a capture */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const net = require("net");
|
|
26
|
+
|
|
27
|
+
// Resolve a URL to the TCP {host, port} a pre-flight connect should target, or
|
|
28
|
+
// null when there is nothing to pre-check — an unparseable URL, or a non-http(s)
|
|
29
|
+
// target like `data:`/blank that the capture renders with no network origin.
|
|
30
|
+
// Pure (no socket) so the parse, protocol gate, and default-port rules are
|
|
31
|
+
// unit-tested without opening a connection.
|
|
32
|
+
function resolveTcpTarget(url) {
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(url);
|
|
36
|
+
} catch (_) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
40
|
+
return {
|
|
41
|
+
host: parsed.hostname,
|
|
42
|
+
port: Number(parsed.port) || (parsed.protocol === "https:" ? 443 : 80),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fast pre-flight (improve35): is anything accepting TCP on the app port? A
|
|
47
|
+
// refused connection (the editor's reverse proxy is down) fails in milliseconds;
|
|
48
|
+
// without this, the iframe's `waitForLoadState("load")` hangs the FULL 30s on a
|
|
49
|
+
// dead origin (the observed capture failure — capture-timing showed
|
|
50
|
+
// navigate-iframe elapsedMs=30004, status=null). A connection that ACCEPTS is
|
|
51
|
+
// good enough to proceed — a slow HTTP response *after* connect is a cold
|
|
52
|
+
// compile, handled by the normal load wait + the editor's retry. So we only bail
|
|
53
|
+
// on a hard refusal/timeout, never on slowness.
|
|
54
|
+
async function assertAppPortReachable(url, { timeoutMs = 2500 } = {}) {
|
|
55
|
+
const target = resolveTcpTarget(url);
|
|
56
|
+
if (!target) return; // non-http target (data:, blank) — nothing to pre-check
|
|
57
|
+
const { host, port } = target;
|
|
58
|
+
const started = Date.now();
|
|
59
|
+
await new Promise((resolve, reject) => {
|
|
60
|
+
const socket = net.connect({ host, port });
|
|
61
|
+
const finish = (err) => {
|
|
62
|
+
socket.destroy();
|
|
63
|
+
const elapsedMs = Date.now() - started;
|
|
64
|
+
logCaptureTiming("app-port-reachable", {
|
|
65
|
+
host,
|
|
66
|
+
port,
|
|
67
|
+
reachable: !err,
|
|
68
|
+
elapsedMs,
|
|
69
|
+
error: err ? err.message : null,
|
|
70
|
+
});
|
|
71
|
+
if (err) reject(err);
|
|
72
|
+
else resolve();
|
|
73
|
+
};
|
|
74
|
+
socket.setTimeout(timeoutMs, () =>
|
|
75
|
+
finish(
|
|
76
|
+
new Error(
|
|
77
|
+
`app port unreachable: TCP connect to ${host}:${port} timed out after ${timeoutMs}ms — the editor's reverse proxy is not accepting connections (proxy down?)`,
|
|
78
|
+
),
|
|
79
|
+
),
|
|
80
|
+
);
|
|
81
|
+
socket.once("connect", () => finish());
|
|
82
|
+
socket.once("error", (e) =>
|
|
83
|
+
finish(
|
|
84
|
+
new Error(
|
|
85
|
+
`app port unreachable: ${host}:${port} ${e.code || e.message} — is the editor's reverse proxy up? (a capture cannot render a dead app port)`,
|
|
86
|
+
),
|
|
87
|
+
),
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
5
91
|
|
|
6
92
|
function escapeHtmlAttribute(value) {
|
|
7
93
|
return String(value).replaceAll("&", "&").replaceAll('"', """);
|
|
@@ -62,7 +148,7 @@ async function collectContentState(target) {
|
|
|
62
148
|
}
|
|
63
149
|
}
|
|
64
150
|
return {
|
|
65
|
-
bodyTextLength: document.body.innerText.trim().length,
|
|
151
|
+
bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
|
|
66
152
|
rootChildCount: root ? root.childElementCount : 0,
|
|
67
153
|
rootTextLength: root ? (root.textContent || "").trim().length : 0,
|
|
68
154
|
imageCount: imgs.length,
|
|
@@ -99,6 +185,18 @@ async function waitForImagesSettled(
|
|
|
99
185
|
}
|
|
100
186
|
const elapsedMs = Date.now() - started;
|
|
101
187
|
const allComplete = images.every((img) => img && img.complete === true);
|
|
188
|
+
const incompleteSrcs = images
|
|
189
|
+
.filter((img) => !img || img.complete !== true || !(img.naturalWidth > 0))
|
|
190
|
+
.map((img) => (img && img.src) || "")
|
|
191
|
+
.slice(0, 6);
|
|
192
|
+
logCaptureTiming("images-settled", {
|
|
193
|
+
elapsedMs,
|
|
194
|
+
settled: allComplete,
|
|
195
|
+
total: images.length,
|
|
196
|
+
incompleteCount: incompleteSrcs.length,
|
|
197
|
+
incompleteSrcs,
|
|
198
|
+
overallTimeoutMs,
|
|
199
|
+
});
|
|
102
200
|
return { settled: allComplete, images, elapsedMs };
|
|
103
201
|
}
|
|
104
202
|
|
|
@@ -121,20 +219,29 @@ async function waitForAnimationsSettled(
|
|
|
121
219
|
return { settled: false, elapsedMs: Date.now() - started };
|
|
122
220
|
}
|
|
123
221
|
|
|
124
|
-
|
|
222
|
+
// `loadingMarkers` are the project's app-specific loading strings (from
|
|
223
|
+
// stack.json `capture.loadingMarkers`); they extend the codeyam-harness
|
|
224
|
+
// defaults so a stable-but-still-loading app screen counts as "not ready"
|
|
225
|
+
// and the loop keeps waiting instead of capturing the loading flash.
|
|
226
|
+
async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers = []) {
|
|
125
227
|
const started = Date.now();
|
|
126
228
|
let lastHtml = "";
|
|
127
229
|
let stableCount = 0;
|
|
230
|
+
let lastHadLoadingMarkers = false;
|
|
231
|
+
let lastHtmlChanged = false;
|
|
128
232
|
|
|
129
233
|
while (Date.now() - started < timeoutMs) {
|
|
130
234
|
await page.waitForTimeout(500);
|
|
131
235
|
|
|
132
236
|
const pageState = await target.evaluate(() => ({
|
|
133
|
-
bodyText: document.body
|
|
134
|
-
html: document.body
|
|
237
|
+
bodyText: document.body?.innerText ?? "",
|
|
238
|
+
html: document.body?.innerHTML ?? "",
|
|
135
239
|
}));
|
|
136
240
|
|
|
137
|
-
|
|
241
|
+
lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
|
|
242
|
+
lastHtmlChanged = pageState.html !== lastHtml;
|
|
243
|
+
|
|
244
|
+
if (!lastHadLoadingMarkers && !lastHtmlChanged) {
|
|
138
245
|
stableCount += 1;
|
|
139
246
|
if (stableCount >= 2) {
|
|
140
247
|
const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
|
|
@@ -142,6 +249,10 @@ async function waitForStablePage(page, target, timeoutMs = 10000) {
|
|
|
142
249
|
timeoutMs: Math.min(2000, remaining()),
|
|
143
250
|
});
|
|
144
251
|
await waitForImagesSettled(target, { overallTimeoutMs: remaining() });
|
|
252
|
+
logCaptureTiming("stable-page", {
|
|
253
|
+
outcome: "stabilized",
|
|
254
|
+
elapsedMs: Date.now() - started,
|
|
255
|
+
});
|
|
145
256
|
return;
|
|
146
257
|
}
|
|
147
258
|
} else {
|
|
@@ -150,9 +261,26 @@ async function waitForStablePage(page, target, timeoutMs = 10000) {
|
|
|
150
261
|
|
|
151
262
|
lastHtml = pageState.html;
|
|
152
263
|
}
|
|
264
|
+
// Hit the cap without stabilizing — record WHY: a persistent loading marker
|
|
265
|
+
// (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
|
|
266
|
+
logCaptureTiming("stable-page", {
|
|
267
|
+
outcome: "timed-out",
|
|
268
|
+
elapsedMs: Date.now() - started,
|
|
269
|
+
timeoutMs,
|
|
270
|
+
lastHadLoadingMarkers,
|
|
271
|
+
lastHtmlStillChanging: lastHtmlChanged,
|
|
272
|
+
});
|
|
153
273
|
}
|
|
154
274
|
|
|
155
|
-
|
|
275
|
+
// `preflight` is injectable so unit tests that drive a mock page can stay
|
|
276
|
+
// network-free; production callers use the default real reachability check.
|
|
277
|
+
async function loadScenarioInIframe(
|
|
278
|
+
page,
|
|
279
|
+
url,
|
|
280
|
+
{ background, preflight = assertAppPortReachable } = {},
|
|
281
|
+
) {
|
|
282
|
+
await preflight(url);
|
|
283
|
+
const navStarted = Date.now();
|
|
156
284
|
const responsePromise = page
|
|
157
285
|
.waitForResponse(
|
|
158
286
|
(response) =>
|
|
@@ -177,10 +305,44 @@ async function loadScenarioInIframe(page, url, { background } = {}) {
|
|
|
177
305
|
|
|
178
306
|
await frame.waitForLoadState("load", { timeout: 30000 });
|
|
179
307
|
const response = await responsePromise;
|
|
308
|
+
logCaptureTiming("navigate-iframe", {
|
|
309
|
+
elapsedMs: Date.now() - navStarted,
|
|
310
|
+
status: response ? response.status() : null,
|
|
311
|
+
url,
|
|
312
|
+
});
|
|
180
313
|
return { frame, response };
|
|
181
314
|
}
|
|
182
315
|
|
|
316
|
+
// Load the scenario as a top-level navigation instead of embedding it in
|
|
317
|
+
// the iframe harness. A top-level document is a first-party context, so a
|
|
318
|
+
// `SameSite=Lax` session cookie is sent on the navigation — which is what
|
|
319
|
+
// auth-gated application routes need to render the authenticated page
|
|
320
|
+
// rather than redirecting to /login. The returned `frame` is the page's
|
|
321
|
+
// main frame so callers can treat it uniformly with the iframe path
|
|
322
|
+
// (`frame.url()`, `frame.evaluate(...)`, `waitForStablePage(page, frame)`).
|
|
323
|
+
async function loadScenarioTopLevel(
|
|
324
|
+
page,
|
|
325
|
+
url,
|
|
326
|
+
{ preflight = assertAppPortReachable } = {},
|
|
327
|
+
) {
|
|
328
|
+
await preflight(url);
|
|
329
|
+
const navStarted = Date.now();
|
|
330
|
+
const response = await page.goto(url, {
|
|
331
|
+
waitUntil: "load",
|
|
332
|
+
timeout: 30000,
|
|
333
|
+
});
|
|
334
|
+
logCaptureTiming("navigate-toplevel", {
|
|
335
|
+
elapsedMs: Date.now() - navStarted,
|
|
336
|
+
status: response ? response.status() : null,
|
|
337
|
+
url,
|
|
338
|
+
});
|
|
339
|
+
return { frame: page.mainFrame(), response };
|
|
340
|
+
}
|
|
341
|
+
|
|
183
342
|
module.exports = {
|
|
343
|
+
logCaptureTiming,
|
|
344
|
+
resolveTcpTarget,
|
|
345
|
+
assertAppPortReachable,
|
|
184
346
|
escapeHtmlAttribute,
|
|
185
347
|
buildIframeHarness,
|
|
186
348
|
collectContentState,
|
|
@@ -189,4 +351,5 @@ module.exports = {
|
|
|
189
351
|
waitForAnimationsSettled,
|
|
190
352
|
waitForStablePage,
|
|
191
353
|
loadScenarioInIframe,
|
|
354
|
+
loadScenarioTopLevel,
|
|
192
355
|
};
|