@codeyam-editor/codeyam-editor 0.1.0 → 0.1.1
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/container.js +7 -3
- package/npm/editor-dev.js +128 -0
- package/npm/editor.js +133 -9
- package/npm/postinstall.js +48 -11
- package/npm/scenario-check.js +85 -304
- package/npm/scenario-handlers.js +71 -0
- package/npm/scenario-issues.js +35 -0
- package/npm/scenario-metrics.js +94 -0
- package/npm/scenario-mocks.js +70 -0
- package/npm/scenario-playwright.js +192 -0
- package/npm/utils.js +44 -4
- package/package.json +9 -3
package/npm/scenario-check.js
CHANGED
|
@@ -1,322 +1,94 @@
|
|
|
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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
}
|
|
16
|
+
const {
|
|
17
|
+
findErrorPattern,
|
|
18
|
+
buildErrorContextSnippet,
|
|
19
|
+
hasRenderableContent,
|
|
20
|
+
describeBlankReason,
|
|
21
|
+
} = require("./scenario-metrics");
|
|
198
22
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
23
|
+
const {
|
|
24
|
+
createIssue,
|
|
25
|
+
pushIssue,
|
|
26
|
+
buildResult,
|
|
27
|
+
} = require("./scenario-issues");
|
|
202
28
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
(response) =>
|
|
207
|
-
response.request().resourceType() === "document" &&
|
|
208
|
-
response.url() === url,
|
|
209
|
-
{ timeout: 30000 },
|
|
210
|
-
)
|
|
211
|
-
.catch(() => null);
|
|
29
|
+
const {
|
|
30
|
+
attachHttpMocks,
|
|
31
|
+
} = require("./scenario-mocks");
|
|
212
32
|
|
|
213
|
-
|
|
33
|
+
const {
|
|
34
|
+
loadScenarioInIframe,
|
|
35
|
+
waitForStablePage,
|
|
36
|
+
collectContentState,
|
|
37
|
+
} = require("./scenario-playwright");
|
|
214
38
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
throw new Error("Scenario iframe did not attach");
|
|
222
|
-
}
|
|
39
|
+
const {
|
|
40
|
+
getInitScript,
|
|
41
|
+
handleConsoleMessage,
|
|
42
|
+
handlePageError,
|
|
43
|
+
handleRequestFailed,
|
|
44
|
+
} = require("./scenario-handlers");
|
|
223
45
|
|
|
224
|
-
|
|
225
|
-
const response = await responsePromise;
|
|
226
|
-
return { frame, response };
|
|
227
|
-
}
|
|
46
|
+
const BLANK_RETRY_DELAY_MS = 500;
|
|
228
47
|
|
|
229
48
|
async function runScenarioCheck(config) {
|
|
230
49
|
const { url, outputPath, width, height, httpMocks = {} } = config;
|
|
231
50
|
const issues = [];
|
|
232
51
|
const browser = await chromium.launch();
|
|
233
|
-
const
|
|
52
|
+
const contextOptions = {
|
|
234
53
|
viewport: { width: width || 1440, height: height || 900 },
|
|
235
|
-
}
|
|
54
|
+
};
|
|
55
|
+
if (config.colorScheme) contextOptions.colorScheme = config.colorScheme;
|
|
56
|
+
if (config.deviceScaleFactor)
|
|
57
|
+
contextOptions.deviceScaleFactor = config.deviceScaleFactor;
|
|
58
|
+
if (config.userAgent) contextOptions.userAgent = config.userAgent;
|
|
59
|
+
if (config.locale) contextOptions.locale = config.locale;
|
|
60
|
+
if (config.timezoneId) contextOptions.timezoneId = config.timezoneId;
|
|
61
|
+
if (config.reduceMotion) contextOptions.reducedMotion = config.reduceMotion;
|
|
62
|
+
if (config.forcedColors) contextOptions.forcedColors = config.forcedColors;
|
|
63
|
+
const context = await browser.newContext(contextOptions);
|
|
236
64
|
|
|
237
65
|
// 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
|
-
});
|
|
66
|
+
await context.addInitScript(getInitScript());
|
|
274
67
|
|
|
275
68
|
const page = await context.newPage();
|
|
276
69
|
await attachHttpMocks(page, httpMocks);
|
|
277
70
|
|
|
278
71
|
page.on("pageerror", (error) => {
|
|
279
|
-
pushIssue(
|
|
280
|
-
issues,
|
|
281
|
-
createIssue("pageerror", error.message || String(error), {
|
|
282
|
-
url: page.url() || url,
|
|
283
|
-
}),
|
|
284
|
-
);
|
|
72
|
+
pushIssue(issues, handlePageError(error));
|
|
285
73
|
});
|
|
286
74
|
|
|
287
75
|
page.on("console", (message) => {
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
if (
|
|
292
|
-
text.includes("WebSocket connection to") ||
|
|
293
|
-
text.includes("Unsupported Media Type")
|
|
294
|
-
) {
|
|
295
|
-
return;
|
|
76
|
+
const issue = handleConsoleMessage(message);
|
|
77
|
+
if (issue) {
|
|
78
|
+
pushIssue(issues, issue);
|
|
296
79
|
}
|
|
297
|
-
pushIssue(
|
|
298
|
-
issues,
|
|
299
|
-
createIssue("console", text, {
|
|
300
|
-
url: page.url() || url,
|
|
301
|
-
}),
|
|
302
|
-
);
|
|
303
80
|
});
|
|
304
81
|
|
|
305
82
|
page.on("requestfailed", (request) => {
|
|
306
|
-
pushIssue(
|
|
307
|
-
issues,
|
|
308
|
-
createIssue(
|
|
309
|
-
"requestfailed",
|
|
310
|
-
request.failure()?.errorText || "Request failed",
|
|
311
|
-
{ url: request.url() },
|
|
312
|
-
),
|
|
313
|
-
);
|
|
83
|
+
pushIssue(issues, handleRequestFailed(request));
|
|
314
84
|
});
|
|
315
85
|
|
|
316
86
|
let loaded = false;
|
|
317
87
|
|
|
318
88
|
try {
|
|
319
|
-
const { frame, response } = await loadScenarioInIframe(page, url
|
|
89
|
+
const { frame, response } = await loadScenarioInIframe(page, url, {
|
|
90
|
+
background: config.iframeBackground,
|
|
91
|
+
});
|
|
320
92
|
loaded = true;
|
|
321
93
|
|
|
322
94
|
if (response && response.status() >= 400) {
|
|
@@ -343,26 +115,46 @@ async function runScenarioCheck(config) {
|
|
|
343
115
|
);
|
|
344
116
|
}
|
|
345
117
|
|
|
346
|
-
|
|
347
|
-
|
|
118
|
+
// Cold-start retry: a single re-collect after a short pause covers
|
|
119
|
+
// the React.lazy / Suspense-fallback race that flagged 4-of-59
|
|
120
|
+
// scenarios as blank against a cold Vite dev server on 2026-04-30.
|
|
121
|
+
// One retry is enough — the Suspense window is sub-second in
|
|
122
|
+
// practice, longer waits just slow down genuine blank-render bugs.
|
|
123
|
+
let contentState = await collectContentState(frame);
|
|
124
|
+
let hasContent = hasRenderableContent(contentState);
|
|
125
|
+
if (!hasContent) {
|
|
126
|
+
await new Promise((r) => setTimeout(r, BLANK_RETRY_DELAY_MS));
|
|
127
|
+
contentState = await collectContentState(frame);
|
|
128
|
+
hasContent = hasRenderableContent(contentState);
|
|
129
|
+
}
|
|
348
130
|
|
|
349
131
|
if (!hasContent) {
|
|
350
132
|
pushIssue(
|
|
351
133
|
issues,
|
|
352
|
-
createIssue(
|
|
353
|
-
|
|
354
|
-
|
|
134
|
+
createIssue(
|
|
135
|
+
"blank",
|
|
136
|
+
`Page rendered no visible content (${describeBlankReason(contentState)})`,
|
|
137
|
+
{ url: page.url() || url },
|
|
138
|
+
),
|
|
355
139
|
);
|
|
356
140
|
}
|
|
357
141
|
|
|
358
142
|
// Check for known error states in the rendered content
|
|
359
143
|
const bodyText = await frame.evaluate(() => document.body.innerText || "");
|
|
360
|
-
|
|
144
|
+
const matchedPattern = findErrorPattern(bodyText);
|
|
145
|
+
if (matchedPattern) {
|
|
146
|
+
const contextSnippet = buildErrorContextSnippet(bodyText, matchedPattern);
|
|
361
147
|
pushIssue(
|
|
362
148
|
issues,
|
|
363
|
-
createIssue(
|
|
364
|
-
|
|
365
|
-
|
|
149
|
+
createIssue(
|
|
150
|
+
"error-state",
|
|
151
|
+
`Page contains error content (matched "${matchedPattern}"): ${contextSnippet ?? bodyText.slice(0, 200)}`,
|
|
152
|
+
{
|
|
153
|
+
url: page.url() || url,
|
|
154
|
+
matchedPattern,
|
|
155
|
+
contextSnippet,
|
|
156
|
+
},
|
|
157
|
+
),
|
|
366
158
|
);
|
|
367
159
|
}
|
|
368
160
|
|
|
@@ -411,19 +203,8 @@ async function main() {
|
|
|
411
203
|
}
|
|
412
204
|
|
|
413
205
|
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
206
|
runScenarioCheck,
|
|
426
|
-
|
|
207
|
+
main,
|
|
427
208
|
};
|
|
428
209
|
|
|
429
210
|
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
|
+
};
|