@milpa/comal 0.2.2 → 0.2.3

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.
Files changed (109) hide show
  1. package/README.md +1 -1
  2. package/backend/contracts/remote-commands-v1.json +0 -12
  3. package/backend/contracts/remote-relay-answers-v1.json +209 -0
  4. package/package.json +3 -1
  5. package/skills/mv3/SKILL.md +44 -0
  6. package/skills/mv3/skill.json +4 -0
  7. package/src/agent.mjs +299 -32
  8. package/src/auto-guard.mjs +40 -3
  9. package/src/autocompact.mjs +233 -0
  10. package/src/bash-timeouts.mjs +29 -0
  11. package/src/child-env.mjs +23 -0
  12. package/src/cli.mjs +213 -55
  13. package/src/code-run-artifact.mjs +30 -0
  14. package/src/code-run-journal.mjs +135 -0
  15. package/src/code-run-session-proof.mjs +50 -0
  16. package/src/code-run-session.mjs +97 -0
  17. package/src/code-run-worker.mjs +20 -4
  18. package/src/code-run.mjs +49 -14
  19. package/src/codex-provider.mjs +4 -4
  20. package/src/compaction-notice.mjs +22 -0
  21. package/src/compaction.mjs +162 -23
  22. package/src/connection-settings.mjs +36 -4
  23. package/src/context-pressure.mjs +5 -4
  24. package/src/context.mjs +27 -22
  25. package/src/execution-metrics.mjs +36 -5
  26. package/src/file-recovery-proof.mjs +9 -1
  27. package/src/file-recovery.mjs +36 -3
  28. package/src/gemini-provider.mjs +5 -3
  29. package/src/git-integration-operations.mjs +2 -1
  30. package/src/git-plumbing.mjs +2 -1
  31. package/src/git-source.mjs +2 -1
  32. package/src/git-workspaces.mjs +2 -1
  33. package/src/house.mjs +13 -1
  34. package/src/i18n/cli-es.mjs +12 -1
  35. package/src/i18n/es.mjs +38 -1
  36. package/src/judges.mjs +2 -1
  37. package/src/mcp.mjs +2 -1
  38. package/src/model-controls.mjs +16 -2
  39. package/src/model-deadline.mjs +2 -2
  40. package/src/openai-provider.mjs +8 -4
  41. package/src/operations.mjs +17 -2
  42. package/src/package-identity.mjs +68 -0
  43. package/src/peer-probe.mjs +16 -0
  44. package/src/permissions.mjs +97 -4
  45. package/src/plugin-config.mjs +18 -1
  46. package/src/plugin-controls.mjs +31 -8
  47. package/src/plugin-trust.mjs +15 -6
  48. package/src/plugins/chrome-lab/bridge.mjs +19 -6
  49. package/src/plugins/chrome-lab/client.mjs +242 -12
  50. package/src/plugins/chrome-lab/index.mjs +106 -24
  51. package/src/plugins/chrome-lab/legacy-fill.mjs +25 -0
  52. package/src/plugins/chrome-lab/native-reply.mjs +31 -0
  53. package/src/plugins/open-relay/actions.mjs +60 -9
  54. package/src/plugins/open-relay/client.mjs +3 -3
  55. package/src/plugins/open-relay/decision.mjs +3 -2
  56. package/src/plugins/open-relay/index.mjs +17 -7
  57. package/src/plugins/open-relay/lookup.mjs +41 -4
  58. package/src/plugins/open-relay/notices.mjs +44 -3
  59. package/src/plugins/open-relay/panel.mjs +4 -1
  60. package/src/plugins/open-relay/watch.mjs +27 -8
  61. package/src/process-lock.mjs +20 -12
  62. package/src/provider-auth.mjs +6 -3
  63. package/src/provider-profiles.mjs +13 -3
  64. package/src/provider.mjs +175 -30
  65. package/src/read-only-check.mjs +6 -1
  66. package/src/relay/broker.mjs +160 -0
  67. package/src/relay/cards.mjs +702 -0
  68. package/src/relay/flow.mjs +886 -0
  69. package/src/relay/follow.mjs +119 -0
  70. package/src/relay/git.mjs +23 -0
  71. package/src/relay/hub-client.mjs +163 -0
  72. package/src/relay/state.mjs +55 -0
  73. package/src/remote-control.mjs +115 -12
  74. package/src/remote-relay.mjs +468 -0
  75. package/src/session-bus.mjs +13 -3
  76. package/src/session-provider-settings.mjs +2 -2
  77. package/src/skill-runtime.mjs +8 -6
  78. package/src/source-ownership.mjs +3 -1
  79. package/src/tool-manifest.mjs +71 -0
  80. package/src/tool-packs.mjs +22 -6
  81. package/src/tool-schema.mjs +14 -2
  82. package/src/tools.mjs +96 -27
  83. package/src/tui/controller.mjs +4 -1
  84. package/src/tui/discovery.mjs +11 -7
  85. package/src/tui/inbound-blocks.mjs +5 -1
  86. package/src/tui/screen.mjs +53 -14
  87. package/src/tui/swarm-panel.mjs +19 -8
  88. package/src/turn-timing.mjs +82 -0
  89. package/src/usage-accounting.mjs +12 -1
  90. package/src/vision.mjs +6 -1
  91. package/src/visual/chrome.mjs +379 -0
  92. package/src/visual/fingerprint.mjs +30 -0
  93. package/src/visual/fixtures.mjs +201 -0
  94. package/src/visual/gate.mjs +92 -0
  95. package/src/visual/judge.mjs +114 -0
  96. package/src/visual/ledger.mjs +160 -0
  97. package/src/visual/measure.mjs +145 -0
  98. package/src/visual/names.mjs +7 -0
  99. package/src/visual/operations.mjs +211 -0
  100. package/src/visual/pixel-diff.mjs +96 -0
  101. package/src/visual/png.mjs +103 -0
  102. package/src/visual/registry.mjs +265 -0
  103. package/src/visual/report.mjs +64 -0
  104. package/src/visual/verify.mjs +292 -0
  105. package/src/work-assertions.mjs +15 -4
  106. package/src/work-plan.mjs +166 -30
  107. package/src/work-state.mjs +4 -4
  108. package/src/workflows/chrome-task-adoption.mjs +7 -3
  109. package/src/workspace-serve.mjs +59 -0
