@codeyam-editor/codeyam-editor 0.1.0-staging.01efa1a
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 +19 -0
- package/npm/cloud-branch-switch.js +442 -0
- package/npm/cloud.js +2184 -0
- package/npm/container.js +54 -0
- package/npm/editor-dev.js +459 -0
- package/npm/editor.js +272 -0
- package/npm/empty-project-dir.js +33 -0
- package/npm/fleet-auth.js +111 -0
- package/npm/fleet-branch.js +49 -0
- package/npm/fleet-broker-death.js +44 -0
- package/npm/fleet-broker-debounce.js +63 -0
- package/npm/fleet-broker-status.js +45 -0
- package/npm/fleet-build-data.js +81 -0
- package/npm/fleet-cleanup.js +297 -0
- package/npm/fleet-effect-status.js +179 -0
- package/npm/fleet-env.js +125 -0
- package/npm/fleet-grouping.js +56 -0
- package/npm/fleet-job-badge.js +83 -0
- package/npm/fleet-memory.js +157 -0
- package/npm/fleet-plans.js +96 -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 +93 -0
- package/npm/fleet-reset.js +222 -0
- package/npm/fleet-roster.js +81 -0
- package/npm/launch-config.js +717 -0
- package/npm/open.js +15 -0
- package/npm/preflight-guidance.js +77 -0
- package/npm/scenario-check.js +604 -0
- package/npm/scenario-handlers.js +82 -0
- package/npm/scenario-interactivity.js +245 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +108 -0
- package/npm/scenario-mocks.js +107 -0
- package/npm/scenario-playwright.js +534 -0
- package/npm/utils.js +823 -0
- package/npm/vm-urls.js +117 -0
- package/package.json +32 -0
- package/ui/dist/assets/ContainerInspector-C8EkH4PP.js +2 -0
- package/ui/dist/assets/EditorShell-By-2rl7D.js +187 -0
- package/ui/dist/assets/Markdown-BHBqz-Mj.js +1 -0
- package/ui/dist/assets/ScenarioDataPanel-Cg8zOFNZ.js +5 -0
- package/ui/dist/assets/ScenarioDataPanel-DYP7pi_n.css +32 -0
- package/ui/dist/assets/ScenarioDataPanelFullPage-WOUYbba7.js +1 -0
- package/ui/dist/assets/ScenarioRenderer-CBTlIVPs.js +1 -0
- package/ui/dist/assets/StepsSimulator-CRtmlyNZ.js +1 -0
- package/ui/dist/assets/index-DzoF9RKw.js +27 -0
- package/ui/dist/assets/index-ckz94rKI.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
|
@@ -0,0 +1,534 @@
|
|
|
1
|
+
const {
|
|
2
|
+
hasLoadingMarkers,
|
|
3
|
+
shouldStopWaitingForImages,
|
|
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
|
+
}
|
|
91
|
+
|
|
92
|
+
function escapeHtmlAttribute(value) {
|
|
93
|
+
return String(value).replaceAll("&", "&").replaceAll('"', """);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The harness background defaults to transparent so the iframe's own
|
|
97
|
+
// <body> background paints through — matching what users see in the Live
|
|
98
|
+
// Preview. Callers (via scenario-check.js) pass a concrete color when the
|
|
99
|
+
// UI has detected a background it wants the capture to paint behind the
|
|
100
|
+
// iframe, e.g. `var(--bg-deep)` from the editor shell.
|
|
101
|
+
function buildIframeHarness(url, { background = "transparent" } = {}) {
|
|
102
|
+
const escapedUrl = escapeHtmlAttribute(url);
|
|
103
|
+
const bg = String(background);
|
|
104
|
+
return `<!doctype html>
|
|
105
|
+
<html>
|
|
106
|
+
<head>
|
|
107
|
+
<meta charset="utf-8">
|
|
108
|
+
<style>
|
|
109
|
+
html, body {
|
|
110
|
+
margin: 0;
|
|
111
|
+
width: 100%;
|
|
112
|
+
height: 100%;
|
|
113
|
+
overflow: hidden;
|
|
114
|
+
background: ${bg};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
iframe {
|
|
118
|
+
display: block;
|
|
119
|
+
width: 100%;
|
|
120
|
+
height: 100%;
|
|
121
|
+
border: 0;
|
|
122
|
+
background: ${bg};
|
|
123
|
+
}
|
|
124
|
+
</style>
|
|
125
|
+
</head>
|
|
126
|
+
<body>
|
|
127
|
+
<iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
|
|
128
|
+
</body>
|
|
129
|
+
</html>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function collectContentState(target) {
|
|
133
|
+
return target.evaluate(() => {
|
|
134
|
+
const root = document.getElementById("root");
|
|
135
|
+
const imgs = Array.from(document.images || []);
|
|
136
|
+
const loadedImageCount = imgs.filter(
|
|
137
|
+
(img) => img.complete && img.naturalWidth > 0,
|
|
138
|
+
).length;
|
|
139
|
+
const mediaSelectors = ["svg", "canvas", "video"];
|
|
140
|
+
let mediaBboxCount = 0;
|
|
141
|
+
for (const selector of mediaSelectors) {
|
|
142
|
+
const nodes = document.querySelectorAll(selector);
|
|
143
|
+
for (const node of nodes) {
|
|
144
|
+
const rect = node.getBoundingClientRect();
|
|
145
|
+
if (rect.width > 0 && rect.height > 0) {
|
|
146
|
+
mediaBboxCount += 1;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
|
|
152
|
+
rootChildCount: root ? root.childElementCount : 0,
|
|
153
|
+
rootTextLength: root ? (root.textContent || "").trim().length : 0,
|
|
154
|
+
imageCount: imgs.length,
|
|
155
|
+
loadedImageCount,
|
|
156
|
+
mediaBboxCount,
|
|
157
|
+
};
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function collectImageStates(target) {
|
|
162
|
+
return target.evaluate(() =>
|
|
163
|
+
Array.from(document.images || []).map((img) => ({
|
|
164
|
+
complete: img.complete,
|
|
165
|
+
naturalWidth: img.naturalWidth,
|
|
166
|
+
src: img.currentSrc || img.src || "",
|
|
167
|
+
})),
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function waitForImagesSettled(
|
|
172
|
+
target,
|
|
173
|
+
{ overallTimeoutMs = 5000, pollIntervalMs = 100 } = {},
|
|
174
|
+
) {
|
|
175
|
+
const started = Date.now();
|
|
176
|
+
let images = await collectImageStates(target);
|
|
177
|
+
while (
|
|
178
|
+
!shouldStopWaitingForImages(images, {
|
|
179
|
+
elapsedMs: Date.now() - started,
|
|
180
|
+
overallTimeoutMs,
|
|
181
|
+
})
|
|
182
|
+
) {
|
|
183
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
184
|
+
images = await collectImageStates(target);
|
|
185
|
+
}
|
|
186
|
+
const elapsedMs = Date.now() - started;
|
|
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
|
+
});
|
|
200
|
+
return { settled: allComplete, images, elapsedMs };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
async function waitForAnimationsSettled(
|
|
204
|
+
target,
|
|
205
|
+
{ timeoutMs = 2000, pollIntervalMs = 100 } = {},
|
|
206
|
+
) {
|
|
207
|
+
const started = Date.now();
|
|
208
|
+
while (Date.now() - started < timeoutMs) {
|
|
209
|
+
const runningCount = await target.evaluate(() =>
|
|
210
|
+
document
|
|
211
|
+
.getAnimations()
|
|
212
|
+
.filter((a) => a.playState === "running").length,
|
|
213
|
+
);
|
|
214
|
+
if (runningCount === 0) {
|
|
215
|
+
return { settled: true, elapsedMs: Date.now() - started };
|
|
216
|
+
}
|
|
217
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
218
|
+
}
|
|
219
|
+
return { settled: false, elapsedMs: Date.now() - started };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Track in-flight network requests on a Playwright page so a capture can wait
|
|
223
|
+
// for client-side data fetches to settle before screenshotting. The
|
|
224
|
+
// resource-timing API (`performance.getEntriesByType("resource")`) only records
|
|
225
|
+
// COMPLETED requests, so it cannot see a fetch that is still in flight — the
|
|
226
|
+
// exact window where a client-fetch page shows a loading skeleton. We count
|
|
227
|
+
// request starts against finishes/failures instead. Returns a live view:
|
|
228
|
+
// `inFlight()` is the current outstanding count and `lastActivityMs()` is the
|
|
229
|
+
// timestamp of the most recent request start OR completion (0 when the page has
|
|
230
|
+
// made no requests since the tracker attached). Attach BEFORE navigation so
|
|
231
|
+
// every request is counted. Stack-agnostic — it observes raw HTTP activity, not
|
|
232
|
+
// any framework's fetch wrapper.
|
|
233
|
+
function createNetworkTracker(page) {
|
|
234
|
+
let inFlight = 0;
|
|
235
|
+
let lastActivityMs = 0;
|
|
236
|
+
const bump = () => {
|
|
237
|
+
lastActivityMs = Date.now();
|
|
238
|
+
};
|
|
239
|
+
if (page && typeof page.on === "function") {
|
|
240
|
+
page.on("request", () => {
|
|
241
|
+
inFlight += 1;
|
|
242
|
+
bump();
|
|
243
|
+
});
|
|
244
|
+
const settle = () => {
|
|
245
|
+
inFlight = Math.max(0, inFlight - 1);
|
|
246
|
+
bump();
|
|
247
|
+
};
|
|
248
|
+
page.on("requestfinished", settle);
|
|
249
|
+
page.on("requestfailed", settle);
|
|
250
|
+
}
|
|
251
|
+
return {
|
|
252
|
+
inFlight: () => inFlight,
|
|
253
|
+
lastActivityMs: () => lastActivityMs,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Bounded network-quiet wait: after the DOM is stable a client-side data fetch
|
|
258
|
+
// can still be in flight — the loading skeleton is gone but the fetched rows
|
|
259
|
+
// haven't replaced it yet, so a screenshot here catches the in-between frame.
|
|
260
|
+
// Wait until no request has been outstanding for `quietWindowMs`, hard-capped at
|
|
261
|
+
// `overallTimeoutMs`. Two properties matter:
|
|
262
|
+
// - A page that made NO requests (lastActivityMs stays 0) is already quiet and
|
|
263
|
+
// returns on the first poll, so server-rendered captures incur no extra wait.
|
|
264
|
+
// - A streaming / long-poll endpoint that never goes idle hits the cap and the
|
|
265
|
+
// caller captures anyway — the wait can never hang the capture.
|
|
266
|
+
async function waitForNetworkQuiet(
|
|
267
|
+
tracker,
|
|
268
|
+
{ quietWindowMs = 500, overallTimeoutMs = 5000, pollIntervalMs = 100 } = {},
|
|
269
|
+
) {
|
|
270
|
+
const started = Date.now();
|
|
271
|
+
while (Date.now() - started < overallTimeoutMs) {
|
|
272
|
+
const idleForMs = Date.now() - tracker.lastActivityMs();
|
|
273
|
+
if (tracker.inFlight() === 0 && idleForMs >= quietWindowMs) {
|
|
274
|
+
const elapsedMs = Date.now() - started;
|
|
275
|
+
logCaptureTiming("network-quiet", { outcome: "quiet", elapsedMs });
|
|
276
|
+
return { quiet: true, elapsedMs };
|
|
277
|
+
}
|
|
278
|
+
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
|
279
|
+
}
|
|
280
|
+
const elapsedMs = Date.now() - started;
|
|
281
|
+
logCaptureTiming("network-quiet", {
|
|
282
|
+
outcome: "capped",
|
|
283
|
+
elapsedMs,
|
|
284
|
+
inFlight: tracker.inFlight(),
|
|
285
|
+
});
|
|
286
|
+
return { quiet: false, elapsedMs };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// `loadingMarkers` are the project's app-specific loading strings (from
|
|
290
|
+
// stack.json `capture.loadingMarkers`); they extend the codeyam-harness
|
|
291
|
+
// defaults so a stable-but-still-loading app screen counts as "not ready"
|
|
292
|
+
// and the loop keeps waiting instead of capturing the loading flash.
|
|
293
|
+
async function waitForStablePage(page, target, timeoutMs = 10000, loadingMarkers = []) {
|
|
294
|
+
const started = Date.now();
|
|
295
|
+
let lastHtml = "";
|
|
296
|
+
let stableCount = 0;
|
|
297
|
+
let lastHadLoadingMarkers = false;
|
|
298
|
+
let lastHtmlChanged = false;
|
|
299
|
+
|
|
300
|
+
while (Date.now() - started < timeoutMs) {
|
|
301
|
+
await page.waitForTimeout(500);
|
|
302
|
+
|
|
303
|
+
const pageState = await target.evaluate(() => {
|
|
304
|
+
const getById = document.getElementById;
|
|
305
|
+
const root =
|
|
306
|
+
typeof getById === "function" ? document.getElementById("root") : null;
|
|
307
|
+
return {
|
|
308
|
+
bodyText: document.body?.innerText ?? "",
|
|
309
|
+
html: document.body?.innerHTML ?? "",
|
|
310
|
+
// Whether the SPA mount point exists AND has painted anything. An
|
|
311
|
+
// existing-but-empty `<div id="root">` is the pre-paint window of a
|
|
312
|
+
// slow-first-paint scenario (e.g. a live-session app that spends a few
|
|
313
|
+
// seconds connecting before the gate UI mounts). `rootExists` lets us
|
|
314
|
+
// distinguish that from a mid-redirect/teardown `null` body, where
|
|
315
|
+
// there is no root to wait on and a stable-empty page is legitimately
|
|
316
|
+
// settled.
|
|
317
|
+
rootExists: !!root,
|
|
318
|
+
rootChildCount: root ? root.childElementCount : 0,
|
|
319
|
+
};
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
lastHadLoadingMarkers = hasLoadingMarkers(pageState.bodyText, loadingMarkers);
|
|
323
|
+
lastHtmlChanged = pageState.html !== lastHtml;
|
|
324
|
+
|
|
325
|
+
// A mounted-but-unpainted root is "still loading", not "settled": the HTML
|
|
326
|
+
// can sit byte-stable for a second or two while the SPA boots, which would
|
|
327
|
+
// otherwise satisfy the stability check and capture a blank frame before
|
|
328
|
+
// first paint. Treat an existing root with zero children AND no body text
|
|
329
|
+
// as not-ready so the loop keeps polling until the app actually paints (or
|
|
330
|
+
// the overall timeout fires, by which point real content is present). A
|
|
331
|
+
// `null` body (rootExists=false) is unaffected — it stays trivially stable.
|
|
332
|
+
const rootUnpainted =
|
|
333
|
+
pageState.rootExists &&
|
|
334
|
+
pageState.rootChildCount === 0 &&
|
|
335
|
+
(pageState.bodyText ?? "").trim().length === 0;
|
|
336
|
+
|
|
337
|
+
if (!lastHadLoadingMarkers && !lastHtmlChanged && !rootUnpainted) {
|
|
338
|
+
stableCount += 1;
|
|
339
|
+
if (stableCount >= 2) {
|
|
340
|
+
const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
|
|
341
|
+
await waitForAnimationsSettled(target, {
|
|
342
|
+
timeoutMs: Math.min(2000, remaining()),
|
|
343
|
+
});
|
|
344
|
+
await waitForImagesSettled(target, { overallTimeoutMs: remaining() });
|
|
345
|
+
logCaptureTiming("stable-page", {
|
|
346
|
+
outcome: "stabilized",
|
|
347
|
+
elapsedMs: Date.now() - started,
|
|
348
|
+
});
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
} else {
|
|
352
|
+
stableCount = 0;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
lastHtml = pageState.html;
|
|
356
|
+
}
|
|
357
|
+
// Hit the cap without stabilizing — record WHY: a persistent loading marker
|
|
358
|
+
// (app stuck) vs HTML still mutating each poll (HMR / animation / re-render).
|
|
359
|
+
logCaptureTiming("stable-page", {
|
|
360
|
+
outcome: "timed-out",
|
|
361
|
+
elapsedMs: Date.now() - started,
|
|
362
|
+
timeoutMs,
|
|
363
|
+
lastHadLoadingMarkers,
|
|
364
|
+
lastHtmlStillChanging: lastHtmlChanged,
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// `preflight` is injectable so unit tests that drive a mock page can stay
|
|
369
|
+
// network-free; production callers use the default real reachability check.
|
|
370
|
+
async function loadScenarioInIframe(
|
|
371
|
+
page,
|
|
372
|
+
url,
|
|
373
|
+
{ background, preflight = assertAppPortReachable } = {},
|
|
374
|
+
) {
|
|
375
|
+
await preflight(url);
|
|
376
|
+
const navStarted = Date.now();
|
|
377
|
+
const responsePromise = page
|
|
378
|
+
.waitForResponse(
|
|
379
|
+
(response) =>
|
|
380
|
+
response.request().resourceType() === "document" &&
|
|
381
|
+
response.url() === url,
|
|
382
|
+
{ timeout: 30000 },
|
|
383
|
+
)
|
|
384
|
+
.catch(() => null);
|
|
385
|
+
|
|
386
|
+
await page.setContent(buildIframeHarness(url, { background }), {
|
|
387
|
+
waitUntil: "domcontentloaded",
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
const frameHandle = await page.waitForSelector("#scenario-frame", {
|
|
391
|
+
state: "attached",
|
|
392
|
+
timeout: 30000,
|
|
393
|
+
});
|
|
394
|
+
const frame = await frameHandle.contentFrame();
|
|
395
|
+
if (!frame) {
|
|
396
|
+
throw new Error("Scenario iframe did not attach");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
await frame.waitForLoadState("load", { timeout: 30000 });
|
|
400
|
+
const response = await responsePromise;
|
|
401
|
+
logCaptureTiming("navigate-iframe", {
|
|
402
|
+
elapsedMs: Date.now() - navStarted,
|
|
403
|
+
status: response ? response.status() : null,
|
|
404
|
+
url,
|
|
405
|
+
});
|
|
406
|
+
return { frame, response };
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Load the scenario as a top-level navigation instead of embedding it in
|
|
410
|
+
// the iframe harness. A top-level document is a first-party context, so a
|
|
411
|
+
// `SameSite=Lax` session cookie is sent on the navigation — which is what
|
|
412
|
+
// auth-gated application routes need to render the authenticated page
|
|
413
|
+
// rather than redirecting to /login. The returned `frame` is the page's
|
|
414
|
+
// main frame so callers can treat it uniformly with the iframe path
|
|
415
|
+
// (`frame.url()`, `frame.evaluate(...)`, `waitForStablePage(page, frame)`).
|
|
416
|
+
async function loadScenarioTopLevel(
|
|
417
|
+
page,
|
|
418
|
+
url,
|
|
419
|
+
{ preflight = assertAppPortReachable } = {},
|
|
420
|
+
) {
|
|
421
|
+
await preflight(url);
|
|
422
|
+
const navStarted = Date.now();
|
|
423
|
+
const response = await page.goto(url, {
|
|
424
|
+
waitUntil: "load",
|
|
425
|
+
timeout: 30000,
|
|
426
|
+
});
|
|
427
|
+
logCaptureTiming("navigate-toplevel", {
|
|
428
|
+
elapsedMs: Date.now() - navStarted,
|
|
429
|
+
status: response ? response.status() : null,
|
|
430
|
+
url,
|
|
431
|
+
});
|
|
432
|
+
return { frame: page.mainFrame(), response };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Collect up to 20 distinct visible labels of interactive elements on the
|
|
436
|
+
// page — buttons, links, role=button, form controls, <summary>, and anything
|
|
437
|
+
// with an onclick. Used to build an ACTIONABLE error when an interaction's
|
|
438
|
+
// target matches nothing: the agent reliably knows a label it rendered, so
|
|
439
|
+
// listing the real candidates turns a silent blank capture into a "did you
|
|
440
|
+
// mean one of these?" hint. Pure read (no clicks); falls back to value /
|
|
441
|
+
// aria-label / placeholder when an element has no text.
|
|
442
|
+
async function collectInteractiveLabels(frame) {
|
|
443
|
+
return frame.evaluate(() => {
|
|
444
|
+
const selector =
|
|
445
|
+
"button, a[href], [role=button], input, select, textarea, summary, [onclick]";
|
|
446
|
+
const nodes = Array.from(document.querySelectorAll(selector));
|
|
447
|
+
const labels = nodes
|
|
448
|
+
.map((node) => {
|
|
449
|
+
const text =
|
|
450
|
+
(node.innerText || node.textContent || "").trim() ||
|
|
451
|
+
(typeof node.value === "string" ? node.value.trim() : "") ||
|
|
452
|
+
(node.getAttribute && node.getAttribute("aria-label")) ||
|
|
453
|
+
(node.getAttribute && node.getAttribute("placeholder")) ||
|
|
454
|
+
"";
|
|
455
|
+
return String(text).trim();
|
|
456
|
+
})
|
|
457
|
+
.filter((label) => label.length > 0);
|
|
458
|
+
return Array.from(new Set(labels)).slice(0, 20);
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// Drive a single user-style interaction against the settled frame before the
|
|
463
|
+
// screenshot, so an interactive state (expanded accordion, open modal, filled
|
|
464
|
+
// field) can be captured without editing app source.
|
|
465
|
+
//
|
|
466
|
+
// The target is matched by visible `text` (preferred — the agent reliably
|
|
467
|
+
// knows the label it rendered) or a CSS `selector`. `action` is click / fill /
|
|
468
|
+
// press; `value` carries the text for `fill` or the key for `press` (e.g.
|
|
469
|
+
// `Enter`). On a no-match target this THROWS with the list of candidate
|
|
470
|
+
// interactive labels — the capture script's outer catch turns that into a
|
|
471
|
+
// failed capture with an actionable message, never a silent blank screenshot.
|
|
472
|
+
async function performInteraction(frame, interaction, { timeoutMs = 5000 } = {}) {
|
|
473
|
+
const { action, selector, text, value } = interaction || {};
|
|
474
|
+
|
|
475
|
+
let locator;
|
|
476
|
+
let targetDesc;
|
|
477
|
+
if (typeof text === "string" && text.length > 0) {
|
|
478
|
+
locator = frame.getByText(text, { exact: false }).first();
|
|
479
|
+
targetDesc = `text "${text}"`;
|
|
480
|
+
} else if (typeof selector === "string" && selector.length > 0) {
|
|
481
|
+
locator = frame.locator(selector).first();
|
|
482
|
+
targetDesc = `selector "${selector}"`;
|
|
483
|
+
} else {
|
|
484
|
+
throw new Error(
|
|
485
|
+
"preview-interact: interaction requires a `text` or `selector` target",
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const matchCount = await locator.count();
|
|
490
|
+
if (matchCount === 0) {
|
|
491
|
+
const candidates = await collectInteractiveLabels(frame);
|
|
492
|
+
const candidateList =
|
|
493
|
+
candidates.length > 0 ? candidates.join(", ") : "(none found on page)";
|
|
494
|
+
throw new Error(
|
|
495
|
+
`preview-interact: no element matched ${targetDesc}. ` +
|
|
496
|
+
`Candidate interactive labels: ${candidateList}`,
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
switch (action) {
|
|
501
|
+
case "click":
|
|
502
|
+
await locator.click({ timeout: timeoutMs });
|
|
503
|
+
break;
|
|
504
|
+
case "fill":
|
|
505
|
+
await locator.fill(value ?? "", { timeout: timeoutMs });
|
|
506
|
+
break;
|
|
507
|
+
case "press":
|
|
508
|
+
await locator.press(value || "Enter", { timeout: timeoutMs });
|
|
509
|
+
break;
|
|
510
|
+
default:
|
|
511
|
+
throw new Error(
|
|
512
|
+
`preview-interact: unknown action "${action}" (expected click | fill | press)`,
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
module.exports = {
|
|
518
|
+
logCaptureTiming,
|
|
519
|
+
resolveTcpTarget,
|
|
520
|
+
assertAppPortReachable,
|
|
521
|
+
escapeHtmlAttribute,
|
|
522
|
+
buildIframeHarness,
|
|
523
|
+
collectContentState,
|
|
524
|
+
collectImageStates,
|
|
525
|
+
waitForImagesSettled,
|
|
526
|
+
waitForAnimationsSettled,
|
|
527
|
+
createNetworkTracker,
|
|
528
|
+
waitForNetworkQuiet,
|
|
529
|
+
waitForStablePage,
|
|
530
|
+
loadScenarioInIframe,
|
|
531
|
+
loadScenarioTopLevel,
|
|
532
|
+
collectInteractiveLabels,
|
|
533
|
+
performInteraction,
|
|
534
|
+
};
|