@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
|
@@ -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,192 @@
|
|
|
1
|
+
const {
|
|
2
|
+
hasLoadingMarkers,
|
|
3
|
+
shouldStopWaitingForImages,
|
|
4
|
+
} = require("./scenario-metrics");
|
|
5
|
+
|
|
6
|
+
function escapeHtmlAttribute(value) {
|
|
7
|
+
return String(value).replaceAll("&", "&").replaceAll('"', """);
|
|
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
|
+
escapeHtmlAttribute,
|
|
185
|
+
buildIframeHarness,
|
|
186
|
+
collectContentState,
|
|
187
|
+
collectImageStates,
|
|
188
|
+
waitForImagesSettled,
|
|
189
|
+
waitForAnimationsSettled,
|
|
190
|
+
waitForStablePage,
|
|
191
|
+
loadScenarioInIframe,
|
|
192
|
+
};
|
package/npm/utils.js
CHANGED
|
@@ -6,12 +6,22 @@ const fs = require("fs");
|
|
|
6
6
|
const rootDir = path.resolve(__dirname, "..");
|
|
7
7
|
const serverStatePath = path.join(rootDir, ".codeyam", "server-state.json");
|
|
8
8
|
|
|
9
|
+
/**
|
|
10
|
+
* Platform-correct executable filename for the codeyam-editor binary.
|
|
11
|
+
* Windows requires the `.exe` extension; on Unix the binary has no extension.
|
|
12
|
+
* Accepts an explicit platform so the helper is testable across platforms.
|
|
13
|
+
*/
|
|
14
|
+
function binaryName(platform = process.platform) {
|
|
15
|
+
return platform === "win32" ? "codeyam-editor.exe" : "codeyam-editor";
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
/** Find the compiled binary — check npm-installed location first, then dev builds. */
|
|
10
19
|
function findBinary() {
|
|
20
|
+
const name = binaryName();
|
|
11
21
|
const candidates = [
|
|
12
|
-
path.join(rootDir, "bin",
|
|
13
|
-
path.join(rootDir, "target", "debug",
|
|
14
|
-
path.join(rootDir, "target", "release",
|
|
22
|
+
path.join(rootDir, "bin", name), // npm install (postinstall)
|
|
23
|
+
path.join(rootDir, "target", "debug", name), // local dev (debug)
|
|
24
|
+
path.join(rootDir, "target", "release", name), // local dev (release)
|
|
15
25
|
];
|
|
16
26
|
|
|
17
27
|
for (const candidate of candidates) {
|
|
@@ -136,14 +146,38 @@ async function requestServerShutdown(port = 14199, timeoutMs = 1500) {
|
|
|
136
146
|
|
|
137
147
|
function tryKillPid(pid) {
|
|
138
148
|
try {
|
|
139
|
-
process.kill(pid, "SIGINT")
|
|
149
|
+
// On Windows, `process.kill(pid, "SIGINT")` either errors or no-ops
|
|
150
|
+
// depending on Node version. Drop the signal so Node maps to a
|
|
151
|
+
// platform TerminateProcess. Less graceful, but the child does exit.
|
|
152
|
+
if (process.platform === "win32") {
|
|
153
|
+
process.kill(pid);
|
|
154
|
+
} else {
|
|
155
|
+
process.kill(pid, "SIGINT");
|
|
156
|
+
}
|
|
140
157
|
return true;
|
|
141
158
|
} catch {
|
|
142
159
|
return false;
|
|
143
160
|
}
|
|
144
161
|
}
|
|
145
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Send the platform-correct termination signal to a spawned child process.
|
|
165
|
+
* Used by editor.js / editor-dev.js / container.js when the user hits Ctrl+C —
|
|
166
|
+
* `child.kill("SIGINT")` is unreliable on Windows.
|
|
167
|
+
*/
|
|
168
|
+
function killChildProcess(child) {
|
|
169
|
+
if (!child) return;
|
|
170
|
+
if (process.platform === "win32") {
|
|
171
|
+
child.kill();
|
|
172
|
+
} else {
|
|
173
|
+
child.kill("SIGINT");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
146
177
|
function findListeningPids(port) {
|
|
178
|
+
// Process discovery on Windows is best-effort — `lsof` is not installed and
|
|
179
|
+
// a `netstat -ano` parser is a follow-up. The API-shutdown path and the
|
|
180
|
+
// PID-file path together stop the server cleanly in normal operation.
|
|
147
181
|
if (process.platform === "win32") return [];
|
|
148
182
|
|
|
149
183
|
const result = spawnSync("lsof", [`-tiTCP:${port}`, "-sTCP:LISTEN"], {
|
|
@@ -229,6 +263,7 @@ async function ensureServer(port = 14199) {
|
|
|
229
263
|
module.exports = {
|
|
230
264
|
rootDir,
|
|
231
265
|
serverStatePath,
|
|
266
|
+
binaryName,
|
|
232
267
|
findBinary,
|
|
233
268
|
ensureBinary,
|
|
234
269
|
uiDistDir,
|
|
@@ -237,7 +272,12 @@ module.exports = {
|
|
|
237
272
|
waitForPort,
|
|
238
273
|
waitForPortToClose,
|
|
239
274
|
readServerState,
|
|
275
|
+
clearServerState,
|
|
276
|
+
requestServerShutdown,
|
|
277
|
+
tryKillPid,
|
|
278
|
+
findListeningPids,
|
|
240
279
|
stopServer,
|
|
241
280
|
restartServer,
|
|
242
281
|
ensureServer,
|
|
282
|
+
killChildProcess,
|
|
243
283
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@codeyam-editor/codeyam-editor",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Language-agnostic managed execution sandbox for scenario-driven development",
|
|
5
5
|
"bin": {
|
|
6
6
|
"codeyam-editor": "./npm/cli.js"
|
|
@@ -9,9 +9,15 @@
|
|
|
9
9
|
"postinstall": "node ./npm/postinstall.js"
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
|
-
"playwright": "^1.58.2"
|
|
12
|
+
"playwright": "^1.58.2",
|
|
13
|
+
"tar": "^7.5.13"
|
|
13
14
|
},
|
|
14
|
-
"keywords": [
|
|
15
|
+
"keywords": [
|
|
16
|
+
"codeyam",
|
|
17
|
+
"editor",
|
|
18
|
+
"development",
|
|
19
|
+
"scenarios"
|
|
20
|
+
],
|
|
15
21
|
"homepage": "https://codeyam.com/",
|
|
16
22
|
"license": "MIT",
|
|
17
23
|
"engines": {
|