@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,82 @@
|
|
|
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
|
+
const errorText = request.failure()?.errorText || "Request failed";
|
|
60
|
+
|
|
61
|
+
// Filter benign request cancellations. `net::ERR_ABORTED` is what Playwright
|
|
62
|
+
// emits when a request is in-flight at the moment the page is closed (or the
|
|
63
|
+
// iframe is destroyed). For scenarios whose pages fetch large payloads (the
|
|
64
|
+
// editor's own EditorShell mounts a 2.2MB `/api/tests` fetch), the
|
|
65
|
+
// browser.close() at the end of capture races the fetch and produces this
|
|
66
|
+
// event AFTER the screenshot has already been taken — there is no real
|
|
67
|
+
// failure to surface. Genuine network failures arrive under different
|
|
68
|
+
// codes (net::ERR_CONNECTION_REFUSED, net::ERR_NAME_NOT_RESOLVED, etc.)
|
|
69
|
+
// and continue to be reported.
|
|
70
|
+
if (errorText.includes("net::ERR_ABORTED")) {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return createIssue("requestfailed", errorText, { url: request.url() });
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
module.exports = {
|
|
78
|
+
getInitScript,
|
|
79
|
+
handleConsoleMessage,
|
|
80
|
+
handlePageError,
|
|
81
|
+
handleRequestFailed,
|
|
82
|
+
};
|
|
@@ -0,0 +1,245 @@
|
|
|
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` (framework-owned controls
|
|
96
|
+
// exist but no attachment signal — the dead-hydration case), or `null` (no
|
|
97
|
+
// detector for this framework, OR every interactive control is delegated to a
|
|
98
|
+
// terminal/canvas widget with no hydration marker → cannot judge).
|
|
99
|
+
async function collectHydrationState(frame, { framework } = {}) {
|
|
100
|
+
return frame.evaluate((fw) => {
|
|
101
|
+
const SELECTOR =
|
|
102
|
+
'button, [role="button"], a[href], input:not([type="hidden"]), select, textarea, summary, [onclick]';
|
|
103
|
+
// Roots of third-party terminal/canvas widgets that mount imperatively and
|
|
104
|
+
// wire their own (non-framework) event handlers — xterm's `.xterm`, any
|
|
105
|
+
// `<canvas>`, or an element a component explicitly flags terminal-backed.
|
|
106
|
+
// Interactive controls inside these (e.g. xterm's hidden helper
|
|
107
|
+
// `<textarea>`) never carry framework attachment keys even on a fully
|
|
108
|
+
// hydrated page, so judging hydration from them yields a false "not
|
|
109
|
+
// interactive" verdict. Stack assumption: `.xterm` is xterm-specific; the
|
|
110
|
+
// `<canvas>` and `[data-terminal-backed]` entries are framework-agnostic
|
|
111
|
+
// escape hatches any widget-embedding component can use.
|
|
112
|
+
const WIDGET_ROOT_SELECTOR = ".xterm, canvas, [data-terminal-backed]";
|
|
113
|
+
|
|
114
|
+
const controls = Array.from(document.querySelectorAll(SELECTOR));
|
|
115
|
+
const inWidget = (el) =>
|
|
116
|
+
!!el &&
|
|
117
|
+
typeof el.closest === "function" &&
|
|
118
|
+
el.closest(WIDGET_ROOT_SELECTOR) != null;
|
|
119
|
+
// Framework-owned controls: those NOT delegated to a terminal/canvas
|
|
120
|
+
// widget. Only these can prove or disprove that the framework hydrated.
|
|
121
|
+
const frameworkControls = controls.filter((el) => !inWidget(el));
|
|
122
|
+
|
|
123
|
+
// Per-framework attachment detectors. Each returns true when the framework
|
|
124
|
+
// has demonstrably attached its client runtime to a live node. The
|
|
125
|
+
// presence of a detector for `fw` is itself the signal that we CAN judge
|
|
126
|
+
// this framework; the default (no detector) means "cannot determine".
|
|
127
|
+
const detectors = {
|
|
128
|
+
react: (els) =>
|
|
129
|
+
els.some(
|
|
130
|
+
(el) =>
|
|
131
|
+
!!el &&
|
|
132
|
+
Object.keys(el).some(
|
|
133
|
+
(k) =>
|
|
134
|
+
k.startsWith("__reactFiber$") ||
|
|
135
|
+
k.startsWith("__reactProps$") ||
|
|
136
|
+
k.startsWith("__reactContainer$"),
|
|
137
|
+
),
|
|
138
|
+
),
|
|
139
|
+
vue: (els) =>
|
|
140
|
+
els.some(
|
|
141
|
+
(el) =>
|
|
142
|
+
!!el &&
|
|
143
|
+
(el.__vue__ != null ||
|
|
144
|
+
el.__vnode != null ||
|
|
145
|
+
el.__vueParentComponent != null),
|
|
146
|
+
) || !!document.querySelector("[data-v-app]"),
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const detector = detectors[fw];
|
|
150
|
+
|
|
151
|
+
// Terminal/canvas scenarios prove hydration with an explicit, framework-
|
|
152
|
+
// rendered `data-codeyam-hydrated` marker. It counts only when the
|
|
153
|
+
// framework actually attached to it — a marker that exists in static HTML
|
|
154
|
+
// but never hydrated fails the same detector and does not count.
|
|
155
|
+
const markerEl = document.querySelector("[data-codeyam-hydrated]");
|
|
156
|
+
const markerAttached = detector
|
|
157
|
+
? detector(markerEl ? [markerEl] : [])
|
|
158
|
+
: false;
|
|
159
|
+
|
|
160
|
+
let frameworkAttached;
|
|
161
|
+
if (!detector) {
|
|
162
|
+
// No detector for this framework — cannot judge (conservative pass).
|
|
163
|
+
frameworkAttached = null;
|
|
164
|
+
} else if (detector(frameworkControls) || markerAttached) {
|
|
165
|
+
// A genuinely framework-owned control attached, or an explicit hydration
|
|
166
|
+
// marker proves the framework ran — definitively hydrated.
|
|
167
|
+
frameworkAttached = true;
|
|
168
|
+
} else if (frameworkControls.length === 0) {
|
|
169
|
+
// Controls exist but every one lives inside a terminal/canvas widget and
|
|
170
|
+
// there is no marker — we cannot prove the page is dead, so never flag.
|
|
171
|
+
frameworkAttached = null;
|
|
172
|
+
} else {
|
|
173
|
+
// Framework-owned controls rendered but none attached — the dead-island
|
|
174
|
+
// signal the gate exists to catch.
|
|
175
|
+
frameworkAttached = false;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return { controlCount: controls.length, frameworkAttached };
|
|
179
|
+
}, framework || null);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Turn a collected state into a `hydration` issue, or `null` to pass. Pure, so
|
|
183
|
+
// every branch is unit-tested without a browser.
|
|
184
|
+
//
|
|
185
|
+
// Pass when: no client runtime expected (no-op stacks); no interactive control
|
|
186
|
+
// to probe (a static content page in a client app is legitimately inert); the
|
|
187
|
+
// framework attached; OR we couldn't determine attachment (`null` — never
|
|
188
|
+
// false-positive). Flag only the proven-dead case: controls exist AND the
|
|
189
|
+
// framework's runtime is demonstrably not attached.
|
|
190
|
+
function interpretHydration({
|
|
191
|
+
expectInteractive,
|
|
192
|
+
controlCount,
|
|
193
|
+
frameworkAttached,
|
|
194
|
+
framework,
|
|
195
|
+
url,
|
|
196
|
+
}) {
|
|
197
|
+
if (!expectInteractive) return null;
|
|
198
|
+
if (!(controlCount > 0)) return null;
|
|
199
|
+
if (frameworkAttached !== false) return null;
|
|
200
|
+
const fw = framework || "the client framework";
|
|
201
|
+
const plural = controlCount === 1 ? "" : "s";
|
|
202
|
+
return createIssue(
|
|
203
|
+
"hydration",
|
|
204
|
+
`Page rendered ${controlCount} interactive control${plural} but ${fw} never attached ` +
|
|
205
|
+
`event handlers — the page is not interactive (hydration did not run). Client JS may ` +
|
|
206
|
+
`not be executing; check the preview proxy and the browser console. Run ` +
|
|
207
|
+
"`codeyam-editor editor diagnose-preview --path <route>` to pinpoint a proxy " +
|
|
208
|
+
`HTML-injection blocker.`,
|
|
209
|
+
{ url: url ?? null },
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Orchestrator called from the capture flow: resolve the expectation (from the
|
|
214
|
+
// project's stack.json unless the caller injects a `stack`), short-circuit when
|
|
215
|
+
// no client runtime is expected, collect the in-page state, and interpret it.
|
|
216
|
+
// Never throws — a probe failure must not break an otherwise-good capture.
|
|
217
|
+
async function probeInteractivity(frame, { url, stack } = {}) {
|
|
218
|
+
const descriptor = stack !== undefined ? stack : readStackJson();
|
|
219
|
+
const { expectInteractive, framework } =
|
|
220
|
+
resolveInteractivityExpectation(descriptor);
|
|
221
|
+
if (!expectInteractive) return null;
|
|
222
|
+
let state;
|
|
223
|
+
try {
|
|
224
|
+
state = await collectHydrationState(frame, { framework });
|
|
225
|
+
} catch (_) {
|
|
226
|
+
return null;
|
|
227
|
+
}
|
|
228
|
+
return interpretHydration({
|
|
229
|
+
expectInteractive: true,
|
|
230
|
+
controlCount: state.controlCount,
|
|
231
|
+
frameworkAttached: state.frameworkAttached,
|
|
232
|
+
framework,
|
|
233
|
+
url,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
module.exports = {
|
|
238
|
+
KNOWN_FRAMEWORKS,
|
|
239
|
+
readStackJson,
|
|
240
|
+
inferFramework,
|
|
241
|
+
resolveInteractivityExpectation,
|
|
242
|
+
collectHydrationState,
|
|
243
|
+
interpretHydration,
|
|
244
|
+
probeInteractivity,
|
|
245
|
+
};
|
|
@@ -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,108 @@
|
|
|
1
|
+
const LOADING_MARKERS = [
|
|
2
|
+
"Loading scenario...",
|
|
3
|
+
"Loading tests...",
|
|
4
|
+
"Loading scenarios...",
|
|
5
|
+
"disconnected",
|
|
6
|
+
];
|
|
7
|
+
|
|
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
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function hasRenderableContent(state) {
|
|
27
|
+
if (!state) return false;
|
|
28
|
+
if (
|
|
29
|
+
state.rootChildCount > 0 ||
|
|
30
|
+
state.rootTextLength > 0 ||
|
|
31
|
+
state.bodyTextLength > 0
|
|
32
|
+
) {
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
35
|
+
if ((state.loadedImageCount || 0) > 0) return true;
|
|
36
|
+
if ((state.mediaBboxCount || 0) > 0) return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function describeBlankReason(state) {
|
|
41
|
+
if (!state) return "no content state collected";
|
|
42
|
+
const parts = [];
|
|
43
|
+
if (!(state.bodyTextLength > 0)) {
|
|
44
|
+
parts.push("no text");
|
|
45
|
+
}
|
|
46
|
+
const imageCount = state.imageCount || 0;
|
|
47
|
+
const loadedImageCount = state.loadedImageCount || 0;
|
|
48
|
+
if (imageCount > 0 && loadedImageCount === 0) {
|
|
49
|
+
parts.push(`${imageCount} unloaded image${imageCount === 1 ? "" : "s"}`);
|
|
50
|
+
} else if (imageCount === 0) {
|
|
51
|
+
parts.push("no images");
|
|
52
|
+
}
|
|
53
|
+
if (!((state.mediaBboxCount || 0) > 0)) {
|
|
54
|
+
parts.push("no svg/canvas/video");
|
|
55
|
+
}
|
|
56
|
+
return parts.join(", ");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function shouldStopWaitingForImages(images, options = {}) {
|
|
60
|
+
const { elapsedMs = 0, overallTimeoutMs = 5000 } = options;
|
|
61
|
+
if (!Array.isArray(images) || images.length === 0) return true;
|
|
62
|
+
if (elapsedMs >= overallTimeoutMs) return true;
|
|
63
|
+
return images.every((img) => img && img.complete === true);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const ERROR_PATTERNS = [
|
|
67
|
+
"not found in registry",
|
|
68
|
+
"Component not found",
|
|
69
|
+
"Scenario Error",
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function hasErrorPatterns(text) {
|
|
73
|
+
return ERROR_PATTERNS.some((pattern) => text.includes(pattern));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function findErrorPattern(text) {
|
|
77
|
+
if (!text) return null;
|
|
78
|
+
for (const pattern of ERROR_PATTERNS) {
|
|
79
|
+
if (text.includes(pattern)) return pattern;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const ERROR_CONTEXT_RADIUS = 60;
|
|
85
|
+
|
|
86
|
+
function buildErrorContextSnippet(text, pattern) {
|
|
87
|
+
if (!text || !pattern) return null;
|
|
88
|
+
const index = text.indexOf(pattern);
|
|
89
|
+
if (index < 0) return null;
|
|
90
|
+
const start = Math.max(0, index - ERROR_CONTEXT_RADIUS);
|
|
91
|
+
const end = Math.min(text.length, index + pattern.length + ERROR_CONTEXT_RADIUS);
|
|
92
|
+
const slice = text.slice(start, end).replace(/\s+/g, " ").trim();
|
|
93
|
+
const prefix = start > 0 ? "…" : "";
|
|
94
|
+
const suffix = end < text.length ? "…" : "";
|
|
95
|
+
return `${prefix}${slice}${suffix}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = {
|
|
99
|
+
hasLoadingMarkers,
|
|
100
|
+
hasRenderableContent,
|
|
101
|
+
describeBlankReason,
|
|
102
|
+
shouldStopWaitingForImages,
|
|
103
|
+
hasErrorPatterns,
|
|
104
|
+
findErrorPattern,
|
|
105
|
+
buildErrorContextSnippet,
|
|
106
|
+
ERROR_PATTERNS,
|
|
107
|
+
ERROR_CONTEXT_RADIUS,
|
|
108
|
+
};
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
// 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
|
+
|
|
45
|
+
async function attachHttpMocks(page, httpMocks) {
|
|
46
|
+
if (!httpMocks || Object.keys(httpMocks).length === 0) return;
|
|
47
|
+
|
|
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
|
+
}
|
|
63
|
+
|
|
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
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
await route.fulfill({
|
|
80
|
+
status: mock.status || 200,
|
|
81
|
+
headers,
|
|
82
|
+
body,
|
|
83
|
+
});
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
// Disable the in-page fetch mock by returning an empty active-mocks.json.
|
|
88
|
+
// The HTML injects a script that synchronously loads this file and
|
|
89
|
+
// monkey-patches window.fetch, which would bypass Playwright's route
|
|
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).
|
|
92
|
+
await page.route("**/active-mocks.json", async (route) => {
|
|
93
|
+
await route.fulfill({
|
|
94
|
+
status: 200,
|
|
95
|
+
headers: { "content-type": "application/json" },
|
|
96
|
+
body: "[]",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = {
|
|
102
|
+
normalizeMockCandidates,
|
|
103
|
+
findHttpMock,
|
|
104
|
+
mockedTargets,
|
|
105
|
+
requestTargetsMock,
|
|
106
|
+
attachHttpMocks,
|
|
107
|
+
};
|