@codeyam-editor/codeyam-editor 0.1.0-staging.46bd02f → 0.1.0-staging.c9412f0

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/editor.js CHANGED
@@ -24,7 +24,21 @@ const VITE_PORT = 5173;
24
24
  const VITE_INTERNAL_PORT = VITE_PORT + 1;
25
25
  const shouldRestart = process.argv.includes("--restart");
26
26
 
27
+ function buildBinary() {
28
+ console.error("Building Rust binary...");
29
+ try {
30
+ execSync("cargo build", { stdio: "inherit", cwd: rootDir });
31
+ } catch {
32
+ console.error("Failed to build Rust binary. Continuing with existing build (if any)...");
33
+ }
34
+ }
35
+
27
36
  async function main() {
37
+ // Rebuild the Rust binary so code changes are picked up on restart.
38
+ // This script is only invoked from a source checkout (via `npm run editor`),
39
+ // so Cargo.toml and the crates tree are always present.
40
+ buildBinary();
41
+
28
42
  // Build the UI so the backend can serve it as static files
29
43
  console.error("Building UI...");
30
44
  try {
@@ -1,322 +1,92 @@
1
1
  #!/usr/bin/env node
2
2
 
3
+ // Render environment (colorScheme, deviceScaleFactor, userAgent, locale,
4
+ // timezoneId, reduceMotion, forcedColors) is read from config when present
5
+ // and passed to browser.newContext(). This is what makes screenshots match
6
+ // the Live Preview iframe's host browser — see docs/rendering.md.
7
+ //
8
+ // iframeBackground is forwarded to buildIframeHarness so the capture paints
9
+ // the user's editor-shell background (or whatever the UI detected) behind
10
+ // the iframe instead of a hardcoded white.
11
+
3
12
  const fs = require("fs");
4
13
  const path = require("path");
5
14
  const { chromium } = require("playwright");
6
15
 
7
- const LOADING_MARKERS = [
8
- "Loading scenario...",
9
- "Loading tests...",
10
- "Loading scenarios...",
11
- "disconnected",
12
- ];
13
-
14
- function hasLoadingMarkers(text) {
15
- return LOADING_MARKERS.some((marker) => text.includes(marker));
16
- }
17
-
18
- function hasRenderableContent(state) {
19
- return Boolean(
20
- state &&
21
- (state.rootChildCount > 0 ||
22
- state.rootTextLength > 0 ||
23
- state.bodyTextLength > 0),
24
- );
25
- }
26
-
27
- const ERROR_PATTERNS = [
28
- "not found in registry",
29
- "Component not found",
30
- "Scenario Error",
31
- ];
32
-
33
- function hasErrorPatterns(text) {
34
- return ERROR_PATTERNS.some((pattern) => text.includes(pattern));
35
- }
36
-
37
- function createIssue(kind, message, extra = {}) {
38
- return {
39
- kind,
40
- message,
41
- url: extra.url ?? null,
42
- status: extra.status ?? null,
43
- };
44
- }
45
-
46
- function pushIssue(issues, issue) {
47
- const key = JSON.stringify(issue);
48
- if (!issues.some((existing) => JSON.stringify(existing) === key)) {
49
- issues.push(issue);
50
- }
51
- }
52
-
53
- function escapeHtmlAttribute(value) {
54
- return String(value)
55
- .replaceAll("&", "&")
56
- .replaceAll('"', """);
57
- }
58
-
59
- function buildIframeHarness(url) {
60
- const escapedUrl = escapeHtmlAttribute(url);
61
- return `<!doctype html>
62
- <html>
63
- <head>
64
- <meta charset="utf-8">
65
- <style>
66
- html, body {
67
- margin: 0;
68
- width: 100%;
69
- height: 100%;
70
- overflow: hidden;
71
- background: #fff;
72
- }
73
-
74
- iframe {
75
- display: block;
76
- width: 100%;
77
- height: 100%;
78
- border: 0;
79
- background: #fff;
80
- }
81
- </style>
82
- </head>
83
- <body>
84
- <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
85
- </body>
86
- </html>`;
87
- }
88
-
89
- function buildResult({ loaded, hasContent, issues, outputPath, url }) {
90
- return {
91
- ok: loaded && hasContent && issues.length === 0,
92
- loaded,
93
- hasContent,
94
- url,
95
- outputPath: outputPath ?? null,
96
- issues,
97
- };
98
- }
99
-
100
- function normalizeMockCandidates(url) {
101
- try {
102
- const parsed = new URL(url);
103
- return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname];
104
- } catch {
105
- return [url];
106
- }
107
- }
108
-
109
- function findHttpMock(httpMocks, request) {
110
- const method = request.method().toUpperCase();
111
- const candidates = normalizeMockCandidates(request.url());
112
- for (const candidate of candidates) {
113
- const key = `${method} ${candidate}`;
114
- if (httpMocks[key]) {
115
- return httpMocks[key];
116
- }
117
- }
118
- return null;
119
- }
120
-
121
- async function attachHttpMocks(page, httpMocks) {
122
- if (!httpMocks || Object.keys(httpMocks).length === 0) return;
123
-
124
- await page.route("**/*", async (route) => {
125
- const mock = findHttpMock(httpMocks, route.request());
126
- if (!mock) {
127
- await route.continue();
128
- return;
129
- }
130
-
131
- const headers = { ...(mock.headers || {}) };
132
- let body;
133
- if (mock.body !== undefined) {
134
- body =
135
- typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body);
136
- const hasContentType = Object.keys(headers).some(
137
- (key) => key.toLowerCase() === "content-type",
138
- );
139
- if (!hasContentType) {
140
- headers["content-type"] = "application/json";
141
- }
142
- }
143
-
144
- await route.fulfill({
145
- status: mock.status || 200,
146
- headers,
147
- body,
148
- });
149
- });
150
-
151
- // Disable the in-page fetch mock by returning an empty active-mocks.json.
152
- // The HTML injects a script that synchronously loads this file and
153
- // monkey-patches window.fetch, which would bypass Playwright's route
154
- // interception. This route is registered AFTER **/* so it takes priority
155
- // (Playwright uses LIFO ordering for route handlers).
156
- await page.route("**/active-mocks.json", async (route) => {
157
- await route.fulfill({
158
- status: 200,
159
- headers: { "content-type": "application/json" },
160
- body: "[]",
161
- });
162
- });
163
- }
164
-
165
- async function collectContentState(target) {
166
- return target.evaluate(() => {
167
- const root = document.getElementById("root");
168
- return {
169
- bodyTextLength: document.body.innerText.trim().length,
170
- rootChildCount: root ? root.childElementCount : 0,
171
- rootTextLength: root ? (root.textContent || "").trim().length : 0,
172
- };
173
- });
174
- }
175
-
176
- async function waitForStablePage(page, target, timeoutMs = 10000) {
177
- const started = Date.now();
178
- let lastHtml = "";
179
- let stableCount = 0;
180
-
181
- while (Date.now() - started < timeoutMs) {
182
- await page.waitForTimeout(500);
183
-
184
- const pageState = await target.evaluate(() => ({
185
- bodyText: document.body.innerText,
186
- html: document.body.innerHTML,
187
- }));
188
-
189
- if (
190
- !hasLoadingMarkers(pageState.bodyText) &&
191
- pageState.html === lastHtml
192
- ) {
193
- stableCount += 1;
194
- if (stableCount >= 2) return;
195
- } else {
196
- stableCount = 0;
197
- }
198
-
199
- lastHtml = pageState.html;
200
- }
201
- }
16
+ const {
17
+ findErrorPattern,
18
+ buildErrorContextSnippet,
19
+ hasRenderableContent,
20
+ describeBlankReason,
21
+ } = require("./scenario-metrics");
202
22
 
203
- async function loadScenarioInIframe(page, url) {
204
- const responsePromise = page
205
- .waitForResponse(
206
- (response) =>
207
- response.request().resourceType() === "document" &&
208
- response.url() === url,
209
- { timeout: 30000 },
210
- )
211
- .catch(() => null);
23
+ const {
24
+ createIssue,
25
+ pushIssue,
26
+ buildResult,
27
+ } = require("./scenario-issues");
212
28
 
213
- await page.setContent(buildIframeHarness(url), { waitUntil: "domcontentloaded" });
29
+ const {
30
+ attachHttpMocks,
31
+ } = require("./scenario-mocks");
214
32
 
215
- const frameHandle = await page.waitForSelector("#scenario-frame", {
216
- state: "attached",
217
- timeout: 30000,
218
- });
219
- const frame = await frameHandle.contentFrame();
220
- if (!frame) {
221
- throw new Error("Scenario iframe did not attach");
222
- }
33
+ const {
34
+ loadScenarioInIframe,
35
+ waitForStablePage,
36
+ collectContentState,
37
+ } = require("./scenario-playwright");
223
38
 
224
- await frame.waitForLoadState("load", { timeout: 30000 });
225
- const response = await responsePromise;
226
- return { frame, response };
227
- }
39
+ const {
40
+ getInitScript,
41
+ handleConsoleMessage,
42
+ handlePageError,
43
+ handleRequestFailed,
44
+ } = require("./scenario-handlers");
228
45
 
229
46
  async function runScenarioCheck(config) {
230
47
  const { url, outputPath, width, height, httpMocks = {} } = config;
231
48
  const issues = [];
232
49
  const browser = await chromium.launch();
233
- const context = await browser.newContext({
50
+ const contextOptions = {
234
51
  viewport: { width: width || 1440, height: height || 900 },
235
- });
52
+ };
53
+ if (config.colorScheme) contextOptions.colorScheme = config.colorScheme;
54
+ if (config.deviceScaleFactor)
55
+ contextOptions.deviceScaleFactor = config.deviceScaleFactor;
56
+ if (config.userAgent) contextOptions.userAgent = config.userAgent;
57
+ if (config.locale) contextOptions.locale = config.locale;
58
+ if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
59
+ if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
60
+ if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
61
+ const context = await browser.newContext(contextOptions);
236
62
 
237
63
  // Context-level init script runs in ALL frames (including cross-origin iframes)
238
- await context.addInitScript(() => {
239
- window.__codeyamUnhandledRejections = [];
240
- window.addEventListener("unhandledrejection", (event) => {
241
- const reason = event.reason;
242
- const message =
243
- reason instanceof Error ? reason.message : String(reason);
244
- window.__codeyamUnhandledRejections.push(message);
245
- });
246
-
247
- // Stub WebSocket during capture to prevent terminal reconnection spam.
248
- // Returns an object that behaves like a closed WebSocket — the terminal
249
- // will attempt to reconnect a few times then give up silently.
250
- window.WebSocket = class StubWebSocket {
251
- static CONNECTING = 0;
252
- static OPEN = 1;
253
- static CLOSING = 2;
254
- static CLOSED = 3;
255
- readyState = 3;
256
- onopen = null;
257
- onclose = null;
258
- onerror = null;
259
- onmessage = null;
260
- send() {}
261
- close() {}
262
- addEventListener() {}
263
- removeEventListener() {}
264
- dispatchEvent() { return false; }
265
- constructor() {
266
- // Fire onerror then onclose on next tick so the terminal sees a failed connection
267
- setTimeout(() => {
268
- if (this.onerror) this.onerror(new Event("error"));
269
- if (this.onclose) this.onclose(new CloseEvent("close"));
270
- }, 0);
271
- }
272
- };
273
- });
64
+ await context.addInitScript(getInitScript());
274
65
 
275
66
  const page = await context.newPage();
276
67
  await attachHttpMocks(page, httpMocks);
277
68
 
278
69
  page.on("pageerror", (error) => {
279
- pushIssue(
280
- issues,
281
- createIssue("pageerror", error.message || String(error), {
282
- url: page.url() || url,
283
- }),
284
- );
70
+ pushIssue(issues, handlePageError(error));
285
71
  });
286
72
 
287
73
  page.on("console", (message) => {
288
- if (message.type() !== "error") return;
289
- const text = message.text();
290
- // Ignore known dev-server WebSocket/HMR errors from Vite proxy
291
- if (
292
- text.includes("WebSocket connection to") ||
293
- text.includes("Unsupported Media Type")
294
- ) {
295
- return;
74
+ const issue = handleConsoleMessage(message);
75
+ if (issue) {
76
+ pushIssue(issues, issue);
296
77
  }
297
- pushIssue(
298
- issues,
299
- createIssue("console", text, {
300
- url: page.url() || url,
301
- }),
302
- );
303
78
  });
304
79
 
305
80
  page.on("requestfailed", (request) => {
306
- pushIssue(
307
- issues,
308
- createIssue(
309
- "requestfailed",
310
- request.failure()?.errorText || "Request failed",
311
- { url: request.url() },
312
- ),
313
- );
81
+ pushIssue(issues, handleRequestFailed(request));
314
82
  });
315
83
 
316
84
  let loaded = false;
317
85
 
318
86
  try {
319
- const { frame, response } = await loadScenarioInIframe(page, url);
87
+ const { frame, response } = await loadScenarioInIframe(page, url, {
88
+ background: config.iframeBackground,
89
+ });
320
90
  loaded = true;
321
91
 
322
92
  if (response && response.status() >= 400) {
@@ -349,20 +119,30 @@ async function runScenarioCheck(config) {
349
119
  if (!hasContent) {
350
120
  pushIssue(
351
121
  issues,
352
- createIssue("blank", "Page rendered no visible content", {
353
- url: page.url() || url,
354
- }),
122
+ createIssue(
123
+ "blank",
124
+ `Page rendered no visible content (${describeBlankReason(contentState)})`,
125
+ { url: page.url() || url },
126
+ ),
355
127
  );
356
128
  }
357
129
 
358
130
  // Check for known error states in the rendered content
359
131
  const bodyText = await frame.evaluate(() => document.body.innerText || "");
360
- if (hasErrorPatterns(bodyText)) {
132
+ const matchedPattern = findErrorPattern(bodyText);
133
+ if (matchedPattern) {
134
+ const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
361
135
  pushIssue(
362
136
  issues,
363
- createIssue("error-state", `Page contains error content: ${bodyText.slice(0, 200)}`, {
364
- url: page.url() || url,
365
- }),
137
+ createIssue(
138
+ "error-state",
139
+ `Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
140
+ {
141
+ url: page.url() || url,
142
+ matchedPattern,
143
+ contextSnippet,
144
+ },
145
+ ),
366
146
  );
367
147
  }
368
148
 
@@ -411,19 +191,7 @@ async function main() {
411
191
  }
412
192
 
413
193
  module.exports = {
414
- attachHttpMocks,
415
- buildResult,
416
- buildIframeHarness,
417
- createIssue,
418
- findHttpMock,
419
- hasErrorPatterns,
420
- hasLoadingMarkers,
421
- hasRenderableContent,
422
- loadScenarioInIframe,
423
- normalizeMockCandidates,
424
- pushIssue,
425
194
  runScenarioCheck,
426
- waitForStablePage,
427
195
  };
428
196
 
429
197
  if (require.main === module) {
@@ -0,0 +1,71 @@
1
+ const { createIssue } = require("./scenario-issues");
2
+
3
+ function getInitScript() {
4
+ return `
5
+ window.__codeyamUnhandledRejections = [];
6
+ window.addEventListener("unhandledrejection", (event) => {
7
+ const reason = event.reason;
8
+ const message =
9
+ reason instanceof Error ? reason.message : String(reason);
10
+ window.__codeyamUnhandledRejections.push(message);
11
+ });
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
+ };
36
+ `;
37
+ }
38
+
39
+ function handleConsoleMessage(message) {
40
+ if (message.type() !== "error") return null;
41
+ const text = message.text();
42
+
43
+ // Ignore known dev-server WebSocket/HMR errors from Vite proxy
44
+ if (
45
+ text.includes("WebSocket connection to") ||
46
+ text.includes("Unsupported Media Type")
47
+ ) {
48
+ return null;
49
+ }
50
+
51
+ return createIssue("console", text);
52
+ }
53
+
54
+ function handlePageError(error) {
55
+ return createIssue("pageerror", error.message || String(error));
56
+ }
57
+
58
+ function handleRequestFailed(request) {
59
+ return createIssue(
60
+ "requestfailed",
61
+ request.failure()?.errorText || "Request failed",
62
+ { url: request.url() }
63
+ );
64
+ }
65
+
66
+ module.exports = {
67
+ getInitScript,
68
+ handleConsoleMessage,
69
+ handlePageError,
70
+ handleRequestFailed,
71
+ };
@@ -0,0 +1,35 @@
1
+ function createIssue(kind, message, extra = {}) {
2
+ const issue = {
3
+ kind,
4
+ message,
5
+ url: extra.url ?? null,
6
+ status: extra.status ?? null,
7
+ };
8
+ if (extra.matchedPattern != null) issue.matchedPattern = extra.matchedPattern;
9
+ if (extra.contextSnippet != null) issue.contextSnippet = extra.contextSnippet;
10
+ return issue;
11
+ }
12
+
13
+ function pushIssue(issues, issue) {
14
+ const key = JSON.stringify(issue);
15
+ if (!issues.some((existing) => JSON.stringify(existing) === key)) {
16
+ issues.push(issue);
17
+ }
18
+ }
19
+
20
+ function buildResult({ loaded, hasContent, issues, outputPath, url }) {
21
+ return {
22
+ ok: loaded && hasContent && issues.length === 0,
23
+ loaded,
24
+ hasContent,
25
+ url,
26
+ outputPath: outputPath ?? null,
27
+ issues,
28
+ };
29
+ }
30
+
31
+ module.exports = {
32
+ createIssue,
33
+ pushIssue,
34
+ buildResult,
35
+ };
@@ -0,0 +1,94 @@
1
+ const LOADING_MARKERS = [
2
+ "Loading scenario...",
3
+ "Loading tests...",
4
+ "Loading scenarios...",
5
+ "disconnected",
6
+ ];
7
+
8
+ function hasLoadingMarkers(text) {
9
+ return LOADING_MARKERS.some((marker) => text.includes(marker));
10
+ }
11
+
12
+ function hasRenderableContent(state) {
13
+ if (!state) return false;
14
+ if (
15
+ state.rootChildCount > 0 ||
16
+ state.rootTextLength > 0 ||
17
+ state.bodyTextLength > 0
18
+ ) {
19
+ return true;
20
+ }
21
+ if ((state.loadedImageCount || 0) > 0) return true;
22
+ if ((state.mediaBboxCount || 0) > 0) return true;
23
+ return false;
24
+ }
25
+
26
+ function describeBlankReason(state) {
27
+ if (!state) return "no content state collected";
28
+ const parts = [];
29
+ if (!(state.bodyTextLength > 0)) {
30
+ parts.push("no text");
31
+ }
32
+ const imageCount = state.imageCount || 0;
33
+ const loadedImageCount = state.loadedImageCount || 0;
34
+ if (imageCount > 0 && loadedImageCount === 0) {
35
+ parts.push(`${imageCount} unloaded image${imageCount === 1 ? "" : "s"}`);
36
+ } else if (imageCount === 0) {
37
+ parts.push("no images");
38
+ }
39
+ if (!((state.mediaBboxCount || 0) > 0)) {
40
+ parts.push("no svg/canvas/video");
41
+ }
42
+ return parts.join(", ");
43
+ }
44
+
45
+ function shouldStopWaitingForImages(images, options = {}) {
46
+ const { elapsedMs = 0, overallTimeoutMs = 5000 } = options;
47
+ if (!Array.isArray(images) || images.length === 0) return true;
48
+ if (elapsedMs >= overallTimeoutMs) return true;
49
+ return images.every((img) => img && img.complete === true);
50
+ }
51
+
52
+ const ERROR_PATTERNS = [
53
+ "not found in registry",
54
+ "Component not found",
55
+ "Scenario Error",
56
+ ];
57
+
58
+ function hasErrorPatterns(text) {
59
+ return ERROR_PATTERNS.some((pattern) => text.includes(pattern));
60
+ }
61
+
62
+ function findErrorPattern(text) {
63
+ if (!text) return null;
64
+ for (const pattern of ERROR_PATTERNS) {
65
+ if (text.includes(pattern)) return pattern;
66
+ }
67
+ return null;
68
+ }
69
+
70
+ const ERROR_CONTEXT_RADIUS = 60;
71
+
72
+ function buildErrorContextSnippet(text, pattern) {
73
+ if (!text || !pattern) return null;
74
+ const index = text.indexOf(pattern);
75
+ if (index < 0) return null;
76
+ const start = Math.max(0, index - ERROR_CONTEXT_RADIUS);
77
+ const end = Math.min(text.length, index + pattern.length + ERROR_CONTEXT_RADIUS);
78
+ const slice = text.slice(start, end).replace(/\s+/g, " ").trim();
79
+ const prefix = start > 0 ? "…" : "";
80
+ const suffix = end < text.length ? "…" : "";
81
+ return `${prefix}${slice}${suffix}`;
82
+ }
83
+
84
+ module.exports = {
85
+ hasLoadingMarkers,
86
+ hasRenderableContent,
87
+ describeBlankReason,
88
+ shouldStopWaitingForImages,
89
+ hasErrorPatterns,
90
+ findErrorPattern,
91
+ buildErrorContextSnippet,
92
+ ERROR_PATTERNS,
93
+ ERROR_CONTEXT_RADIUS,
94
+ };
@@ -0,0 +1,70 @@
1
+ function normalizeMockCandidates(url) {
2
+ try {
3
+ const parsed = new URL(url);
4
+ return [url, `${parsed.pathname}${parsed.search}`, parsed.pathname];
5
+ } catch {
6
+ return [url];
7
+ }
8
+ }
9
+
10
+ function findHttpMock(httpMocks, request) {
11
+ const method = request.method().toUpperCase();
12
+ const candidates = normalizeMockCandidates(request.url());
13
+ for (const candidate of candidates) {
14
+ const key = `${method} ${candidate}`;
15
+ if (httpMocks[key]) {
16
+ return httpMocks[key];
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+
22
+ async function attachHttpMocks(page, httpMocks) {
23
+ if (!httpMocks || Object.keys(httpMocks).length === 0) return;
24
+
25
+ await page.route("**/*", async (route) => {
26
+ const mock = findHttpMock(httpMocks, route.request());
27
+ if (!mock) {
28
+ await route.continue();
29
+ return;
30
+ }
31
+
32
+ const headers = { ...(mock.headers || {}) };
33
+ let body;
34
+ if (mock.body !== undefined) {
35
+ body =
36
+ typeof mock.body === "string" ? mock.body : JSON.stringify(mock.body);
37
+ const hasContentType = Object.keys(headers).some(
38
+ (key) => key.toLowerCase() === "content-type",
39
+ );
40
+ if (!hasContentType) {
41
+ headers["content-type"] = "application/json";
42
+ }
43
+ }
44
+
45
+ await route.fulfill({
46
+ status: mock.status || 200,
47
+ headers,
48
+ body,
49
+ });
50
+ });
51
+
52
+ // Disable the in-page fetch mock by returning an empty active-mocks.json.
53
+ // The HTML injects a script that synchronously loads this file and
54
+ // monkey-patches window.fetch, which would bypass Playwright's route
55
+ // interception. This route is registered AFTER **/* so it takes priority
56
+ // (Playwright uses LIFO ordering for route handlers).
57
+ await page.route("**/active-mocks.json", async (route) => {
58
+ await route.fulfill({
59
+ status: 200,
60
+ headers: { "content-type": "application/json" },
61
+ body: "[]",
62
+ });
63
+ });
64
+ }
65
+
66
+ module.exports = {
67
+ normalizeMockCandidates,
68
+ findHttpMock,
69
+ attachHttpMocks,
70
+ };
@@ -0,0 +1,191 @@
1
+ const {
2
+ hasLoadingMarkers,
3
+ shouldStopWaitingForImages,
4
+ } = require("./scenario-metrics");
5
+
6
+ function escapeHtmlAttribute(value) {
7
+ return String(value).replaceAll("&", "&amp;").replaceAll('"', "&quot;");
8
+ }
9
+
10
+ // The harness background defaults to transparent so the iframe's own
11
+ // <body> background paints through — matching what users see in the Live
12
+ // Preview. Callers (via scenario-check.js) pass a concrete color when the
13
+ // UI has detected a background it wants the capture to paint behind the
14
+ // iframe, e.g. `var(--bg-deep)` from the editor shell.
15
+ function buildIframeHarness(url, { background = "transparent" } = {}) {
16
+ const escapedUrl = escapeHtmlAttribute(url);
17
+ const bg = String(background);
18
+ return `<!doctype html>
19
+ <html>
20
+ <head>
21
+ <meta charset="utf-8">
22
+ <style>
23
+ html, body {
24
+ margin: 0;
25
+ width: 100%;
26
+ height: 100%;
27
+ overflow: hidden;
28
+ background: ${bg};
29
+ }
30
+
31
+ iframe {
32
+ display: block;
33
+ width: 100%;
34
+ height: 100%;
35
+ border: 0;
36
+ background: ${bg};
37
+ }
38
+ </style>
39
+ </head>
40
+ <body>
41
+ <iframe id="scenario-frame" title="Scenario Preview" src="${escapedUrl}"></iframe>
42
+ </body>
43
+ </html>`;
44
+ }
45
+
46
+ async function collectContentState(target) {
47
+ return target.evaluate(() => {
48
+ const root = document.getElementById("root");
49
+ const imgs = Array.from(document.images || []);
50
+ const loadedImageCount = imgs.filter(
51
+ (img) => img.complete && img.naturalWidth > 0,
52
+ ).length;
53
+ const mediaSelectors = ["svg", "canvas", "video"];
54
+ let mediaBboxCount = 0;
55
+ for (const selector of mediaSelectors) {
56
+ const nodes = document.querySelectorAll(selector);
57
+ for (const node of nodes) {
58
+ const rect = node.getBoundingClientRect();
59
+ if (rect.width > 0 && rect.height > 0) {
60
+ mediaBboxCount += 1;
61
+ }
62
+ }
63
+ }
64
+ return {
65
+ bodyTextLength: document.body.innerText.trim().length,
66
+ rootChildCount: root ? root.childElementCount : 0,
67
+ rootTextLength: root ? (root.textContent || "").trim().length : 0,
68
+ imageCount: imgs.length,
69
+ loadedImageCount,
70
+ mediaBboxCount,
71
+ };
72
+ });
73
+ }
74
+
75
+ async function collectImageStates(target) {
76
+ return target.evaluate(() =>
77
+ Array.from(document.images || []).map((img) => ({
78
+ complete: img.complete,
79
+ naturalWidth: img.naturalWidth,
80
+ src: img.currentSrc || img.src || "",
81
+ })),
82
+ );
83
+ }
84
+
85
+ async function waitForImagesSettled(
86
+ target,
87
+ { overallTimeoutMs = 5000, pollIntervalMs = 100 } = {},
88
+ ) {
89
+ const started = Date.now();
90
+ let images = await collectImageStates(target);
91
+ while (
92
+ !shouldStopWaitingForImages(images, {
93
+ elapsedMs: Date.now() - started,
94
+ overallTimeoutMs,
95
+ })
96
+ ) {
97
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
98
+ images = await collectImageStates(target);
99
+ }
100
+ const elapsedMs = Date.now() - started;
101
+ const allComplete = images.every((img) => img && img.complete === true);
102
+ return { settled: allComplete, images, elapsedMs };
103
+ }
104
+
105
+ async function waitForAnimationsSettled(
106
+ target,
107
+ { timeoutMs = 2000, pollIntervalMs = 100 } = {},
108
+ ) {
109
+ const started = Date.now();
110
+ while (Date.now() - started < timeoutMs) {
111
+ const runningCount = await target.evaluate(() =>
112
+ document
113
+ .getAnimations()
114
+ .filter((a) => a.playState === "running").length,
115
+ );
116
+ if (runningCount === 0) {
117
+ return { settled: true, elapsedMs: Date.now() - started };
118
+ }
119
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
120
+ }
121
+ return { settled: false, elapsedMs: Date.now() - started };
122
+ }
123
+
124
+ async function waitForStablePage(page, target, timeoutMs = 10000) {
125
+ const started = Date.now();
126
+ let lastHtml = "";
127
+ let stableCount = 0;
128
+
129
+ while (Date.now() - started < timeoutMs) {
130
+ await page.waitForTimeout(500);
131
+
132
+ const pageState = await target.evaluate(() => ({
133
+ bodyText: document.body.innerText,
134
+ html: document.body.innerHTML,
135
+ }));
136
+
137
+ if (!hasLoadingMarkers(pageState.bodyText) && pageState.html === lastHtml) {
138
+ stableCount += 1;
139
+ if (stableCount >= 2) {
140
+ const remaining = () => Math.max(0, timeoutMs - (Date.now() - started));
141
+ await waitForAnimationsSettled(target, {
142
+ timeoutMs: Math.min(2000, remaining()),
143
+ });
144
+ await waitForImagesSettled(target, { overallTimeoutMs: remaining() });
145
+ return;
146
+ }
147
+ } else {
148
+ stableCount = 0;
149
+ }
150
+
151
+ lastHtml = pageState.html;
152
+ }
153
+ }
154
+
155
+ async function loadScenarioInIframe(page, url, { background } = {}) {
156
+ const responsePromise = page
157
+ .waitForResponse(
158
+ (response) =>
159
+ response.request().resourceType() === "document" &&
160
+ response.url() === url,
161
+ { timeout: 30000 },
162
+ )
163
+ .catch(() => null);
164
+
165
+ await page.setContent(buildIframeHarness(url, { background }), {
166
+ waitUntil: "domcontentloaded",
167
+ });
168
+
169
+ const frameHandle = await page.waitForSelector("#scenario-frame", {
170
+ state: "attached",
171
+ timeout: 30000,
172
+ });
173
+ const frame = await frameHandle.contentFrame();
174
+ if (!frame) {
175
+ throw new Error("Scenario iframe did not attach");
176
+ }
177
+
178
+ await frame.waitForLoadState("load", { timeout: 30000 });
179
+ const response = await responsePromise;
180
+ return { frame, response };
181
+ }
182
+
183
+ module.exports = {
184
+ buildIframeHarness,
185
+ collectContentState,
186
+ collectImageStates,
187
+ waitForImagesSettled,
188
+ waitForAnimationsSettled,
189
+ waitForStablePage,
190
+ loadScenarioInIframe,
191
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codeyam-editor/codeyam-editor",
3
- "version": "0.1.0-staging.46bd02f",
3
+ "version": "0.1.0-staging.c9412f0",
4
4
  "description": "Language-agnostic managed execution sandbox for scenario-driven development",
5
5
  "bin": {
6
6
  "codeyam-editor": "./npm/cli.js"