@digital-gravy/etch-connector 0.2.0 → 0.2.2
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/dist/cdp-client.js +101 -21
- package/dist/cli.js +21 -4
- package/dist/guidance.js +9 -2
- package/package.json +1 -1
package/dist/cdp-client.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
* keep the surface small.
|
|
15
15
|
*/
|
|
16
16
|
import { WebSocket } from 'ws';
|
|
17
|
+
import { z } from 'zod';
|
|
17
18
|
/** A script evaluated over CDP threw. Distinct from operational failures. */
|
|
18
19
|
export class CdpEvalError extends Error {
|
|
19
20
|
/** The full page-side exception text (description + stack), if any. */
|
|
@@ -34,6 +35,62 @@ export class CdpError extends Error {
|
|
|
34
35
|
const DEFAULT_PORT = 9222;
|
|
35
36
|
const DEFAULT_HOST = '127.0.0.1';
|
|
36
37
|
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
38
|
+
/**
|
|
39
|
+
* Page-side helpers prepended to the inspection expressions. Etch renders its
|
|
40
|
+
* canvas inside a same-origin iframe that `document.querySelector` can't see
|
|
41
|
+
* into, so canvas documents are searched first: `h1` resolves to the page being
|
|
42
|
+
* built, not the builder UI.
|
|
43
|
+
*/
|
|
44
|
+
const PAGE_HELPERS = `
|
|
45
|
+
const __etchDocuments = () => [
|
|
46
|
+
...Array.from(document.querySelectorAll('iframe'), (frame) => frame.contentDocument).filter(Boolean),
|
|
47
|
+
document
|
|
48
|
+
];
|
|
49
|
+
const __etchFind = (selector) => {
|
|
50
|
+
for (const doc of __etchDocuments()) {
|
|
51
|
+
const el = doc.querySelector(selector);
|
|
52
|
+
if (el) return el;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
};
|
|
56
|
+
// Content outside a frame's viewport isn't painted, even with captureBeyondViewport.
|
|
57
|
+
const __etchClipToFrame = (edges, frame) => ({
|
|
58
|
+
left: Math.max(edges.left, 0),
|
|
59
|
+
top: Math.max(edges.top, 0),
|
|
60
|
+
right: Math.min(edges.right, frame.clientWidth),
|
|
61
|
+
bottom: Math.min(edges.bottom, frame.clientHeight)
|
|
62
|
+
});
|
|
63
|
+
// The canvas is zoomed with a CSS transform, so frame-local pixels are scaled.
|
|
64
|
+
const __etchToParentFrame = (edges, frame) => {
|
|
65
|
+
const frameRect = frame.getBoundingClientRect();
|
|
66
|
+
const scale = frame.offsetWidth ? frameRect.width / frame.offsetWidth : 1;
|
|
67
|
+
return {
|
|
68
|
+
left: frameRect.left + (frame.clientLeft + edges.left) * scale,
|
|
69
|
+
right: frameRect.left + (frame.clientLeft + edges.right) * scale,
|
|
70
|
+
top: frameRect.top + (frame.clientTop + edges.top) * scale,
|
|
71
|
+
bottom: frameRect.top + (frame.clientTop + edges.bottom) * scale
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
const __etchPageRect = (el) => {
|
|
75
|
+
const { left, top, right, bottom } = el.getBoundingClientRect();
|
|
76
|
+
let edges = { left, top, right, bottom };
|
|
77
|
+
let cropped = false;
|
|
78
|
+
for (let frame = el.ownerDocument.defaultView.frameElement; frame; frame = frame.ownerDocument.defaultView.frameElement) {
|
|
79
|
+
const visible = __etchClipToFrame(edges, frame);
|
|
80
|
+
if (visible.right <= visible.left || visible.bottom <= visible.top) return null;
|
|
81
|
+
if (Object.keys(visible).some((side) => visible[side] !== edges[side])) cropped = true;
|
|
82
|
+
edges = __etchToParentFrame(visible, frame);
|
|
83
|
+
}
|
|
84
|
+
return { x: edges.left + scrollX, y: edges.top + scrollY, width: edges.right - edges.left, height: edges.bottom - edges.top, cropped };
|
|
85
|
+
};
|
|
86
|
+
`;
|
|
87
|
+
const PageRectSchema = z.object({
|
|
88
|
+
x: z.number(),
|
|
89
|
+
y: z.number(),
|
|
90
|
+
width: z.number(),
|
|
91
|
+
height: z.number(),
|
|
92
|
+
cropped: z.boolean()
|
|
93
|
+
});
|
|
37
94
|
export class CdpClient {
|
|
38
95
|
host;
|
|
39
96
|
port;
|
|
@@ -187,7 +244,7 @@ export class CdpClient {
|
|
|
187
244
|
}
|
|
188
245
|
/** Rendered `outerHTML` of the first element matching `selector`. */
|
|
189
246
|
async outerHtml(selector) {
|
|
190
|
-
const v = await this.evaluate(`(() => { const el =
|
|
247
|
+
const v = await this.evaluate(`(() => { ${PAGE_HELPERS} const el = __etchFind(${JSON.stringify(selector)}); return el ? el.outerHTML : null; })()`);
|
|
191
248
|
if (v === null)
|
|
192
249
|
throw new CdpError(`no element matches "${selector}"`);
|
|
193
250
|
return v;
|
|
@@ -196,9 +253,10 @@ export class CdpClient {
|
|
|
196
253
|
async computedStyle(selector, props) {
|
|
197
254
|
const keys = props ? JSON.stringify(props) : 'Array.from(cs)';
|
|
198
255
|
const expr = `(() => {
|
|
199
|
-
|
|
256
|
+
${PAGE_HELPERS}
|
|
257
|
+
const el = __etchFind(${JSON.stringify(selector)});
|
|
200
258
|
if (!el) return null;
|
|
201
|
-
const cs = getComputedStyle(el);
|
|
259
|
+
const cs = el.ownerDocument.defaultView.getComputedStyle(el);
|
|
202
260
|
const out = {};
|
|
203
261
|
for (const k of ${keys}) out[k] = cs.getPropertyValue(k);
|
|
204
262
|
return out;
|
|
@@ -217,16 +275,25 @@ export class CdpClient {
|
|
|
217
275
|
try {
|
|
218
276
|
let clip;
|
|
219
277
|
let captureBeyondViewport = false;
|
|
278
|
+
let cropped = false;
|
|
220
279
|
if (options.selector) {
|
|
221
|
-
const rect =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
280
|
+
const rect = await this.evaluate(`(() => {
|
|
281
|
+
${PAGE_HELPERS}
|
|
282
|
+
const el = __etchFind(${JSON.stringify(options.selector)});
|
|
283
|
+
if (!el) return 'missing';
|
|
284
|
+
const box = el.getBoundingClientRect();
|
|
285
|
+
if (!box.width || !box.height) return 'empty';
|
|
286
|
+
return __etchPageRect(el) ?? 'offscreen';
|
|
287
|
+
})()`);
|
|
288
|
+
if (rect === 'missing')
|
|
228
289
|
throw new CdpError(`no element matches "${options.selector}"`);
|
|
229
|
-
|
|
290
|
+
if (rect === 'empty')
|
|
291
|
+
throw new CdpError(`"${options.selector}" has no rendered size (hidden or empty)`);
|
|
292
|
+
if (rect === 'offscreen')
|
|
293
|
+
throw new CdpError(`"${options.selector}" is outside the visible canvas area — ask the user to scroll it into view in the builder`);
|
|
294
|
+
const { cropped: partlyVisible, ...box } = PageRectSchema.parse(rect);
|
|
295
|
+
clip = { ...box, scale: 1 };
|
|
296
|
+
cropped = partlyVisible;
|
|
230
297
|
captureBeyondViewport = true;
|
|
231
298
|
}
|
|
232
299
|
else if (options.full) {
|
|
@@ -246,7 +313,7 @@ export class CdpClient {
|
|
|
246
313
|
...(clip ? { clip } : {}),
|
|
247
314
|
captureBeyondViewport
|
|
248
315
|
});
|
|
249
|
-
return Buffer.from(data, 'base64');
|
|
316
|
+
return { image: Buffer.from(data, 'base64'), cropped };
|
|
250
317
|
}
|
|
251
318
|
finally {
|
|
252
319
|
if (options.freeze)
|
|
@@ -256,29 +323,42 @@ export class CdpClient {
|
|
|
256
323
|
/** Pause every animation/transition and seek it to a deterministic frame. */
|
|
257
324
|
async freezeAnimations() {
|
|
258
325
|
await this.evaluate(`(() => {
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
326
|
+
${PAGE_HELPERS}
|
|
327
|
+
const documents = __etchDocuments();
|
|
328
|
+
for (const doc of documents) {
|
|
329
|
+
if (doc.getElementById('__etch_freeze__')) continue;
|
|
330
|
+
const s = doc.createElement('style');
|
|
262
331
|
s.id = '__etch_freeze__';
|
|
263
332
|
s.textContent = '*,*::before,*::after{transition:none!important}';
|
|
264
|
-
(
|
|
333
|
+
(doc.head || doc.documentElement).appendChild(s);
|
|
265
334
|
}
|
|
266
|
-
|
|
335
|
+
const frozen = [];
|
|
336
|
+
for (const a of documents.flatMap((doc) => doc.getAnimations())) {
|
|
267
337
|
try {
|
|
338
|
+
frozen.push({ animation: a, playState: a.playState, currentTime: a.currentTime });
|
|
268
339
|
a.pause();
|
|
269
340
|
const end = a.effect && a.effect.getComputedTiming ? a.effect.getComputedTiming().endTime : 0;
|
|
270
341
|
a.currentTime = (typeof end === 'number' && isFinite(end)) ? end : 0;
|
|
271
342
|
} catch (e) { /* leave this one as-is */ }
|
|
272
343
|
}
|
|
344
|
+
window.__etchFrozenAnimations = frozen;
|
|
273
345
|
return true;
|
|
274
346
|
})()`);
|
|
275
347
|
}
|
|
276
|
-
/** Undo {@link freezeAnimations}:
|
|
348
|
+
/** Undo {@link freezeAnimations}: put each animation back where it was and remove the helper style. */
|
|
277
349
|
async restoreAnimations() {
|
|
350
|
+
// A blanket play() would restart finished animations (auto-rewind) and resume ones the page paused itself.
|
|
278
351
|
await this.evaluate(`(() => {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
for (const
|
|
352
|
+
${PAGE_HELPERS}
|
|
353
|
+
for (const doc of __etchDocuments()) doc.getElementById('__etch_freeze__')?.remove();
|
|
354
|
+
for (const { animation, playState, currentTime } of window.__etchFrozenAnimations ?? []) {
|
|
355
|
+
try {
|
|
356
|
+
animation.currentTime = currentTime;
|
|
357
|
+
if (playState === 'running') animation.play();
|
|
358
|
+
if (playState === 'finished') animation.finish();
|
|
359
|
+
} catch (e) { /* leave this one as-is */ }
|
|
360
|
+
}
|
|
361
|
+
delete window.__etchFrozenAnimations;
|
|
282
362
|
return true;
|
|
283
363
|
})()`);
|
|
284
364
|
}
|
package/dist/cli.js
CHANGED
|
@@ -451,14 +451,16 @@ async function runShot(flags) {
|
|
|
451
451
|
const format = flags['jpeg'] ? 'jpeg' : 'png';
|
|
452
452
|
const freeze = bool(flags['freeze'], true); // default on (animations → deterministic)
|
|
453
453
|
const out = str(flags['out']) ?? `etch-shot.${format === 'jpeg' ? 'jpg' : 'png'}`;
|
|
454
|
-
const
|
|
455
|
-
writeFileSync(out,
|
|
454
|
+
const { image, cropped } = await withRequiredCdp(flags, (c) => c.screenshot({ selector, full, format, freeze }));
|
|
455
|
+
writeFileSync(out, image);
|
|
456
456
|
const scope = selector
|
|
457
457
|
? ` — clipped to ${selector}`
|
|
458
458
|
: full
|
|
459
459
|
? ' — full page'
|
|
460
460
|
: ' — viewport';
|
|
461
|
-
console.error(`wrote ${out} (${
|
|
461
|
+
console.error(`wrote ${out} (${image.length} bytes)${scope}${freeze ? ' (frozen)' : ''}`);
|
|
462
|
+
if (cropped)
|
|
463
|
+
console.error(`warning: ${selector} extends past the visible canvas area, so the shot only shows the visible part — ask the user to scroll it fully into view`);
|
|
462
464
|
}
|
|
463
465
|
async function runHtml(positional, flags) {
|
|
464
466
|
const selector = requireSelector('html', positional, flags);
|
|
@@ -504,6 +506,20 @@ AGENT: before running anything, ask the user how they want to connect — bridge
|
|
|
504
506
|
(API only) or cdp (DevTools privilege: full page access). cdp grants more reach;
|
|
505
507
|
don't assume — let the user choose.
|
|
506
508
|
|
|
509
|
+
AGENT: EVERY change goes through the public etch.* API — no exceptions, in
|
|
510
|
+
either backend, for anything, inline styles included. NEVER touch the canvas
|
|
511
|
+
directly: no document/DOM manipulation, no injected <style> tags, no setting
|
|
512
|
+
attributes or styles on canvas elements or iframes. Styling goes through
|
|
513
|
+
Etch's styling system by default: etch.styles.* for selectors/classes,
|
|
514
|
+
etch.blocks.addClass() to apply them, etch.stylesheets.* for global CSS.
|
|
515
|
+
When a value genuinely belongs inline (e.g. a per-instance custom property
|
|
516
|
+
bound to a prop, style="--radius: {props.radius};"), it is still authored
|
|
517
|
+
through the API — as the block's style attribute via etch.blocks.* — so it
|
|
518
|
+
lands in the saved document, not just the rendered page. cdp's page access is
|
|
519
|
+
for inspecting (shot, html, computed) — never for writing. Those three already
|
|
520
|
+
look inside the canvas iframe, so inspecting a canvas element never needs a
|
|
521
|
+
hand-written eval.
|
|
522
|
+
|
|
507
523
|
AGENT: \`etch-connector tabs\` prints, per connected tab, which runtime it is and
|
|
508
524
|
which surfaces that runtime backs (from \`etch.environment\`), followed by the
|
|
509
525
|
authoring rules for it — the dynamic-data model, the media lookup, the
|
|
@@ -538,7 +554,8 @@ Examples:
|
|
|
538
554
|
|
|
539
555
|
The eval body runs as an async function, so it may 'return' a value and use
|
|
540
556
|
top-level 'await'. In bridge mode reference the API as "etch.*" ("window" is
|
|
541
|
-
withheld); in cdp mode the full page is
|
|
557
|
+
withheld); in cdp mode the full page is readable, but changes still go
|
|
558
|
+
through etch.* only.
|
|
542
559
|
|
|
543
560
|
AI presence (AGENT: drive the etch.ai overlay so the human watching the tab
|
|
544
561
|
sees an agent is active — this connector is exactly what etch.ai is built for.
|
package/dist/guidance.js
CHANGED
|
@@ -35,7 +35,12 @@ function dynamicDataGuidance(environment) {
|
|
|
35
35
|
" yielding a list: data('products'), a path into one",
|
|
36
36
|
" (data('products').items), nested data from an enclosing loop",
|
|
37
37
|
' (item.tags), or a generated list (range(1, 10)). There is no',
|
|
38
|
-
' loopId/loopParams here.'
|
|
38
|
+
' loopId/loopParams here.',
|
|
39
|
+
' The sources themselves are managed through etch.dataSources.*:',
|
|
40
|
+
' list(), get(key) and findDataSource(query) read them synchronously;',
|
|
41
|
+
' createAsync(), updateAsync() and deleteAsync() persist immediately.',
|
|
42
|
+
' Call resolveAsync(key) to see the value a source actually returns',
|
|
43
|
+
' before authoring property paths into it — do not guess its shape.'
|
|
39
44
|
];
|
|
40
45
|
}
|
|
41
46
|
if (hasCapability(environment, 'loops')) {
|
|
@@ -44,7 +49,9 @@ function dynamicDataGuidance(environment) {
|
|
|
44
49
|
' definitions. An etch/loop block binds one by `loopId`, passes',
|
|
45
50
|
' $-prefixed `loopParams`, and `target` is an optional sub-path within',
|
|
46
51
|
" it. There are no data sources here — a data('key') expression",
|
|
47
|
-
' resolves to nothing.'
|
|
52
|
+
' resolves to nothing and etch.dataSources is absent, not merely',
|
|
53
|
+
' unavailable: the property does not exist, so feature-detect it',
|
|
54
|
+
' rather than calling it and catching.'
|
|
48
55
|
];
|
|
49
56
|
}
|
|
50
57
|
return [
|