@dodi-education/cli 0.0.0-stage → 0.1.0
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/LICENSE +661 -0
- package/README.md +90 -2
- package/dist/chunk-2ZK44UVJ.js +19 -0
- package/dist/chunk-KP4U5Z6H.js +19239 -0
- package/dist/dodi.js +6923 -0
- package/dist/index.umd-A32AINXN.js +2358 -0
- package/dist/renderer-3K4ZMPYW.js +444 -0
- package/package.json +74 -3
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
DEFAULT_SCREENSHOT_VIEWPORT,
|
|
4
|
+
SCREENSHOT_CONTRACT_VERSION,
|
|
5
|
+
SCREENSHOT_LIMITS,
|
|
6
|
+
createBridgeToken
|
|
7
|
+
} from "./chunk-KP4U5Z6H.js";
|
|
8
|
+
import "./chunk-2ZK44UVJ.js";
|
|
9
|
+
|
|
10
|
+
// ../../screenshot/src/renderer.ts
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
12
|
+
import { chromium } from "playwright";
|
|
13
|
+
|
|
14
|
+
// ../../screenshot/src/host-page.ts
|
|
15
|
+
function buildHostPage(viewport) {
|
|
16
|
+
return `<!doctype html>
|
|
17
|
+
<html>
|
|
18
|
+
<head>
|
|
19
|
+
<meta charset="utf-8" />
|
|
20
|
+
<style>
|
|
21
|
+
html, body { margin: 0; padding: 0; background: #fff; overflow: hidden; }
|
|
22
|
+
iframe { border: 0; display: block; width: ${viewport.width}px; height: ${viewport.height}px; }
|
|
23
|
+
</style>
|
|
24
|
+
</head>
|
|
25
|
+
<body>
|
|
26
|
+
<iframe id="game" title="Game" sandbox="allow-scripts" referrerpolicy="no-referrer"></iframe>
|
|
27
|
+
<script>
|
|
28
|
+
(function () {
|
|
29
|
+
var frame = document.getElementById('game');
|
|
30
|
+
window.addEventListener('message', function (e) {
|
|
31
|
+
if (!frame || e.source !== frame.contentWindow) return;
|
|
32
|
+
var json;
|
|
33
|
+
try { json = JSON.stringify(e.data); } catch (err) { return; }
|
|
34
|
+
if (typeof json !== 'string') return;
|
|
35
|
+
if (typeof window.__onGameMessage === 'function') window.__onGameMessage(json);
|
|
36
|
+
});
|
|
37
|
+
window.__postToGame = function (msg) {
|
|
38
|
+
if (frame && frame.contentWindow) frame.contentWindow.postMessage(msg, '*');
|
|
39
|
+
};
|
|
40
|
+
window.__loadGame = function (doc) {
|
|
41
|
+
return new Promise(function (resolve) {
|
|
42
|
+
frame.addEventListener('load', function () { resolve(); }, { once: true });
|
|
43
|
+
frame.srcdoc = doc;
|
|
44
|
+
});
|
|
45
|
+
};
|
|
46
|
+
})();
|
|
47
|
+
</script>
|
|
48
|
+
</body>
|
|
49
|
+
</html>`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ../../screenshot/src/layout-probe.ts
|
|
53
|
+
function probeLayout(options) {
|
|
54
|
+
const MIN_SIDE = 8;
|
|
55
|
+
const MIN_OVERLAP_SIDE = 4;
|
|
56
|
+
const MIN_OVERLAP_SHARE = 0.08;
|
|
57
|
+
const MAX_SCANNED = 1500;
|
|
58
|
+
const PAGE_LAYER_SHARE = 0.5;
|
|
59
|
+
const INTERACTIVE = /* @__PURE__ */ new Set([
|
|
60
|
+
"BUTTON",
|
|
61
|
+
"INPUT",
|
|
62
|
+
"SELECT",
|
|
63
|
+
"TEXTAREA",
|
|
64
|
+
"PROGRESS",
|
|
65
|
+
"METER",
|
|
66
|
+
"IMG",
|
|
67
|
+
"CANVAS",
|
|
68
|
+
"VIDEO",
|
|
69
|
+
"svg"
|
|
70
|
+
]);
|
|
71
|
+
const SKIP = /* @__PURE__ */ new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT", "BR", "HEAD", "META", "LINK"]);
|
|
72
|
+
const vw = window.innerWidth;
|
|
73
|
+
const vh = window.innerHeight;
|
|
74
|
+
const viewArea = vw * vh;
|
|
75
|
+
const hasOwnText = (el) => {
|
|
76
|
+
for (const node of Array.from(el.childNodes)) {
|
|
77
|
+
if (node.nodeType === Node.TEXT_NODE && (node.textContent ?? "").trim()) return true;
|
|
78
|
+
}
|
|
79
|
+
return false;
|
|
80
|
+
};
|
|
81
|
+
const hasVisibleBox = (style) => {
|
|
82
|
+
const bg = style.backgroundColor;
|
|
83
|
+
const transparent = bg === "transparent" || /rgba\(.*,\s*0\)$/.test(bg);
|
|
84
|
+
if (!transparent) return true;
|
|
85
|
+
if (style.backgroundImage && style.backgroundImage !== "none") return true;
|
|
86
|
+
if (style.boxShadow && style.boxShadow !== "none") return true;
|
|
87
|
+
return ["Top", "Right", "Bottom", "Left"].some(
|
|
88
|
+
(side) => parseFloat(style.getPropertyValue(`border-${side.toLowerCase()}-width`)) > 0 && style.getPropertyValue(`border-${side.toLowerCase()}-style`) !== "none"
|
|
89
|
+
);
|
|
90
|
+
};
|
|
91
|
+
const depthOf = (el) => {
|
|
92
|
+
let depth = 0;
|
|
93
|
+
for (let node = el.parentElement; node; node = node.parentElement) depth++;
|
|
94
|
+
return depth;
|
|
95
|
+
};
|
|
96
|
+
const pathOf = (el) => {
|
|
97
|
+
const parts = [];
|
|
98
|
+
for (let node = el; node && node !== document.documentElement; node = node.parentElement) {
|
|
99
|
+
const parent = node.parentElement;
|
|
100
|
+
const index = parent ? Array.from(parent.children).indexOf(node) : 0;
|
|
101
|
+
parts.push(`${node.tagName}:${index}`);
|
|
102
|
+
}
|
|
103
|
+
return parts.reverse().join("/");
|
|
104
|
+
};
|
|
105
|
+
const nameOf = (el) => {
|
|
106
|
+
let name = el.tagName.toLowerCase();
|
|
107
|
+
if (el.id) name += `#${el.id}`;
|
|
108
|
+
const classes = Array.from(el.classList).slice(0, 2);
|
|
109
|
+
if (classes.length) name += `.${classes.join(".")}`;
|
|
110
|
+
const text = (el.textContent ?? "").replace(/\s+/g, " ").trim();
|
|
111
|
+
if (text) name += ` "${text.length > 24 ? `${text.slice(0, 23)}\u2026` : text}"`;
|
|
112
|
+
return name;
|
|
113
|
+
};
|
|
114
|
+
const candidates = [];
|
|
115
|
+
const all = document.body ? Array.from(document.body.querySelectorAll("*")) : [];
|
|
116
|
+
for (const el of all.slice(0, MAX_SCANNED)) {
|
|
117
|
+
if (SKIP.has(el.tagName)) continue;
|
|
118
|
+
if (el.tagName !== "svg" && el.closest("svg")) continue;
|
|
119
|
+
if (el.closest("[data-overlap-ok]")) continue;
|
|
120
|
+
const visible = el.checkVisibility?.({ checkOpacity: true, checkVisibilityCSS: true });
|
|
121
|
+
if (visible === false) continue;
|
|
122
|
+
const style = getComputedStyle(el);
|
|
123
|
+
const box = el.getBoundingClientRect();
|
|
124
|
+
const rect = {
|
|
125
|
+
left: Math.max(0, box.left),
|
|
126
|
+
top: Math.max(0, box.top),
|
|
127
|
+
right: Math.min(vw, box.right),
|
|
128
|
+
bottom: Math.min(vh, box.bottom)
|
|
129
|
+
};
|
|
130
|
+
const width = rect.right - rect.left;
|
|
131
|
+
const height = rect.bottom - rect.top;
|
|
132
|
+
if (width < MIN_SIDE || height < MIN_SIDE) continue;
|
|
133
|
+
const area = width * height;
|
|
134
|
+
if (area > viewArea * PAGE_LAYER_SHARE) continue;
|
|
135
|
+
const text = hasOwnText(el);
|
|
136
|
+
const interactive = INTERACTIVE.has(el.tagName) || el.getAttribute("role") === "button" || el.getAttribute("role") === "progressbar" || el.tagName === "A" && el.hasAttribute("href");
|
|
137
|
+
if (style.pointerEvents === "none" && !text && !interactive) continue;
|
|
138
|
+
if (!text && !interactive && !hasVisibleBox(style)) continue;
|
|
139
|
+
candidates.push({ el, rect, area, depth: depthOf(el) });
|
|
140
|
+
}
|
|
141
|
+
const found = [];
|
|
142
|
+
for (let i = 0; i < candidates.length; i++) {
|
|
143
|
+
for (let j = i + 1; j < candidates.length; j++) {
|
|
144
|
+
const a = candidates[i];
|
|
145
|
+
const b = candidates[j];
|
|
146
|
+
if (a.el.contains(b.el) || b.el.contains(a.el)) continue;
|
|
147
|
+
const left = Math.max(a.rect.left, b.rect.left);
|
|
148
|
+
const top = Math.max(a.rect.top, b.rect.top);
|
|
149
|
+
const width = Math.min(a.rect.right, b.rect.right) - left;
|
|
150
|
+
const height = Math.min(a.rect.bottom, b.rect.bottom) - top;
|
|
151
|
+
if (width < MIN_OVERLAP_SIDE || height < MIN_OVERLAP_SIDE) continue;
|
|
152
|
+
const overlap = width * height;
|
|
153
|
+
if (overlap < MIN_OVERLAP_SHARE * Math.min(a.area, b.area)) continue;
|
|
154
|
+
const hit = document.elementFromPoint(left + width / 2, top + height / 2);
|
|
155
|
+
const onTop = hit && (a.el === hit || a.el.contains(hit)) ? "a" : hit && (b.el === hit || b.el.contains(hit)) ? "b" : null;
|
|
156
|
+
const [small, big, smallKey] = a.area <= b.area ? [a, b, "a"] : [b, a, "b"];
|
|
157
|
+
const inside = small.rect.left >= big.rect.left && small.rect.top >= big.rect.top && small.rect.right <= big.rect.right && small.rect.bottom <= big.rect.bottom;
|
|
158
|
+
if (inside && onTop === smallKey) continue;
|
|
159
|
+
found.push({
|
|
160
|
+
a: { name: nameOf(a.el), path: pathOf(a.el) },
|
|
161
|
+
b: { name: nameOf(b.el), path: pathOf(b.el) },
|
|
162
|
+
top: onTop,
|
|
163
|
+
x: Math.round(left),
|
|
164
|
+
y: Math.round(top),
|
|
165
|
+
width: Math.round(width),
|
|
166
|
+
height: Math.round(height),
|
|
167
|
+
aEl: a.el,
|
|
168
|
+
bEl: b.el,
|
|
169
|
+
depth: a.depth + b.depth
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
found.sort((x, y) => x.depth - y.depth || y.width * y.height - x.width * x.height);
|
|
174
|
+
const reported = [];
|
|
175
|
+
const within = (outer, inner) => outer === inner || outer.contains(inner);
|
|
176
|
+
for (const pair of found) {
|
|
177
|
+
const covered = reported.some(
|
|
178
|
+
(r) => within(r.aEl, pair.aEl) && within(r.bEl, pair.bEl) || within(r.aEl, pair.bEl) && within(r.bEl, pair.aEl)
|
|
179
|
+
);
|
|
180
|
+
if (covered) continue;
|
|
181
|
+
reported.push(pair);
|
|
182
|
+
if (reported.length >= options.maxOverlaps) break;
|
|
183
|
+
}
|
|
184
|
+
return reported.map(({ a, b, top, x, y, width, height }) => ({ a, b, top, x, y, width, height }));
|
|
185
|
+
}
|
|
186
|
+
function probeLayoutScript(options) {
|
|
187
|
+
return `(() => { const __name = (fn) => fn; return (${probeLayout.toString()})(${JSON.stringify(options)}); })()`;
|
|
188
|
+
}
|
|
189
|
+
function collectLayoutIssues(perFrame, frameLabels) {
|
|
190
|
+
const byPair = /* @__PURE__ */ new Map();
|
|
191
|
+
perFrame.forEach((overlaps, frameIndex) => {
|
|
192
|
+
for (const overlap of overlaps) {
|
|
193
|
+
const key = [overlap.a.path, overlap.b.path].sort().join("|");
|
|
194
|
+
const entry = byPair.get(key);
|
|
195
|
+
if (entry) {
|
|
196
|
+
if (!entry.frames.includes(frameIndex)) entry.frames.push(frameIndex);
|
|
197
|
+
} else {
|
|
198
|
+
byPair.set(key, { overlap, frames: [frameIndex] });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
const lines = [];
|
|
203
|
+
for (const { overlap, frames } of byPair.values()) {
|
|
204
|
+
const { a, b, top } = overlap;
|
|
205
|
+
const relation = top === "a" ? `${a.name} covers ${b.name}` : top === "b" ? `${b.name} covers ${a.name}` : `${a.name} overlaps ${b.name}`;
|
|
206
|
+
const where = `${overlap.width}\xD7${overlap.height} px at ${overlap.x},${overlap.y}`;
|
|
207
|
+
const shownIn = frames.map((i) => `${i + 1} (${frameLabels[i] ?? "?"})`).join(", ");
|
|
208
|
+
const line = `${relation} (${where}), frame${frames.length > 1 ? "s" : ""} ${shownIn}`;
|
|
209
|
+
lines.push(line.slice(0, SCREENSHOT_LIMITS.MAX_ERROR_CHARS));
|
|
210
|
+
if (lines.length >= SCREENSHOT_LIMITS.MAX_LAYOUT_ISSUES) break;
|
|
211
|
+
}
|
|
212
|
+
return lines;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ../../screenshot/src/renderer.ts
|
|
216
|
+
var INIT_RETRY_MS = 300;
|
|
217
|
+
var POST_COMMAND_PAINT_MS = 150;
|
|
218
|
+
var MAX_QUEUE_FACTOR = 2;
|
|
219
|
+
var RenderBusyError = class extends Error {
|
|
220
|
+
constructor() {
|
|
221
|
+
super("render queue is full");
|
|
222
|
+
this.name = "RenderBusyError";
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
var RenderTimeoutError = class extends Error {
|
|
226
|
+
constructor(ms) {
|
|
227
|
+
super(`render exceeded ${ms}ms`);
|
|
228
|
+
this.name = "RenderTimeoutError";
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
function note(list, text) {
|
|
232
|
+
if (list.length >= SCREENSHOT_LIMITS.MAX_ERRORS) return;
|
|
233
|
+
const trimmed = text.replace(/\s+/g, " ").trim();
|
|
234
|
+
if (trimmed) list.push(trimmed.slice(0, SCREENSHOT_LIMITS.MAX_ERROR_CHARS));
|
|
235
|
+
}
|
|
236
|
+
var GameInbox = class {
|
|
237
|
+
#waiters = /* @__PURE__ */ new Set();
|
|
238
|
+
push(json) {
|
|
239
|
+
let parsed;
|
|
240
|
+
try {
|
|
241
|
+
parsed = JSON.parse(json);
|
|
242
|
+
} catch {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (!parsed || typeof parsed !== "object") return;
|
|
246
|
+
const msg = parsed;
|
|
247
|
+
if (typeof msg.type !== "string") return;
|
|
248
|
+
for (const waiter of this.#waiters) waiter(msg);
|
|
249
|
+
}
|
|
250
|
+
/** Resolves with the first message matching `predicate`, or null after `timeoutMs`. */
|
|
251
|
+
waitFor(predicate, timeoutMs) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
const waiter = (msg) => {
|
|
254
|
+
if (!predicate(msg)) return;
|
|
255
|
+
done(msg);
|
|
256
|
+
};
|
|
257
|
+
const timer = setTimeout(() => done(null), timeoutMs);
|
|
258
|
+
const done = (value) => {
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
this.#waiters.delete(waiter);
|
|
261
|
+
resolve(value);
|
|
262
|
+
};
|
|
263
|
+
this.#waiters.add(waiter);
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
};
|
|
267
|
+
async function drive(page, request, opts) {
|
|
268
|
+
const errors = [];
|
|
269
|
+
const warnings = [];
|
|
270
|
+
const frames = [];
|
|
271
|
+
const inbox = new GameInbox();
|
|
272
|
+
const token = createBridgeToken();
|
|
273
|
+
page.on("pageerror", (error) => note(errors, error.message));
|
|
274
|
+
page.on("console", (msg) => {
|
|
275
|
+
if (msg.type() === "error") note(errors, msg.text());
|
|
276
|
+
});
|
|
277
|
+
await page.exposeFunction("__onGameMessage", (json) => inbox.push(json));
|
|
278
|
+
void inbox.waitFor((msg) => {
|
|
279
|
+
if (msg.type === "game:error") {
|
|
280
|
+
const detail = msg.payload?.error;
|
|
281
|
+
note(errors, `game:error: ${typeof detail === "string" ? detail : "unknown"}`);
|
|
282
|
+
}
|
|
283
|
+
return false;
|
|
284
|
+
}, opts.renderTimeoutMs);
|
|
285
|
+
const viewport = request.viewport ?? DEFAULT_SCREENSHOT_VIEWPORT;
|
|
286
|
+
await page.setContent(buildHostPage(viewport), { waitUntil: "load" });
|
|
287
|
+
await page.evaluate(
|
|
288
|
+
(doc) => window.__loadGame(doc),
|
|
289
|
+
request.document
|
|
290
|
+
);
|
|
291
|
+
const post = (msg) => page.evaluate((m) => window.__postToGame(m), msg);
|
|
292
|
+
const shoot = async () => {
|
|
293
|
+
const buffer = await page.screenshot({
|
|
294
|
+
type: "jpeg",
|
|
295
|
+
quality: SCREENSHOT_LIMITS.JPEG_QUALITY,
|
|
296
|
+
clip: { x: 0, y: 0, width: viewport.width, height: viewport.height }
|
|
297
|
+
});
|
|
298
|
+
return `data:image/jpeg;base64,${buffer.toString("base64")}`;
|
|
299
|
+
};
|
|
300
|
+
const probes = [];
|
|
301
|
+
const probe = async () => {
|
|
302
|
+
const gameFrame = page.frames().find((frame) => frame.parentFrame() === page.mainFrame());
|
|
303
|
+
const found = gameFrame ? await gameFrame.evaluate(
|
|
304
|
+
probeLayoutScript({ maxOverlaps: SCREENSHOT_LIMITS.MAX_LAYOUT_ISSUES })
|
|
305
|
+
).catch(() => []) : [];
|
|
306
|
+
probes.push(found);
|
|
307
|
+
};
|
|
308
|
+
const init = {
|
|
309
|
+
type: "dodi:init",
|
|
310
|
+
token,
|
|
311
|
+
payload: { gameId: randomUUID(), ...request.locale ? { locale: request.locale } : {} }
|
|
312
|
+
};
|
|
313
|
+
const readyPromise = inbox.waitFor(
|
|
314
|
+
(msg) => msg.type === "game:ready" && msg.token === token,
|
|
315
|
+
opts.readyTimeoutMs
|
|
316
|
+
);
|
|
317
|
+
const retry = setInterval(() => void post(init).catch(() => {
|
|
318
|
+
}), INIT_RETRY_MS);
|
|
319
|
+
await post(init).catch(() => {
|
|
320
|
+
});
|
|
321
|
+
const ready = await readyPromise !== null;
|
|
322
|
+
clearInterval(retry);
|
|
323
|
+
await page.waitForTimeout(request.settleMs ?? SCREENSHOT_LIMITS.DEFAULT_SETTLE_MS);
|
|
324
|
+
frames.push({ label: "initial", image: await shoot() });
|
|
325
|
+
await probe();
|
|
326
|
+
const steps = request.steps ?? [];
|
|
327
|
+
if (!ready && steps.length > 0) {
|
|
328
|
+
warnings.push(`skipped ${steps.length} step(s): the game never became ready`);
|
|
329
|
+
} else {
|
|
330
|
+
for (const [index, step] of steps.entries()) {
|
|
331
|
+
const waitMs = step.waitMs ?? SCREENSHOT_LIMITS.DEFAULT_STEP_WAIT_MS;
|
|
332
|
+
if (step.command) {
|
|
333
|
+
const resultPromise = inbox.waitFor(
|
|
334
|
+
(msg) => msg.type === "game:result" && msg.token === token,
|
|
335
|
+
waitMs
|
|
336
|
+
);
|
|
337
|
+
await post({ type: "dodi:command", token, payload: { command: step.command } });
|
|
338
|
+
if (await resultPromise === null) {
|
|
339
|
+
warnings.push(`step ${index + 1} (${step.label}): no game:result within ${waitMs}ms`);
|
|
340
|
+
}
|
|
341
|
+
await page.waitForTimeout(POST_COMMAND_PAINT_MS);
|
|
342
|
+
} else {
|
|
343
|
+
await page.waitForTimeout(waitMs);
|
|
344
|
+
}
|
|
345
|
+
frames.push({ label: step.label, image: await shoot() });
|
|
346
|
+
await probe();
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
const layoutIssues = collectLayoutIssues(
|
|
350
|
+
probes,
|
|
351
|
+
frames.map((frame) => frame.label)
|
|
352
|
+
);
|
|
353
|
+
return {
|
|
354
|
+
version: SCREENSHOT_CONTRACT_VERSION,
|
|
355
|
+
frames,
|
|
356
|
+
ready,
|
|
357
|
+
warnings,
|
|
358
|
+
errors,
|
|
359
|
+
...layoutIssues.length > 0 ? { layoutIssues } : {}
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
function createRenderer(opts) {
|
|
363
|
+
let browserPromise = null;
|
|
364
|
+
let active = 0;
|
|
365
|
+
const waiting = [];
|
|
366
|
+
const browser = () => {
|
|
367
|
+
if (browserPromise) return browserPromise;
|
|
368
|
+
const launching = chromium.launch({ headless: true, chromiumSandbox: opts.chromiumSandbox }).then((launched) => {
|
|
369
|
+
launched.on("disconnected", () => {
|
|
370
|
+
if (browserPromise === launching) browserPromise = null;
|
|
371
|
+
});
|
|
372
|
+
return launched;
|
|
373
|
+
});
|
|
374
|
+
launching.catch(() => {
|
|
375
|
+
if (browserPromise === launching) browserPromise = null;
|
|
376
|
+
});
|
|
377
|
+
browserPromise = launching;
|
|
378
|
+
return launching;
|
|
379
|
+
};
|
|
380
|
+
async function acquire() {
|
|
381
|
+
if (active < opts.maxConcurrent) {
|
|
382
|
+
active++;
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (waiting.length >= opts.maxConcurrent * MAX_QUEUE_FACTOR) throw new RenderBusyError();
|
|
386
|
+
await new Promise((resolve) => waiting.push(resolve));
|
|
387
|
+
active++;
|
|
388
|
+
}
|
|
389
|
+
function release() {
|
|
390
|
+
active--;
|
|
391
|
+
waiting.shift()?.();
|
|
392
|
+
}
|
|
393
|
+
async function renderOnce(request) {
|
|
394
|
+
const viewport = request.viewport ?? DEFAULT_SCREENSHOT_VIEWPORT;
|
|
395
|
+
const context = await (await browser()).newContext({
|
|
396
|
+
viewport,
|
|
397
|
+
deviceScaleFactor: 1,
|
|
398
|
+
// The document's CSP already forbids network; the context is offline and
|
|
399
|
+
// every request is aborted as belt and braces.
|
|
400
|
+
offline: true,
|
|
401
|
+
javaScriptEnabled: true
|
|
402
|
+
});
|
|
403
|
+
try {
|
|
404
|
+
const page = await context.newPage();
|
|
405
|
+
await page.route("**/*", (route) => route.abort());
|
|
406
|
+
const work = drive(page, request, opts);
|
|
407
|
+
work.catch(() => {
|
|
408
|
+
});
|
|
409
|
+
let timer;
|
|
410
|
+
const deadline = new Promise((_, reject) => {
|
|
411
|
+
timer = setTimeout(() => reject(new RenderTimeoutError(opts.renderTimeoutMs)), opts.renderTimeoutMs);
|
|
412
|
+
});
|
|
413
|
+
try {
|
|
414
|
+
return await Promise.race([work, deadline]);
|
|
415
|
+
} finally {
|
|
416
|
+
clearTimeout(timer);
|
|
417
|
+
}
|
|
418
|
+
} finally {
|
|
419
|
+
await context.close().catch(() => {
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return {
|
|
424
|
+
async render(request) {
|
|
425
|
+
await acquire();
|
|
426
|
+
try {
|
|
427
|
+
return await renderOnce(request);
|
|
428
|
+
} finally {
|
|
429
|
+
release();
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
async close() {
|
|
433
|
+
const open = browserPromise;
|
|
434
|
+
browserPromise = null;
|
|
435
|
+
if (open) await open.then((b) => b.close()).catch(() => {
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
};
|
|
439
|
+
}
|
|
440
|
+
export {
|
|
441
|
+
RenderBusyError,
|
|
442
|
+
RenderTimeoutError,
|
|
443
|
+
createRenderer
|
|
444
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,77 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dodi-education/cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "The dodi CLI: let your own AI agent build learning games and companion looks for your family on dodi.",
|
|
5
|
+
"license": "AGPL-3.0-only",
|
|
6
|
+
"author": "dodi",
|
|
7
|
+
"homepage": "https://dodi.app",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/dodi-education/dodi-app.git",
|
|
11
|
+
"directory": "clients/cli"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/dodi-education/dodi-app/issues"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"dodi",
|
|
18
|
+
"cli",
|
|
19
|
+
"mcp",
|
|
20
|
+
"ai-agent",
|
|
21
|
+
"learning-games",
|
|
22
|
+
"kids",
|
|
23
|
+
"e2ee"
|
|
24
|
+
],
|
|
25
|
+
"type": "module",
|
|
26
|
+
"bin": {
|
|
27
|
+
"dodi": "./dist/dodi.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"dist",
|
|
31
|
+
"README.md",
|
|
32
|
+
"LICENSE"
|
|
33
|
+
],
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=20"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"yaml": "^2.9.1"
|
|
39
|
+
},
|
|
40
|
+
"peerDependencies": {
|
|
41
|
+
"playwright": ">=1.50.0"
|
|
42
|
+
},
|
|
43
|
+
"peerDependenciesMeta": {
|
|
44
|
+
"playwright": {
|
|
45
|
+
"optional": true
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@types/node": "^20",
|
|
50
|
+
"esbuild": "^0.28.2",
|
|
51
|
+
"tsx": "4.23.13",
|
|
52
|
+
"typescript": "^5",
|
|
53
|
+
"vitest": "^4.1.8",
|
|
54
|
+
"zod": "^4.3.6",
|
|
55
|
+
"@dodi/ai": "0.0.0",
|
|
56
|
+
"@dodi/character": "0.0.0",
|
|
57
|
+
"@dodi/crypto": "0.0.0",
|
|
58
|
+
"@dodi/games": "0.0.0",
|
|
59
|
+
"@dodi/intl": "0.0.0",
|
|
60
|
+
"@dodi/protocol": "0.0.0",
|
|
61
|
+
"@dodi/types": "0.0.0",
|
|
62
|
+
"@dodi/screenshot": "0.0.0",
|
|
63
|
+
"@dodi/vault": "0.0.0"
|
|
64
|
+
},
|
|
65
|
+
"publishConfig": {
|
|
66
|
+
"access": "public",
|
|
67
|
+
"provenance": false
|
|
68
|
+
},
|
|
69
|
+
"scripts": {
|
|
70
|
+
"dev": "tsx src/index.ts",
|
|
71
|
+
"build": "node scripts/build.mjs",
|
|
72
|
+
"typecheck": "tsc --noEmit",
|
|
73
|
+
"lint": "tsc --noEmit",
|
|
74
|
+
"test": "vitest run",
|
|
75
|
+
"test:watch": "vitest"
|
|
76
|
+
}
|
|
6
77
|
}
|