@@ -0,0 +1,379 @@
1
+ // House-owned Chrome driver for visual acceptance. Launches the local stable
2
+ // Chrome with a throwaway profile and speaks CDP over --remote-debugging-pipe,
3
+ // which also enables Extensions.loadUnpacked (branded Chrome dropped
4
+ // --load-extension). Selectors pierce shadow roots (open or closed) and enter
5
+ // iframes, including out-of-process extension frames, with ` >>> ` segments.
6
+ import { spawn } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
8
+ import fs from 'node:fs/promises';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ const CANDIDATES = ['/usr/bin/google-chrome-stable', '/usr/bin/google-chrome', '/opt/google/chrome/chrome', '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
13
+ export function defaultChromeBinary(env = process.env) { return env.COMAL_VISUAL_CHROME || CANDIDATES.find(file => existsSync(file)) || CANDIDATES[0]; }
14
+
15
+ const fail = (message, code = 'VISUAL_CHROME', extra = {}) => Object.assign(new Error(message), { code, ...extra });
16
+ const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
17
+
18
+ class CdpPipe {
19
+ constructor(child) {
20
+ this.out = child.stdio[3]; this.id = 0; this.pending = new Map(); this.listeners = new Set(); this.buffer = ''; this.closed = null;
21
+ child.stdio[4].setEncoding('utf8');
22
+ child.stdio[4].on('data', chunk => {
23
+ this.buffer += chunk;
24
+ for (let end = this.buffer.indexOf('\0'); end >= 0; end = this.buffer.indexOf('\0')) {
25
+ const message = JSON.parse(this.buffer.slice(0, end)); this.buffer = this.buffer.slice(end + 1);
26
+ if (message.id !== undefined && this.pending.has(message.id)) { const entry = this.pending.get(message.id); this.pending.delete(message.id); entry.settle(message); }
27
+ else for (const listener of [...this.listeners]) listener(message);
28
+ }
29
+ });
30
+ this.out.on('error', () => {});
31
+ }
32
+ send(method, params = {}, sessionId, timeoutMs = 30000) {
33
+ if (this.closed) return Promise.reject(this.closed);
34
+ const id = ++this.id;
35
+ return new Promise((resolve, reject) => {
36
+ const timer = setTimeout(() => { this.pending.delete(id); reject(fail(`CDP ${method} no respondió en ${timeoutMs} ms.`, 'VISUAL_CDP_TIMEOUT')); }, timeoutMs);
37
+ this.pending.set(id, { settle: message => { clearTimeout(timer); message.error ? reject(fail(`CDP ${method}: ${message.error.message}`, 'VISUAL_CDP_ERROR', { cdp: message.error })) : resolve(message.result); },
38
+ reject: error => { clearTimeout(timer); reject(error); } });
39
+ this.out.write(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }) + '\0');
40
+ });
41
+ }
42
+ on(listener) { this.listeners.add(listener); return () => this.listeners.delete(listener); }
43
+ waitFor(predicate, timeoutMs, label) {
44
+ return new Promise((resolve, reject) => {
45
+ const off = this.on(message => { if (predicate(message)) { clearTimeout(timer); off(); resolve(message); } });
46
+ const timer = setTimeout(() => { off(); reject(fail(`${label} no ocurrió en ${timeoutMs} ms.`, 'VISUAL_CDP_TIMEOUT')); }, timeoutMs);
47
+ });
48
+ }
49
+ close(error) { this.closed = error; for (const entry of this.pending.values()) entry.reject(error); this.pending.clear(); }
50
+ }
51
+
52
+ const KEYS = Object.freeze({
53
+ Enter: { key: 'Enter', code: 'Enter', windowsVirtualKeyCode: 13, text: '\r' }, Escape: { key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 },
54
+ Tab: { key: 'Tab', code: 'Tab', windowsVirtualKeyCode: 9 }, ArrowDown: { key: 'ArrowDown', code: 'ArrowDown', windowsVirtualKeyCode: 40 },
55
+ ArrowUp: { key: 'ArrowUp', code: 'ArrowUp', windowsVirtualKeyCode: 38 }, Backspace: { key: 'Backspace', code: 'Backspace', windowsVirtualKeyCode: 8 },
56
+ });
57
+ export const STEP_ACTIONS = Object.freeze(['click', 'hover', 'focus', 'type', 'key', 'wait', 'sleep', 'extension']);
58
+
59
+ /** Validate a list of steps; throws with the offending index. */
60
+ export function normalizeSteps(steps) {
61
+ if (!Array.isArray(steps) || steps.length > 100) throw fail('Los pasos deben ser una lista de como máximo 100.', 'VISUAL_STEP_INVALID');
62
+ return steps.map((step, index) => {
63
+ const bad = message => fail(`Paso ${index} inválido: ${message}`, 'VISUAL_STEP_INVALID');
64
+ if (!step || typeof step !== 'object' || !STEP_ACTIONS.includes(step.action)) throw bad(`action debe ser ${STEP_ACTIONS.join(', ')}`);
65
+ const allowed = { click: ['selector'], hover: ['selector'], focus: ['selector'], type: ['text', 'selector'], key: ['key'], wait: ['selector', 'state', 'timeout_ms'], sleep: ['ms'], extension: ['expression'] }[step.action];
66
+ for (const key of Object.keys(step)) if (key !== 'action' && !allowed.includes(key)) throw bad(`campo desconocido ${key}`);
67
+ const selector = value => typeof value === 'string' && value.trim() && value.length <= 1000;
68
+ if (['click', 'hover', 'focus', 'wait'].includes(step.action) && !selector(step.selector)) throw bad('selector requerido');
69
+ if (step.action === 'type' && (typeof step.text !== 'string' || step.text.length > 500 || step.selector !== undefined && !selector(step.selector))) throw bad('text de hasta 500 caracteres');
70
+ if (step.action === 'key' && !(step.key in KEYS)) throw bad(`key en ${Object.keys(KEYS).join(', ')}`);
71
+ if (step.action === 'wait' && (!['present', 'visible', 'absent'].includes(step.state ?? 'visible') || step.timeout_ms !== undefined && !(Number.isSafeInteger(step.timeout_ms) && step.timeout_ms > 0 && step.timeout_ms <= 15000))) throw bad('state present|visible|absent y timeout_ms <= 15000');
72
+ if (step.action === 'sleep' && !(Number.isSafeInteger(step.ms) && step.ms >= 0 && step.ms <= 5000)) throw bad('ms entre 0 y 5000');
73
+ if (step.action === 'extension' && (typeof step.expression !== 'string' || !step.expression.trim() || step.expression.length > 20000)) throw bad('expression requerida');
74
+ return Object.freeze({ ...step });
75
+ });
76
+ }
77
+
78
+ const FACTS_FN = `function(props){const r=this.getBoundingClientRect(),cs=getComputedStyle(this),styles={};for(const p of props)styles[p]=cs.getPropertyValue(p);
79
+ const backgrounds=[];let n=this,guard=0;while(n&&guard++<200){if(n.nodeType===1)backgrounds.push(getComputedStyle(n).backgroundColor);n=n.parentNode||n.host||null;}
80
+ return{box:{x:r.left,y:r.top,width:r.width,height:r.height},styles,backgrounds};}`;
81
+ const VISIBLE_FN = `function(){const r=this.getBoundingClientRect(),cs=getComputedStyle(this);return r.width>0&&r.height>0&&cs.visibility!=='hidden'&&cs.display!=='none'&&Number(cs.opacity)>0;}`;
82
+ const FRAME_OFFSET_FN = `function(){const r=this.getBoundingClientRect(),cs=getComputedStyle(this);return{x:r.left+this.clientLeft+parseFloat(cs.paddingLeft||'0'),y:r.top+this.clientTop+parseFloat(cs.paddingTop||'0')};}`;
83
+ const PAINT_FN = `function(){const cs=getComputedStyle(this);if(cs.display==='none'||cs.visibility==='hidden'||Number(cs.opacity)===0)return null;
84
+ const r=this.getBoundingClientRect();if(!(r.width>0&&r.height>0))return null;const tag=this.localName,reasons=[];
85
+ const clear=c=>c==='transparent'||/rgba\\([^)]*,\\s*0\\)$/.test(c);
86
+ if(['img','canvas','video','svg','iframe','object','embed','picture','frame'].includes(tag))reasons.push(tag);
87
+ if(!clear(cs.backgroundColor))reasons.push('background');if(cs.backgroundImage!=='none')reasons.push('background-image');
88
+ if(['Top','Right','Bottom','Left'].some(s=>parseFloat(cs['border'+s+'Width'])>0&&cs['border'+s+'Style']!=='none'&&!clear(cs['border'+s+'Color'])))reasons.push('border');
89
+ if(cs.boxShadow!=='none')reasons.push('box-shadow');if(cs.outlineStyle!=='none'&&parseFloat(cs.outlineWidth)>0)reasons.push('outline');
90
+ if([...this.childNodes].some(n=>n.nodeType===3&&n.data.trim()))reasons.push('text');
91
+ for(const p of ['::before','::after']){const ps=getComputedStyle(this,p);if(ps.content&&ps.content!=='none'&&ps.content!=='normal')reasons.push(p);}
92
+ return reasons.length?{x:r.left,y:r.top,width:r.width,height:r.height,tag,reasons}:null;}`;
93
+ const round = value => Math.round(value * 100) / 100;
94
+
95
+ export class Page {
96
+ constructor(session, targetId, sessionId) { this.session = session; this.cdp = session.cdp; this.targetId = targetId; this.sessionId = sessionId; this.frames = new Map(); }
97
+ async init() {
98
+ const { viewport } = this.session;
99
+ this.off = this.cdp.on(message => {
100
+ if (message.method === 'Target.attachedToTarget' && (message.sessionId === this.sessionId || [...this.frames.values()].includes(message.sessionId))) {
101
+ const { sessionId, targetInfo } = message.params;
102
+ if (targetInfo.type === 'iframe') { this.frames.set(targetInfo.targetId, sessionId); this.cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, sessionId).catch(() => {}); }
103
+ }
104
+ if (message.method === 'Target.detachedFromTarget') for (const [frame, sid] of this.frames) if (sid === message.params.sessionId) this.frames.delete(frame);
105
+ });
106
+ await this.cdp.send('Page.enable', {}, this.sessionId);
107
+ await this.cdp.send('Emulation.setDeviceMetricsOverride', { width: viewport.width, height: viewport.height, deviceScaleFactor: viewport.deviceScaleFactor ?? 1, mobile: false }, this.sessionId);
108
+ await this.cdp.send('Target.setAutoAttach', { autoAttach: true, waitForDebuggerOnStart: false, flatten: true }, this.sessionId);
109
+ return this;
110
+ }
111
+ async navigate(url, { timeoutMs = 30000 } = {}) {
112
+ const loaded = this.cdp.waitFor(m => m.method === 'Page.loadEventFired' && m.sessionId === this.sessionId, timeoutMs, `Carga de ${url}`);
113
+ const result = await this.cdp.send('Page.navigate', { url }, this.sessionId, timeoutMs);
114
+ if (result.errorText) { loaded.catch(() => {}); throw fail(`No se pudo abrir ${url}: ${result.errorText}`, 'VISUAL_NAVIGATION_FAILED'); }
115
+ await loaded;
116
+ await this.evaluate('document.fonts ? document.fonts.ready.then(() => true) : true');
117
+ }
118
+ async evaluate(expression, sessionId = this.sessionId) {
119
+ const result = await this.cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId);
120
+ if (result.exceptionDetails) throw fail(`La expresión falló: ${result.exceptionDetails.exception?.description || result.exceptionDetails.text}`, 'VISUAL_EVALUATE_FAILED');
121
+ return result.result.value;
122
+ }
123
+ async frameSession(frameId, timeoutMs = 5000) {
124
+ const deadline = Date.now() + timeoutMs;
125
+ while (Date.now() < deadline) { if (this.frames.has(frameId)) return this.frames.get(frameId); await sleep(50); }
126
+ return null;
127
+ }
128
+ async call(handle, functionDeclaration, args = []) {
129
+ // backendNodeId is stable for the node's lifetime; frontend nodeIds are
130
+ // invalidated by every DOM.getDocument and are only used transiently.
131
+ const { object } = await this.cdp.send('DOM.resolveNode', { backendNodeId: handle.backendNodeId }, handle.session);
132
+ try {
133
+ const result = await this.cdp.send('Runtime.callFunctionOn', { objectId: object.objectId, functionDeclaration, arguments: args.map(value => ({ value })), returnByValue: true, awaitPromise: true }, handle.session);
134
+ if (result.exceptionDetails) throw fail(`Falló en el elemento: ${result.exceptionDetails.exception?.description || result.exceptionDetails.text}`, 'VISUAL_EVALUATE_FAILED');
135
+ return result.result.value;
136
+ } finally { this.cdp.send('Runtime.releaseObject', { objectId: object.objectId }, handle.session).catch(() => {}); }
137
+ }
138
+ /** Resolve a piercing selector to a handle {session, backendNodeId, offset}, or null. */
139
+ async handle(selector, from = null) {
140
+ try { return await this.#handle(selector, from); }
141
+ catch (error) {
142
+ // A document replaced mid-resolution (an iframe finishing its navigation)
143
+ // invalidates node ids: the element is not there yet, which is not an error.
144
+ if (error.code === 'VISUAL_CDP_ERROR' && /Could not find node|No node with given id|Cannot find context|No frame|Node is detached/i.test(error.message)) return null;
145
+ throw error;
146
+ }
147
+ }
148
+ async #handle(selector, from) {
149
+ const segments = String(selector).split('>>>').map(s => s.trim());
150
+ if (!segments.length || segments.some(s => !s)) throw fail(`Selector inválido: ${selector}`, 'VISUAL_SELECTOR_INVALID');
151
+ let session = from?.session ?? this.sessionId, offset = { ...(from?.offset ?? { x: 0, y: 0 }) };
152
+ const root = (await this.cdp.send('DOM.getDocument', { depth: 0 }, session)).root.nodeId;
153
+ const push = async (backendNodeId, sid) => (await this.cdp.send('DOM.pushNodesByBackendIdsToFrontend', { backendNodeIds: [backendNodeId] }, sid)).nodeIds[0];
154
+ let node = from ? await push(from.backendNodeId, session) : root;
155
+ if (!node) return null;
156
+ for (let i = 0; i < segments.length; i++) {
157
+ let found;
158
+ try { found = (await this.cdp.send('DOM.querySelector', { nodeId: node, selector: segments[i] }, session)).nodeId; }
159
+ // CDP reports a querySelector SyntaxError as a generic "DOM Error while querying".
160
+ catch (error) { if (/DOM Error while querying|not valid selector|SyntaxError/i.test(error.message)) throw fail(`Selector CSS inválido: ${segments[i]}`, 'VISUAL_SELECTOR_INVALID'); return null; }
161
+ if (!found) return null;
162
+ const described = (await this.cdp.send('DOM.describeNode', { nodeId: found, depth: 1, pierce: true }, session)).node;
163
+ if (i === segments.length - 1) return { session, backendNodeId: described.backendNodeId, offset };
164
+ if (described.shadowRoots?.length) { node = await push(described.shadowRoots[0].backendNodeId, session); continue; }
165
+ if (described.nodeName === 'IFRAME' || described.nodeName === 'FRAME') {
166
+ const frameOffset = await this.call({ session, backendNodeId: described.backendNodeId }, FRAME_OFFSET_FN);
167
+ offset = { x: offset.x + frameOffset.x, y: offset.y + frameOffset.y };
168
+ if (described.contentDocument) { node = await push(described.contentDocument.backendNodeId, session); continue; }
169
+ const child = described.frameId && await this.frameSession(described.frameId);
170
+ if (!child) return null;
171
+ session = child; node = (await this.cdp.send('DOM.getDocument', { depth: 0 }, child)).root.nodeId; continue;
172
+ }
173
+ return null;
174
+ }
175
+ return null;
176
+ }
177
+ async resolve(selector) { const handle = await this.handle(selector); return handle ? handle.backendNodeId : null; }
178
+ async visible(selector) { const handle = await this.handle(selector); return handle ? Boolean(await this.call(handle, VISIBLE_FN)) : false; }
179
+ async box(handle) {
180
+ const facts = await this.call(handle, FACTS_FN, [[]]);
181
+ return { x: round(facts.box.x + handle.offset.x), y: round(facts.box.y + handle.offset.y), width: round(facts.box.width), height: round(facts.box.height) };
182
+ }
183
+ async facts({ root, elements = [], anchors = [], properties = [] }) {
184
+ const rootHandle = await this.handle(root);
185
+ const result = { elements: {}, anchors: {} };
186
+ const collect = async handle => {
187
+ const facts = await this.call(handle, FACTS_FN, [properties]);
188
+ return { box: { x: round(facts.box.x + handle.offset.x), y: round(facts.box.y + handle.offset.y), width: round(facts.box.width), height: round(facts.box.height) }, styles: facts.styles, backgrounds: facts.backgrounds };
189
+ };
190
+ if (rootHandle) {
191
+ result.elements.root = await collect(rootHandle);
192
+ for (const selector of elements) { const handle = await this.handle(selector, rootHandle); if (handle) result.elements[selector] = await collect(handle); }
193
+ }
194
+ for (const selector of anchors) { const handle = await this.handle(selector); if (handle) result.anchors[selector] = { box: (await collect(handle)).box }; }
195
+ return result;
196
+ }
197
+ /**
198
+ * Every rendered element of the page (pierced shadow roots, same-process
199
+ * frames) with a structural signature: ancestor tags, ids and classes, no
200
+ * sibling indexes, so inserting extension nodes does not rename page nodes.
201
+ */
202
+ async elements() {
203
+ const { root } = await this.cdp.send('DOM.getDocument', { depth: -1, pierce: true }, this.sessionId);
204
+ const out = [], SKIP = new Set(['head', 'script', 'style', 'template', 'noscript', 'link', 'meta', 'title', 'base']);
205
+ const visit = (node, path, frames) => {
206
+ if (node.nodeType === 1) {
207
+ if (SKIP.has(node.localName)) return;
208
+ const attrs = {}; for (let i = 0; i + 1 < (node.attributes || []).length; i += 2) attrs[node.attributes[i]] = node.attributes[i + 1];
209
+ path = `${path}/${node.localName}${attrs.id ? '#' + attrs.id : ''}${attrs.class?.trim() ? '.' + attrs.class.trim().split(/\s+/).sort().join('.') : ''}`;
210
+ out.push({ signature: path, backendNodeId: node.backendNodeId, frames });
211
+ if (out.length > 20000) throw fail('La página tiene más de 20000 elementos.', 'VISUAL_PAGE_TOO_LARGE');
212
+ }
213
+ for (const child of node.children || []) visit(child, path, frames);
214
+ for (const shadow of node.shadowRoots || []) visit(shadow, path + '/#shadow', frames);
215
+ if (node.contentDocument) visit(node.contentDocument, path + '/#document', [...frames, node.backendNodeId]);
216
+ };
217
+ visit(root, '', []);
218
+ return out;
219
+ }
220
+ async #elementHandle(entry) {
221
+ let offset = { x: 0, y: 0 };
222
+ for (const frame of entry.frames) { const o = await this.call({ session: this.sessionId, backendNodeId: frame }, FRAME_OFFSET_FN); offset = { x: offset.x + o.x, y: offset.y + o.y }; }
223
+ return { session: this.sessionId, backendNodeId: entry.backendNodeId, offset };
224
+ }
225
+ /** Border box of an element() entry in page coordinates, or null when it has none. */
226
+ async rectOf(entry) {
227
+ try { const box = await this.box(await this.#elementHandle(entry)); return box.width > 0 && box.height > 0 ? box : null; }
228
+ catch (error) { if (error.code === 'VISUAL_CDP_ERROR') return null; throw error; }
229
+ }
230
+ /** Box of an element() entry when it visibly paints something (background, image, media, border, text, shadow or pseudo content). */
231
+ async paintOf(entry) {
232
+ try {
233
+ const handle = await this.#elementHandle(entry), painted = await this.call(handle, PAINT_FN);
234
+ return painted && { ...painted, x: round(painted.x + handle.offset.x), y: round(painted.y + handle.offset.y), width: round(painted.width), height: round(painted.height) };
235
+ } catch (error) { if (error.code === 'VISUAL_CDP_ERROR') return null; throw error; }
236
+ }
237
+ async screenshot({ clip } = {}) {
238
+ let params = { format: 'png', captureBeyondViewport: false };
239
+ if (clip) {
240
+ const metrics = await this.cdp.send('Page.getLayoutMetrics', {}, this.sessionId), view = metrics.cssLayoutViewport || { pageX: 0, pageY: 0 };
241
+ const x = Math.floor(clip.x), y = Math.floor(clip.y), width = Math.ceil(clip.x + clip.width) - x, height = Math.ceil(clip.y + clip.height) - y;
242
+ if (width < 1 || height < 1) throw fail('El recorte de la captura está vacío.', 'VISUAL_CLIP_EMPTY');
243
+ params = { ...params, clip: { x: x + (view.pageX || 0), y: y + (view.pageY || 0), width, height, scale: 1 } };
244
+ }
245
+ const shot = await this.cdp.send('Page.captureScreenshot', params, this.sessionId);
246
+ return Buffer.from(shot.data, 'base64');
247
+ }
248
+ // Centre of the element in main-viewport coordinates. When it is outside the
249
+ // viewport, scroll it in (inside its frame, then the page) and resolve the
250
+ // handle again: frame offsets change with every scroll.
251
+ async pointer(selector, type) {
252
+ const view = this.session.viewport;
253
+ const inView = b => b.width > 0 && b.height > 0 && b.x + b.width / 2 >= 0 && b.y + b.height / 2 >= 0 && b.x + b.width / 2 <= view.width && b.y + b.height / 2 <= view.height;
254
+ let handle = await this.required(selector), box = await this.box(handle);
255
+ if (!inView(box) && box.width > 0 && box.height > 0) {
256
+ await this.call(handle, "function(){this.scrollIntoView({block:'center',inline:'center'});}");
257
+ handle = await this.required(selector); box = await this.box(handle);
258
+ if (!inView(box)) {
259
+ await this.evaluate(`window.scrollBy(${Math.round(box.x + box.width / 2 - view.width / 2)}, ${Math.round(box.y + box.height / 2 - view.height / 2)})`);
260
+ handle = await this.required(selector); box = await this.box(handle);
261
+ }
262
+ }
263
+ if (!(box.width > 0 && box.height > 0)) throw fail(`${selector} no tiene caja visible para el puntero.`, 'VISUAL_STEP_FAILED');
264
+ if (!inView(box)) throw fail(`${selector} sigue fuera del viewport después de desplazarlo; no se hizo clic.`, 'VISUAL_STEP_FAILED');
265
+ const x = box.x + box.width / 2, y = box.y + box.height / 2;
266
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y }, this.sessionId);
267
+ if (type === 'click') {
268
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 }, this.sessionId);
269
+ await this.cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 }, this.sessionId);
270
+ }
271
+ }
272
+ async required(selector) { const handle = await this.handle(selector); if (!handle) throw fail(`No se encontró ${selector}.`, 'VISUAL_STEP_FAILED'); return handle; }
273
+ async runSteps(input) {
274
+ for (const step of normalizeSteps(input)) {
275
+ if (step.action === 'click' || step.action === 'hover') await this.pointer(step.selector, step.action);
276
+ else if (step.action === 'focus') await this.call(await this.required(step.selector), 'function(){this.focus();}');
277
+ else if (step.action === 'type') {
278
+ if (step.selector) await this.call(await this.required(step.selector), 'function(){this.focus();}');
279
+ for (const ch of step.text) {
280
+ await this.cdp.send('Input.dispatchKeyEvent', { type: 'keyDown', text: ch, unmodifiedText: ch, key: ch }, this.sessionId);
281
+ await this.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: ch }, this.sessionId);
282
+ }
283
+ } else if (step.action === 'key') {
284
+ const key = KEYS[step.key];
285
+ await this.cdp.send('Input.dispatchKeyEvent', { type: key.text ? 'keyDown' : 'rawKeyDown', ...key }, this.sessionId);
286
+ await this.cdp.send('Input.dispatchKeyEvent', { type: 'keyUp', key: key.key, code: key.code, windowsVirtualKeyCode: key.windowsVirtualKeyCode }, this.sessionId);
287
+ } else if (step.action === 'wait') {
288
+ const state = step.state ?? 'visible', timeout = step.timeout_ms ?? 5000, deadline = Date.now() + timeout;
289
+ for (;;) {
290
+ const ok = state === 'present' ? (await this.resolve(step.selector)) !== null : state === 'absent' ? (await this.resolve(step.selector)) === null : await this.visible(step.selector);
291
+ if (ok) break;
292
+ if (Date.now() >= deadline) throw fail(`Espera agotada: ${step.selector} no llegó a "${state}" en ${timeout} ms.`, 'VISUAL_WAIT_TIMEOUT');
293
+ await sleep(100);
294
+ }
295
+ } else if (step.action === 'sleep') await sleep(step.ms);
296
+ else if (step.action === 'extension') {
297
+ if (!this.session.extensionId) throw fail('El paso extension requiere una extensión cargada.', 'VISUAL_STEP_FAILED');
298
+ await this.session.evaluateInExtension(this.session.extensionId, step.expression);
299
+ }
300
+ }
301
+ }
302
+ async close() { this.off?.(); await this.cdp.send('Target.closeTarget', { targetId: this.targetId }).catch(() => {}); }
303
+ }
304
+
305
+ export class ChromeSession {
306
+ constructor({ child, cdp, profile, version, viewport, headless }) { Object.assign(this, { child, cdp, profile, version, viewport, headless, extensionId: null }); }
307
+ async loadExtension(dir) {
308
+ const { id } = await this.cdp.send('Extensions.loadUnpacked', { path: path.resolve(dir) });
309
+ this.extensionId = id; return id;
310
+ }
311
+ async evaluateInExtension(extensionId, expression, { timeoutMs = 10000 } = {}) {
312
+ const prefix = `chrome-extension://${extensionId}/`, deadline = Date.now() + 10000;
313
+ const find = async () => (await this.cdp.send('Target.getTargets')).targetInfos.find(info => info.type === 'service_worker' && info.url.startsWith(prefix));
314
+ // MV3 workers start on demand; wake an idle one through a helper page.
315
+ let target = await find(), helper = null, woke = 0;
316
+ try {
317
+ while (!target && Date.now() < deadline) {
318
+ if (Date.now() - woke > 1000) {
319
+ helper ??= await this.openPage();
320
+ await this.cdp.send('ServiceWorker.enable', {}, helper.sessionId).catch(() => {});
321
+ await this.cdp.send('ServiceWorker.startWorker', { scopeURL: prefix }, helper.sessionId).catch(() => {});
322
+ woke = Date.now();
323
+ }
324
+ await sleep(100); target = await find();
325
+ }
326
+ } finally { await helper?.close(); }
327
+ if (!target) throw fail(`La extensión ${extensionId} no tiene un service worker activo; el paso extension lo requiere.`, 'VISUAL_EXTENSION_WORKER_MISSING');
328
+ const { sessionId } = await this.cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: true });
329
+ try {
330
+ const result = await this.cdp.send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true }, sessionId, timeoutMs);
331
+ if (result.exceptionDetails) throw fail(`La expresión de la extensión falló: ${result.exceptionDetails.exception?.description || result.exceptionDetails.text}`, 'VISUAL_EVALUATE_FAILED');
332
+ return result.result.value;
333
+ } finally { await this.cdp.send('Target.detachFromTarget', { sessionId }).catch(() => {}); }
334
+ }
335
+ async openPage() {
336
+ const { targetId } = await this.cdp.send('Target.createTarget', { url: 'about:blank' });
337
+ const { sessionId } = await this.cdp.send('Target.attachToTarget', { targetId, flatten: true });
338
+ return new Page(this, targetId, sessionId).init();
339
+ }
340
+ async close() {
341
+ if (this.closing) return this.closing;
342
+ this.releaseSignal?.();
343
+ this.closing = (async () => {
344
+ const exited = this.child.exitCode !== null || this.child.signalCode !== null ? Promise.resolve() : new Promise(resolve => this.child.once('exit', resolve));
345
+ await this.cdp.send('Browser.close', {}, undefined, 3000).catch(() => {});
346
+ const timer = setTimeout(() => this.child.kill('SIGKILL'), 3000);
347
+ await exited; clearTimeout(timer);
348
+ await fs.rm(this.profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }).catch(() => {});
349
+ })();
350
+ return this.closing;
351
+ }
352
+ }
353
+
354
+ export async function launchChrome({ binary = defaultChromeBinary(), viewport = { width: 1280, height: 800, deviceScaleFactor: 1 }, hostRules = [], ignoreCertificateErrors = false, headless = true, signal } = {}) {
355
+ if (!existsSync(binary)) throw fail(`No existe el binario de Chrome ${binary}. Define COMAL_VISUAL_CHROME.`, 'VISUAL_CHROME_MISSING');
356
+ signal?.throwIfAborted();
357
+ const profile = await fs.mkdtemp(path.join(os.tmpdir(), 'comal-visual-'));
358
+ const args = [...(headless ? ['--headless=new'] : []), '--remote-debugging-pipe', '--enable-unsafe-extension-debugging', `--user-data-dir=${profile}`,
359
+ '--no-first-run', '--no-default-browser-check', '--disable-component-update', '--disable-sync', '--disable-background-networking', '--password-store=basic',
360
+ '--hide-scrollbars', '--force-color-profile=srgb', `--window-size=${viewport.width},${viewport.height}`,
361
+ ...(hostRules.length ? [`--host-resolver-rules=${hostRules.join(',')}`] : []), ...(ignoreCertificateErrors ? ['--ignore-certificate-errors'] : []), 'about:blank'];
362
+ const child = spawn(binary, args, { stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'] });
363
+ let stderr = '';
364
+ child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-4000); });
365
+ const cdp = new CdpPipe(child);
366
+ child.once('exit', (code, signalName) => cdp.close(fail(`Chrome terminó (${code ?? signalName}). ${stderr.trim().split('\n').slice(-3).join(' ')}`, 'VISUAL_CHROME_EXITED')));
367
+ child.once('error', error => cdp.close(fail(`Chrome no arrancó: ${error.message}`, 'VISUAL_CHROME_EXITED')));
368
+ try {
369
+ const { product } = await cdp.send('Browser.getVersion', {}, undefined, 20000);
370
+ const session = new ChromeSession({ child, cdp, profile, version: product, viewport: { deviceScaleFactor: 1, ...viewport }, headless });
371
+ const onAbort = () => session.close();
372
+ signal?.addEventListener('abort', onAbort, { once: true });
373
+ session.releaseSignal = () => signal?.removeEventListener('abort', onAbort);
374
+ return session;
375
+ } catch (error) {
376
+ child.kill('SIGKILL'); await fs.rm(profile, { recursive: true, force: true }).catch(() => {});
377
+ throw error;
378
+ }
379
+ }
@@ -0,0 +1,30 @@
1
+ // Environment fingerprint for exact-mode references: two captures only compare
2
+ // pixel by pixel when Chrome, platform, viewport, scale, headless mode and the
3
+ // installed fonts are the same.
4
+ import { createHash } from 'node:crypto';
5
+ import { spawnSync } from 'node:child_process';
6
+ import os from 'node:os';
7
+
8
+ let fontsCache;
9
+ function fontsDigest() {
10
+ if (fontsCache !== undefined) return fontsCache;
11
+ const listed = spawnSync('fc-list', ['--format', '%{family}|%{style}|%{file}\n'], { encoding: 'utf8', timeout: 10000 });
12
+ fontsCache = listed.status === 0 ? createHash('sha256').update(listed.stdout.split('\n').filter(Boolean).sort().join('\n')).digest('hex') : null;
13
+ return fontsCache;
14
+ }
15
+
16
+ export function environmentFingerprint({ version, viewport, headless = true }) {
17
+ return Object.freeze({ chrome: version, platform: process.platform, release: os.release(), arch: process.arch,
18
+ viewport: { width: viewport.width, height: viewport.height }, device_scale_factor: viewport.deviceScaleFactor ?? 1,
19
+ headless: Boolean(headless), fonts_sha256: fontsDigest() });
20
+ }
21
+
22
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object'
23
+ ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
24
+ export const fingerprintDigest = fingerprint => createHash('sha256').update(JSON.stringify(canonical(fingerprint))).digest('hex');
25
+
26
+ /** Field names that differ, so a report can say why a pair is not comparable. */
27
+ export function fingerprintDifferences(expected, actual) {
28
+ const keys = new Set([...Object.keys(expected || {}), ...Object.keys(actual || {})]);
29
+ return [...keys].filter(key => JSON.stringify(canonical(expected?.[key])) !== JSON.stringify(canonical(actual?.[key]))).sort();
30
+ }
@@ -0,0 +1,201 @@
1
+ // Fixture pages for visual acceptance. A local fixture is a directory with
2
+ // fixture.json and static files; a frozen fixture is a recorded copy of a live
3
+ // page. Both are served over HTTPS at their own origin: Chrome resolves the
4
+ // fixture hosts to this server through --host-resolver-rules, so a frozen copy
5
+ // of https://site.example/login keeps its origin and URLs need no rewriting.
6
+ import { createHash } from 'node:crypto';
7
+ import { execFile } from 'node:child_process';
8
+ import fs from 'node:fs/promises';
9
+ import https from 'node:https';
10
+ import path from 'node:path';
11
+ import { promisify } from 'node:util';
12
+
13
+ const run = promisify(execFile);
14
+ const ID = /^[a-z0-9][a-z0-9-]{0,62}$/;
15
+ const fail = (message, code = 'VISUAL_FIXTURE_INVALID') => Object.assign(new Error(message), { code });
16
+ const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
17
+ const TYPES = { '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8', '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
18
+ '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml', '.webp': 'image/webp',
19
+ '.ico': 'image/x-icon', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf', '.otf': 'font/otf', '.txt': 'text/plain; charset=utf-8' };
20
+
21
+ export async function readFixture(dir) {
22
+ let raw;
23
+ try { raw = JSON.parse(await fs.readFile(path.join(dir, 'fixture.json'), 'utf8')); }
24
+ catch (error) { throw fail(`Fixture sin fixture.json legible en ${dir}: ${error.message}`); }
25
+ if (!raw || typeof raw !== 'object' || !ID.test(raw.id || '')) throw fail(`Fixture ${dir}: id en minúsculas, dígitos o guiones.`);
26
+ const origin = raw.origin ?? `https://${raw.id}.fixture.test`;
27
+ let parsed;
28
+ try { parsed = new URL(origin); } catch { throw fail(`Fixture ${raw.id}: origin inválido.`); }
29
+ if (parsed.protocol !== 'https:' || parsed.origin !== origin || parsed.port) throw fail(`Fixture ${raw.id}: origin debe ser https://host sin puerto ni ruta.`);
30
+ const entry = raw.entry ?? '/';
31
+ if (typeof entry !== 'string' || !entry.startsWith('/')) throw fail(`Fixture ${raw.id}: entry debe empezar con /.`);
32
+ return { id: raw.id, origin, entry, frozen: raw.frozen ?? null, dir: await fs.realpath(dir) };
33
+ }
34
+
35
+ /** Self-signed certificate kept in stateDir/visual/tls; Chrome runs with --ignore-certificate-errors. */
36
+ export async function fixtureCertificate(stateDir) {
37
+ const base = path.join(stateDir, 'visual'), dir = path.join(base, 'tls');
38
+ const read = async () => ({ key: await fs.readFile(path.join(dir, 'key.pem')), cert: await fs.readFile(path.join(dir, 'cert.pem')) });
39
+ try { return await read(); } catch {}
40
+ // Generate in a private directory and publish it with one atomic rename, so
41
+ // concurrent first runs never leave a mismatched key and certificate.
42
+ await fs.mkdir(base, { recursive: true, mode: 0o700 });
43
+ const temp = await fs.mkdtemp(path.join(base, 'tls-'));
44
+ try {
45
+ await run('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-keyout', path.join(temp, 'key.pem'), '-out', path.join(temp, 'cert.pem'), '-days', '3650', '-subj', '/CN=comal-visual-fixtures'], { timeout: 30000 });
46
+ await fs.rename(temp, dir).catch(async error => {
47
+ if (!['ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error;
48
+ // Another run published first, or an old partial directory is in the way.
49
+ if (await read().then(() => true, () => false)) return;
50
+ await fs.rm(dir, { recursive: true, force: true });
51
+ await fs.rename(temp, dir);
52
+ });
53
+ } finally { await fs.rm(temp, { recursive: true, force: true }); }
54
+ return read();
55
+ }
56
+
57
+ async function frozenTable(fixture) {
58
+ const responses = JSON.parse(await fs.readFile(path.join(fixture.dir, 'responses.json'), 'utf8'));
59
+ if (!Array.isArray(responses)) throw fail(`Fixture ${fixture.id}: responses.json debe ser una lista.`);
60
+ const byHost = new Map();
61
+ for (const entry of responses) {
62
+ // Bodies are content-addressed: only a sha256 file name inside bodies/ is servable.
63
+ if (!entry || !/^[a-f0-9]{64}$/.test(entry.file || '') || typeof entry.url !== 'string') throw fail(`Fixture ${fixture.id}: entrada de responses.json inválida (file debe ser un sha256).`);
64
+ if (entry.status !== undefined && !(Number.isSafeInteger(entry.status) && entry.status >= 100 && entry.status <= 599)) throw fail(`Fixture ${fixture.id}: status inválido.`);
65
+ if (entry.content_type !== undefined && (typeof entry.content_type !== 'string' || entry.content_type.length > 200 || /[\r\n]/.test(entry.content_type))) throw fail(`Fixture ${fixture.id}: content_type inválido.`);
66
+ let url; try { url = new URL(entry.url); } catch { throw fail(`Fixture ${fixture.id}: url inválida en responses.json.`); }
67
+ if (url.protocol !== 'https:') throw fail(`Fixture ${fixture.id}: sólo respuestas https.`);
68
+ const host = url.host;
69
+ if (!byHost.has(host)) byHost.set(host, new Map());
70
+ byHost.get(host).set(url.pathname + url.search, entry);
71
+ }
72
+ return byHost;
73
+ }
74
+
75
+ /** overlays: [{origin, path, file}] serves one extra file at a fixture origin (maquettes). */
76
+ export async function startFixtureServer({ fixtures, stateDir, overlays = [] }) {
77
+ const loaded = [];
78
+ for (const dir of fixtures) loaded.push(await readFixture(dir));
79
+ const ids = new Set();
80
+ for (const fixture of loaded) { if (ids.has(fixture.id)) throw fail(`Fixture duplicado ${fixture.id}.`); ids.add(fixture.id); }
81
+ const routes = new Map();
82
+ for (const fixture of loaded) {
83
+ if (fixture.frozen) for (const [host, table] of await frozenTable(fixture)) routes.set(host, { kind: 'frozen', fixture, table });
84
+ else routes.set(new URL(fixture.origin).host, { kind: 'local', fixture });
85
+ }
86
+ const misses = [];
87
+ const { key, cert } = await fixtureCertificate(stateDir);
88
+ const server = https.createServer({ key, cert }, async (req, res) => {
89
+ const host = String(req.headers.host || '').replace(/:\d+$/, ''), route = routes.get(host);
90
+ const notFound = () => { misses.push(`${host}${req.url}`); res.statusCode = 404; res.end(); };
91
+ try {
92
+ if (!route || !['GET', 'HEAD'].includes(req.method)) return notFound();
93
+ const url = new URL(req.url, `https://${host}`);
94
+ const overlay = overlays.find(o => new URL(o.origin).host === host && o.path === url.pathname);
95
+ if (overlay) { res.setHeader('content-type', TYPES[path.extname(overlay.file).toLowerCase()] || 'text/html; charset=utf-8'); return res.end(req.method === 'HEAD' ? undefined : await fs.readFile(overlay.file)); }
96
+ if (route.kind === 'frozen') {
97
+ const entry = route.table.get(url.pathname + url.search) || route.table.get(url.pathname);
98
+ if (!entry) return notFound();
99
+ const bodies = path.join(route.fixture.dir, 'bodies'), file = await fs.realpath(path.join(bodies, entry.file)).catch(() => null);
100
+ if (!file || path.dirname(file) !== await fs.realpath(bodies)) return notFound();
101
+ const body = await fs.readFile(file);
102
+ res.statusCode = entry.status || 200; res.setHeader('content-type', entry.content_type || 'application/octet-stream');
103
+ return res.end(req.method === 'HEAD' ? undefined : body);
104
+ }
105
+ let relative = decodeURIComponent(url.pathname);
106
+ if (relative.endsWith('/')) relative += 'index.html';
107
+ const candidate = path.resolve(route.fixture.dir, '.' + relative);
108
+ // Resolve symlinks: a served file must stay inside the fixture directory.
109
+ const file = await fs.realpath(candidate).catch(() => null);
110
+ if (!file || !file.startsWith(route.fixture.dir + path.sep) || path.basename(file) === 'fixture.json') return notFound();
111
+ const body = await fs.readFile(file).catch(() => null);
112
+ if (!body) return notFound();
113
+ res.setHeader('content-type', TYPES[path.extname(file).toLowerCase()] || 'application/octet-stream');
114
+ res.end(req.method === 'HEAD' ? undefined : body);
115
+ } catch { if (!res.headersSent) notFound(); else res.destroy(); }
116
+ });
117
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); });
118
+ const port = server.address().port;
119
+ return {
120
+ port, misses, fixtures: loaded,
121
+ hostRules: ({ offline = false } = {}) => [...[...routes.keys()].map(host => `MAP ${host} 127.0.0.1:${port}`), ...(offline ? ['MAP * ~NOTFOUND'] : [])],
122
+ urlFor(id, entry) { const fixture = loaded.find(f => f.id === id); if (!fixture) throw fail(`Fixture desconocido ${id}.`); return fixture.origin + (entry ?? fixture.entry); },
123
+ close: () => new Promise(resolve => { server.closeAllConnections?.(); server.close(() => resolve()); }),
124
+ };
125
+ }
126
+
127
+ // Runs inside each frame: serialises the live DOM without scripts or inline
128
+ // handlers, open shadow roots as declarative templates and CSSOM-inserted rules.
129
+ const SERIALIZE = `(() => {
130
+ const VOID = new Set(['area','base','br','col','embed','hr','img','input','link','meta','source','track','wbr']);
131
+ const esc = s => s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
132
+ const attr = s => s.replace(/&/g,'&amp;').replace(/"/g,'&quot;');
133
+ const rules = sheet => { try { return [...sheet.cssRules].map(r => r.cssText).join('\\n'); } catch { return null; } };
134
+ const adopted = root => (root.adoptedStyleSheets || []).map(s => '<style data-comal-adopted>' + esc(rules(s) || '') + '</style>').join('');
135
+ const children = node => [...node.childNodes].map(ser).join('');
136
+ function ser(node) {
137
+ if (node.nodeType === 3) return node.parentNode && node.parentNode.localName === 'style' ? node.data : esc(node.data);
138
+ if (node.nodeType !== 1) return '';
139
+ const tag = node.localName;
140
+ if (tag === 'script' || tag === 'noscript') return '';
141
+ if (tag === 'meta' && /content-security-policy/i.test(node.getAttribute('http-equiv') || '')) return '';
142
+ if (tag === 'link' && /preload|modulepreload/i.test(node.rel || '') && /script/i.test(node.getAttribute('as') || node.rel)) return '';
143
+ let attrs = '';
144
+ for (const a of node.attributes) if (!/^on/i.test(a.name)) attrs += ' ' + a.name + '="' + attr(a.value) + '"';
145
+ if (tag === 'input' && node.value && node.type !== 'password' && !node.hasAttribute('value')) attrs += ' value="' + attr(node.value) + '"';
146
+ if (VOID.has(tag)) return '<' + tag + attrs + '>';
147
+ let inner = '';
148
+ if (node.shadowRoot && node.shadowRoot.mode === 'open') inner += '<template shadowrootmode="open">' + adopted(node.shadowRoot) + children(node.shadowRoot) + '</template>';
149
+ if (tag === 'style') { const text = node.sheet ? rules(node.sheet) : null; inner += text !== null && text !== node.textContent.trim() ? text : node.textContent; }
150
+ else inner += tag === 'template' ? children(node.content) : children(node);
151
+ return '<' + tag + attrs + '>' + inner + '</' + tag + '>';
152
+ }
153
+ const html = ser(document.documentElement);
154
+ return '<!doctype html>' + html.replace(/<\\/head>/, adopted(document) + '</head>');
155
+ })()`;
156
+
157
+ function frameTree(tree, out = []) { out.push(tree); for (const child of tree.childFrames || []) frameTree(child, out); return out; }
158
+
159
+ /** Record a live page into dir as a frozen fixture and return its fixture.json. */
160
+ export async function freezePage({ session, url, dir, id, settleMs = 1500, signal }) {
161
+ if (!ID.test(id || '')) throw fail('id de fixture inválido.');
162
+ const source = new URL(url);
163
+ if (source.protocol !== 'https:' || source.port) throw fail('Sólo se congelan páginas https sin puerto explícito.');
164
+ const page = await session.openPage();
165
+ try {
166
+ await page.navigate(url);
167
+ await new Promise(resolve => setTimeout(resolve, settleMs));
168
+ signal?.throwIfAborted();
169
+ await fs.mkdir(path.join(dir, 'bodies'), { recursive: true });
170
+ const responses = new Map();
171
+ const store = async (resourceUrl, body, contentType) => {
172
+ const clean = new URL(resourceUrl); clean.hash = '';
173
+ if (!/^https?:$/.test(clean.protocol)) return;
174
+ const file = sha256(body);
175
+ await fs.writeFile(path.join(dir, 'bodies', file), body);
176
+ responses.set(clean.href, { url: clean.href, status: 200, content_type: contentType, file });
177
+ };
178
+ const sessions = [page.sessionId, ...page.frames.values()];
179
+ for (const sid of sessions) {
180
+ const { frameTree: tree } = await page.cdp.send('Page.getResourceTree', {}, sid);
181
+ for (const node of frameTree(tree)) {
182
+ for (const resource of node.resources || []) {
183
+ if (['Script', 'Document'].includes(resource.type) || resource.failed || resource.canceled) continue;
184
+ const content = await page.cdp.send('Page.getResourceContent', { frameId: node.frame.id, url: resource.url }, sid).catch(() => null);
185
+ if (!content) continue;
186
+ await store(resource.url, Buffer.from(content.content, content.base64Encoded ? 'base64' : 'utf8'), resource.mimeType || 'application/octet-stream');
187
+ }
188
+ if (!/^https?:/.test(node.frame.url)) continue;
189
+ const { executionContextId } = await page.cdp.send('Page.createIsolatedWorld', { frameId: node.frame.id, worldName: 'comal-freeze' }, sid);
190
+ const result = await page.cdp.send('Runtime.evaluate', { expression: SERIALIZE, contextId: executionContextId, returnByValue: true }, sid);
191
+ if (result.exceptionDetails) throw fail(`No se pudo serializar ${node.frame.url}: ${result.exceptionDetails.text}`, 'VISUAL_FREEZE_FAILED');
192
+ await store(node.frame.url + (node.frame.urlFragment || ''), Buffer.from(result.result.value, 'utf8'), 'text/html; charset=utf-8');
193
+ }
194
+ }
195
+ const fixture = { id, origin: source.origin, entry: source.pathname + source.search,
196
+ frozen: { source_url: url, frozen_at: new Date().toISOString(), chrome: session.version, responses: responses.size } };
197
+ await fs.writeFile(path.join(dir, 'responses.json'), JSON.stringify([...responses.values()], null, 1) + '\n');
198
+ await fs.writeFile(path.join(dir, 'fixture.json'), JSON.stringify(fixture, null, 2) + '\n');
199
+ return { ...fixture, dir: path.resolve(dir) };
200
+ } finally { await page.close(); }
201
+ }