wu-framework 1.1.8 → 1.1.9

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.
@@ -5,14 +5,32 @@
5
5
  * commands using wu.* APIs. This is the "eyes and hands" of
6
6
  * the MCP server inside the browser.
7
7
  *
8
+ * Security:
9
+ * - Optional auth token sent on first message (handshake)
10
+ * - All state/event/mount operations check wu.ai permissions
11
+ * - Mutating operations emit audit events
12
+ * - Read-only operations (status, list_apps, snapshot, console, network) are unrestricted
13
+ *
8
14
  * @example
9
- * // Auto-connect (called by wu.mcp.connect())
10
- * import { createMcpBridge } from './wu-mcp-bridge.js';
15
+ * // Connect with auth token
16
+ * wu.mcp.connect('ws://localhost:19100', { token: 'my-secret' });
11
17
  *
12
- * const bridge = createMcpBridge(wuInstance);
13
- * bridge.connect('ws://localhost:3100');
18
+ * // Connect without auth (development only)
19
+ * wu.mcp.connect();
14
20
  */
15
21
 
22
+ import {
23
+ ensureInterceptors,
24
+ networkLog,
25
+ consoleLog,
26
+ captureScreenshot,
27
+ buildA11yTree,
28
+ clickElement,
29
+ typeIntoElement,
30
+ getFilteredNetwork,
31
+ getFilteredConsole,
32
+ } from '../ai/wu-ai-browser-primitives.js';
33
+
16
34
  /**
17
35
  * Create the MCP bridge for a Wu instance.
18
36
  *
@@ -23,6 +41,8 @@ export function createMcpBridge(wu) {
23
41
  let ws = null;
24
42
  let reconnectTimer = null;
25
43
  let reconnectAttempts = 0;
44
+ let authenticated = false;
45
+ let authToken = null;
26
46
  const MAX_RECONNECT_ATTEMPTS = 10;
27
47
  const RECONNECT_DELAY = 2000;
28
48
 
@@ -30,14 +50,6 @@ export function createMcpBridge(wu) {
30
50
  const eventLog = [];
31
51
  const MAX_EVENT_LOG = 200;
32
52
 
33
- // Console log capture
34
- const consoleLog = [];
35
- const MAX_CONSOLE_LOG = 500;
36
-
37
- // Network log capture
38
- const networkLog = [];
39
- const MAX_NETWORK_LOG = 300;
40
-
41
53
  // Capture events for history
42
54
  if (wu.eventBus) {
43
55
  wu.eventBus.on('*', (event) => {
@@ -51,22 +63,50 @@ export function createMcpBridge(wu) {
51
63
  });
52
64
  }
53
65
 
54
- // Capture console messages
55
- _interceptConsole();
66
+ // Install shared interceptors (idempotent — safe if wu-ai-browser already did it)
67
+ ensureInterceptors();
56
68
 
57
- // Capture network requests (fetch + XMLHttpRequest)
58
- _interceptNetwork();
69
+ // ── Permission helpers ──
70
+
71
+ /**
72
+ * Check a permission flag via wu.ai.permissions if available.
73
+ * Falls back to deny if wu.ai is not initialized.
74
+ */
75
+ function _checkPermission(perm) {
76
+ if (wu.ai && wu.ai.permissions) {
77
+ return wu.ai.permissions.check(perm);
78
+ }
79
+ // If AI module not initialized, deny write operations, allow reads
80
+ const readPerms = ['readStore', 'executeActions'];
81
+ return readPerms.includes(perm);
82
+ }
83
+
84
+ /**
85
+ * Emit an audit event for bridge operations.
86
+ */
87
+ function _audit(operation, params, result) {
88
+ if (wu.eventBus) {
89
+ wu.eventBus.emit('mcp:bridge:operation', {
90
+ operation,
91
+ params,
92
+ result: result?.error ? { error: result.error } : { success: true },
93
+ timestamp: Date.now(),
94
+ }, { appName: 'wu-mcp-bridge' });
95
+ }
96
+ }
59
97
 
60
98
  // ── Command handlers ──
61
99
 
62
100
  const handlers = {
101
+ // ── Read-only operations (no permission gates) ──
102
+
63
103
  status() {
64
104
  return {
65
105
  connected: true,
66
106
  framework: 'wu-framework',
67
107
  apps: _getAppList(),
68
108
  storeKeys: wu.store ? Object.keys(wu.store.get('') || {}) : [],
69
- actionsCount: wu.ai?._actions ? Object.keys(wu.ai._actions).length : 0,
109
+ actionsCount: wu.ai ? wu.ai.tools().length : 0,
70
110
  eventLogSize: eventLog.length,
71
111
  };
72
112
  },
@@ -75,45 +115,55 @@ export function createMcpBridge(wu) {
75
115
  return _getAppList();
76
116
  },
77
117
 
78
- navigate({ route }) {
79
- if (!route) return { error: 'Route is required' };
80
- if (wu.eventBus) {
81
- wu.eventBus.emit('shell:navigate', { route });
82
- }
83
- if (wu.store) {
84
- wu.store.set('currentPath', route);
85
- }
86
- return { navigated: route };
118
+ list_events({ limit = 20 }) {
119
+ return eventLog.slice(-limit);
87
120
  },
88
121
 
89
- mount_app({ appName, container }) {
90
- if (!appName) return { error: 'appName is required' };
91
- try {
92
- if (wu.mount) {
93
- wu.mount(appName, container);
94
- return { mounted: appName, container };
95
- }
96
- return { error: 'wu.mount not available' };
97
- } catch (err) {
98
- return { error: err.message };
99
- }
122
+ list_actions() {
123
+ if (!wu.ai) return { actions: [], note: 'wu.ai not initialized' };
124
+ const tools = wu.ai.tools();
125
+ return { actions: tools, count: tools.length };
100
126
  },
101
127
 
102
- unmount_app({ appName }) {
103
- if (!appName) return { error: 'appName is required' };
128
+ snapshot({ appName }) {
104
129
  try {
105
- if (wu.unmount) {
106
- wu.unmount(appName);
107
- return { unmounted: appName };
108
- }
109
- return { error: 'wu.unmount not available' };
130
+ const target = appName
131
+ ? document.querySelector(`[data-wu-app="${appName}"]`) || document.querySelector(`#wu-app-${appName}`)
132
+ : document.body;
133
+
134
+ if (!target) return { error: `App "${appName}" not found in DOM` };
135
+
136
+ return {
137
+ app: appName || '(page)',
138
+ snapshot: buildA11yTree(target, 0, 5),
139
+ timestamp: Date.now(),
140
+ };
110
141
  } catch (err) {
111
142
  return { error: err.message };
112
143
  }
113
144
  },
114
145
 
146
+ console({ level = 'all', limit = 50 }) {
147
+ return getFilteredConsole(level, limit);
148
+ },
149
+
150
+ async screenshot({ selector, quality = 0.8 }) {
151
+ const result = await captureScreenshot(selector, quality);
152
+ if (!result.error) result.timestamp = Date.now();
153
+ return result;
154
+ },
155
+
156
+ network({ method, status, limit = 50 }) {
157
+ return getFilteredNetwork(method, status, limit);
158
+ },
159
+
160
+ // ── Permission-gated operations ──
161
+
115
162
  get_state({ path }) {
116
163
  if (!wu.store) return { error: 'wu.store not available' };
164
+ if (!_checkPermission('readStore')) {
165
+ return { error: 'Permission denied: readStore is disabled' };
166
+ }
117
167
  const value = wu.store.get(path || '');
118
168
  return { path: path || '(root)', value };
119
169
  },
@@ -121,293 +171,162 @@ export function createMcpBridge(wu) {
121
171
  set_state({ path, value }) {
122
172
  if (!wu.store) return { error: 'wu.store not available' };
123
173
  if (!path) return { error: 'path is required' };
174
+ if (!_checkPermission('writeStore')) {
175
+ _audit('set_state', { path }, { error: 'Permission denied' });
176
+ return { error: 'Permission denied: writeStore is disabled' };
177
+ }
124
178
  wu.store.set(path, value);
179
+ _audit('set_state', { path, value }, { success: true });
125
180
  return { path, value, updated: true };
126
181
  },
127
182
 
128
183
  emit_event({ event, data }) {
129
184
  if (!wu.eventBus) return { error: 'wu.eventBus not available' };
130
185
  if (!event) return { error: 'event name is required' };
131
- wu.eventBus.emit(event, data);
186
+ if (!_checkPermission('emitEvents')) {
187
+ _audit('emit_event', { event }, { error: 'Permission denied' });
188
+ return { error: 'Permission denied: emitEvents is disabled' };
189
+ }
190
+ wu.eventBus.emit(event, data, { appName: 'wu-mcp-bridge' });
191
+ _audit('emit_event', { event, data }, { success: true });
132
192
  return { emitted: event, data };
133
193
  },
134
194
 
135
- list_events({ limit = 20 }) {
136
- return eventLog.slice(-limit);
137
- },
138
-
139
- list_actions() {
140
- if (!wu.ai?._actions) return { actions: [], note: 'wu.ai not initialized or no actions registered' };
141
- const actions = Object.entries(wu.ai._actions).map(([name, def]) => ({
142
- name,
143
- description: def.description || '',
144
- parameters: def.parameters ? Object.keys(def.parameters) : [],
145
- }));
146
- return { actions, count: actions.length };
195
+ navigate({ route }) {
196
+ if (!route) return { error: 'Route is required' };
197
+ if (!_checkPermission('emitEvents')) {
198
+ _audit('navigate', { route }, { error: 'Permission denied: emitEvents' });
199
+ return { error: 'Permission denied: emitEvents is disabled' };
200
+ }
201
+ if (wu.eventBus) {
202
+ wu.eventBus.emit('shell:navigate', { route }, { appName: 'wu-mcp-bridge' });
203
+ }
204
+ if (wu.store && _checkPermission('writeStore')) {
205
+ wu.store.set('currentPath', route);
206
+ }
207
+ _audit('navigate', { route }, { success: true });
208
+ return { navigated: route };
147
209
  },
148
210
 
149
- async execute_action({ action, params }) {
150
- if (!wu.ai) return { error: 'wu.ai not available' };
151
- if (!action) return { error: 'action name is required' };
152
-
211
+ mount_app({ appName, container }) {
212
+ if (!appName) return { error: 'appName is required' };
213
+ if (!_checkPermission('modifyDOM')) {
214
+ _audit('mount_app', { appName }, { error: 'Permission denied' });
215
+ return { error: 'Permission denied: modifyDOM is disabled' };
216
+ }
153
217
  try {
154
- // Try to execute via wu.ai action system
155
- if (wu.ai._actions && wu.ai._actions[action]) {
156
- const handler = wu.ai._actions[action].handler;
157
- const result = await handler(params || {}, {
158
- emit: (e, d) => wu.eventBus?.emit(e, d),
159
- setState: (p, v) => wu.store?.set(p, v),
160
- getState: (p) => wu.store?.get(p),
161
- });
162
- return { action, result };
218
+ if (wu.mount) {
219
+ wu.mount(appName, container);
220
+ _audit('mount_app', { appName, container }, { success: true });
221
+ return { mounted: appName, container };
163
222
  }
164
- return { error: `Action "${action}" not found` };
223
+ return { error: 'wu.mount not available' };
165
224
  } catch (err) {
166
225
  return { error: err.message };
167
226
  }
168
227
  },
169
228
 
170
- snapshot({ appName }) {
171
- try {
172
- const target = appName
173
- ? document.querySelector(`[data-wu-app="${appName}"]`) || document.querySelector(`#wu-app-${appName}`)
174
- : document.body;
175
-
176
- if (!target) return { error: `App "${appName}" not found in DOM` };
177
-
178
- const tree = _buildA11yTree(target, 0, 5);
179
- return {
180
- app: appName || '(page)',
181
- snapshot: tree,
182
- timestamp: Date.now(),
183
- };
184
- } catch (err) {
185
- return { error: err.message };
229
+ unmount_app({ appName }) {
230
+ if (!appName) return { error: 'appName is required' };
231
+ if (!_checkPermission('modifyDOM')) {
232
+ _audit('unmount_app', { appName }, { error: 'Permission denied' });
233
+ return { error: 'Permission denied: modifyDOM is disabled' };
186
234
  }
187
- },
188
-
189
- console({ level = 'all', limit = 50 }) {
190
- const filtered = level === 'all'
191
- ? consoleLog
192
- : consoleLog.filter((m) => m.level === level);
193
- return filtered.slice(-limit);
194
- },
195
-
196
- async screenshot({ selector, quality = 0.8 }) {
197
235
  try {
198
- const target = selector
199
- ? document.querySelector(selector)
200
- : document.documentElement;
201
-
202
- if (!target) return { error: `Element not found: ${selector}` };
203
-
204
- const rect = target.getBoundingClientRect();
205
- const w = Math.ceil(Math.min(rect.width || window.innerWidth, 1920));
206
- const h = Math.ceil(Math.min(rect.height || window.innerHeight, 1080));
207
-
208
- // Clone target and inline all computed styles for accurate rendering
209
- const clone = target.cloneNode(true);
210
- _inlineComputedStyles(target, clone);
211
-
212
- // Serialize to XHTML (required for SVG foreignObject)
213
- const serializer = new XMLSerializer();
214
- const xhtml = serializer.serializeToString(clone);
215
-
216
- // Build SVG with foreignObject containing the styled DOM
217
- const svgStr = [
218
- `<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}">`,
219
- '<foreignObject width="100%" height="100%">',
220
- `<div xmlns="http://www.w3.org/1999/xhtml" style="width:${w}px;height:${h}px;overflow:hidden;">`,
221
- xhtml,
222
- '</div>',
223
- '</foreignObject>',
224
- '</svg>',
225
- ].join('');
226
-
227
- const svgBlob = new Blob([svgStr], { type: 'image/svg+xml;charset=utf-8' });
228
- const url = URL.createObjectURL(svgBlob);
229
-
230
- // Render SVG to Canvas
231
- const dataUrl = await new Promise((resolve) => {
232
- const img = new Image();
233
- img.onload = () => {
234
- const canvas = document.createElement('canvas');
235
- canvas.width = w;
236
- canvas.height = h;
237
- const ctx = canvas.getContext('2d');
238
- ctx.drawImage(img, 0, 0);
239
- URL.revokeObjectURL(url);
240
- resolve(canvas.toDataURL('image/png', quality));
241
- };
242
- img.onerror = () => {
243
- URL.revokeObjectURL(url);
244
- resolve(null);
245
- };
246
- img.src = url;
247
- });
248
-
249
- if (!dataUrl) return { error: 'Canvas rendering failed' };
250
-
251
- // Return base64 without the data:image/png;base64, prefix
252
- const base64 = dataUrl.split(',')[1];
253
- return {
254
- selector: selector || '(page)',
255
- width: w,
256
- height: h,
257
- format: 'png',
258
- base64,
259
- sizeKB: Math.round((base64.length * 3) / 4 / 1024),
260
- timestamp: Date.now(),
261
- };
236
+ if (wu.unmount) {
237
+ wu.unmount(appName);
238
+ _audit('unmount_app', { appName }, { success: true });
239
+ return { unmounted: appName };
240
+ }
241
+ return { error: 'wu.unmount not available' };
262
242
  } catch (err) {
263
243
  return { error: err.message };
264
244
  }
265
245
  },
266
246
 
267
247
  click({ selector, text }) {
268
- try {
269
- let el = null;
270
-
271
- if (selector) {
272
- el = document.querySelector(selector);
273
- }
274
-
275
- // Fallback: find by visible text content
276
- if (!el && text) {
277
- const candidates = document.querySelectorAll('button, a, [role="button"], input[type="submit"], [data-click], label');
278
- for (const candidate of candidates) {
279
- if (candidate.textContent?.trim().toLowerCase().includes(text.toLowerCase())) {
280
- el = candidate;
281
- break;
282
- }
283
- }
284
- }
285
-
286
- if (!el) return { error: `Element not found: ${selector || `text="${text}"`}` };
287
-
288
- // Scroll into view and click
289
- el.scrollIntoView({ behavior: 'instant', block: 'center' });
290
- el.click();
291
-
292
- const tag = el.tagName?.toLowerCase();
293
- const id = el.id ? `#${el.id}` : '';
294
- const cls = el.className && typeof el.className === 'string' ? `.${el.className.split(' ')[0]}` : '';
295
- return {
296
- clicked: `${tag}${id}${cls}`,
297
- text: el.textContent?.trim().slice(0, 80) || '',
298
- rect: el.getBoundingClientRect().toJSON(),
299
- };
300
- } catch (err) {
301
- return { error: err.message };
248
+ if (!_checkPermission('modifyDOM')) {
249
+ return { error: 'Permission denied: modifyDOM is disabled' };
302
250
  }
251
+ const result = clickElement(selector, text);
252
+ _audit('click', { selector, text }, result);
253
+ return result;
303
254
  },
304
255
 
305
256
  type({ selector, text, clear = false, submit = false }) {
306
- try {
307
- if (!selector) return { error: 'selector is required' };
308
- if (text === undefined) return { error: 'text is required' };
309
-
310
- const el = document.querySelector(selector);
311
- if (!el) return { error: `Element not found: ${selector}` };
312
-
313
- // Focus the element
314
- el.focus();
315
-
316
- // Clear existing value if requested
317
- if (clear) {
318
- el.value = '';
319
- el.dispatchEvent(new Event('input', { bubbles: true }));
320
- }
321
-
322
- // Set value and fire events (works with React, Vue, etc.)
323
- const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
324
- window.HTMLInputElement.prototype, 'value'
325
- )?.set || Object.getOwnPropertyDescriptor(
326
- window.HTMLTextAreaElement.prototype, 'value'
327
- )?.set;
328
-
329
- if (nativeInputValueSetter) {
330
- nativeInputValueSetter.call(el, clear ? text : el.value + text);
331
- } else {
332
- el.value = clear ? text : el.value + text;
333
- }
334
-
335
- // Dispatch events that frameworks listen to
336
- el.dispatchEvent(new Event('input', { bubbles: true }));
337
- el.dispatchEvent(new Event('change', { bubbles: true }));
338
-
339
- // Submit form if requested
340
- if (submit) {
341
- const form = el.closest('form');
342
- if (form) {
343
- form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
344
- } else {
345
- el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', code: 'Enter', bubbles: true }));
346
- }
347
- }
348
-
349
- return {
350
- selector,
351
- typed: text,
352
- currentValue: el.value?.slice(0, 200),
353
- submitted: submit,
354
- };
355
- } catch (err) {
356
- return { error: err.message };
257
+ if (!_checkPermission('modifyDOM')) {
258
+ return { error: 'Permission denied: modifyDOM is disabled' };
357
259
  }
260
+ const result = typeIntoElement(selector, text, { clear, submit });
261
+ _audit('type', { selector, textLength: text?.length }, result);
262
+ return result;
358
263
  },
359
264
 
360
- network({ method, status, limit = 50 }) {
361
- let filtered = networkLog;
362
- if (method) filtered = filtered.filter((r) => r.method.toUpperCase() === method.toUpperCase());
363
- if (status) {
364
- if (status === 'error') {
365
- filtered = filtered.filter((r) => r.status === 0 || r.status >= 400);
366
- } else {
367
- filtered = filtered.filter((r) => String(r.status).startsWith(String(status)));
368
- }
369
- }
370
- return {
371
- requests: filtered.slice(-limit),
372
- total: networkLog.length,
373
- filtered: filtered.length,
374
- };
375
- },
265
+ async execute_action({ action, params }) {
266
+ if (!wu.ai) return { error: 'wu.ai not available' };
267
+ if (!action) return { error: 'action name is required' };
376
268
 
377
- eval({ expression }) {
378
269
  try {
379
- // eslint-disable-next-line no-eval
380
- const result = eval(expression);
381
- return {
382
- expression,
383
- result: typeof result === 'object' ? JSON.stringify(result, null, 2) : String(result),
384
- type: typeof result,
385
- };
270
+ // Execute through public API (respects permissions, validation, audit)
271
+ const result = await wu.ai.execute(action, params || {});
272
+ return { action, ...result };
386
273
  } catch (err) {
387
- return { error: err.message, expression };
274
+ return { error: err.message };
388
275
  }
389
276
  },
390
277
  };
391
278
 
392
279
  // ── WebSocket connection ──
393
280
 
394
- function connect(url = 'ws://localhost:19100') {
281
+ function connect(url = 'ws://localhost:19100', options = {}) {
395
282
  if (ws && ws.readyState <= 1) {
396
283
  console.warn('[wu-mcp-bridge] Already connected or connecting');
397
284
  return;
398
285
  }
399
286
 
287
+ authToken = options.token || null;
288
+ authenticated = !authToken; // No token = auto-authenticated (dev mode)
289
+
400
290
  try {
401
291
  ws = new WebSocket(url);
402
292
 
403
293
  ws.onopen = () => {
404
294
  console.log('[wu-mcp-bridge] Connected to wu-mcp-server');
405
295
  reconnectAttempts = 0;
296
+
297
+ // Send auth handshake if token provided
298
+ if (authToken) {
299
+ ws.send(JSON.stringify({
300
+ type: 'auth',
301
+ token: authToken,
302
+ }));
303
+ }
406
304
  };
407
305
 
408
306
  ws.onmessage = async (event) => {
409
307
  try {
410
308
  const msg = JSON.parse(event.data);
309
+
310
+ // Handle auth response
311
+ if (msg.type === 'auth_result') {
312
+ authenticated = msg.success === true;
313
+ if (!authenticated) {
314
+ console.error('[wu-mcp-bridge] Authentication failed:', msg.reason || 'Invalid token');
315
+ disconnect();
316
+ } else {
317
+ console.log('[wu-mcp-bridge] Authenticated successfully');
318
+ }
319
+ return;
320
+ }
321
+
322
+ // Reject commands if not authenticated
323
+ if (!authenticated) {
324
+ if (msg.id) {
325
+ _respond(msg.id, null, 'Not authenticated. Send auth token first.');
326
+ }
327
+ return;
328
+ }
329
+
411
330
  const { id, command, params } = msg;
412
331
 
413
332
  if (!id || !command) {
@@ -435,7 +354,8 @@ export function createMcpBridge(wu) {
435
354
  ws.onclose = () => {
436
355
  console.log('[wu-mcp-bridge] Disconnected');
437
356
  ws = null;
438
- _scheduleReconnect(url);
357
+ authenticated = false;
358
+ _scheduleReconnect(url, options);
439
359
  };
440
360
 
441
361
  ws.onerror = () => {
@@ -443,7 +363,7 @@ export function createMcpBridge(wu) {
443
363
  };
444
364
  } catch (err) {
445
365
  console.error('[wu-mcp-bridge] Connection failed:', err.message);
446
- _scheduleReconnect(url);
366
+ _scheduleReconnect(url, options);
447
367
  }
448
368
  }
449
369
 
@@ -457,10 +377,11 @@ export function createMcpBridge(wu) {
457
377
  ws.close();
458
378
  ws = null;
459
379
  }
380
+ authenticated = false;
460
381
  }
461
382
 
462
383
  function isConnected() {
463
- return ws !== null && ws.readyState === 1;
384
+ return ws !== null && ws.readyState === 1 && authenticated;
464
385
  }
465
386
 
466
387
  // ── Private helpers ──
@@ -471,15 +392,14 @@ export function createMcpBridge(wu) {
471
392
  ws.send(JSON.stringify(msg));
472
393
  }
473
394
 
474
- function _scheduleReconnect(url) {
395
+ function _scheduleReconnect(url, options) {
475
396
  if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) return;
476
397
  reconnectAttempts++;
477
398
  const delay = RECONNECT_DELAY * Math.min(reconnectAttempts, 5);
478
- reconnectTimer = setTimeout(() => connect(url), delay);
399
+ reconnectTimer = setTimeout(() => connect(url, options), delay);
479
400
  }
480
401
 
481
402
  function _getAppList() {
482
- // Try to get app info from wu internals
483
403
  const apps = [];
484
404
 
485
405
  if (wu._apps) {
@@ -507,141 +427,5 @@ export function createMcpBridge(wu) {
507
427
  return apps;
508
428
  }
509
429
 
510
- function _buildA11yTree(el, depth, maxDepth) {
511
- if (depth > maxDepth || !el) return '';
512
-
513
- const indent = ' '.repeat(depth);
514
- const tag = el.tagName?.toLowerCase() || '';
515
- const role = el.getAttribute?.('role') || '';
516
- const ariaLabel = el.getAttribute?.('aria-label') || '';
517
- const text = el.childNodes?.length === 1 && el.childNodes[0].nodeType === 3
518
- ? el.textContent?.trim().slice(0, 80) : '';
519
-
520
- let line = `${indent}<${tag}`;
521
- if (el.id) line += ` id="${el.id}"`;
522
- if (role) line += ` role="${role}"`;
523
- if (ariaLabel) line += ` aria-label="${ariaLabel}"`;
524
- if (el.className && typeof el.className === 'string') {
525
- const cls = el.className.trim().slice(0, 60);
526
- if (cls) line += ` class="${cls}"`;
527
- }
528
- line += '>';
529
- if (text) line += ` "${text}"`;
530
-
531
- let result = line + '\n';
532
-
533
- // Traverse into shadow DOM if present
534
- const root = el.shadowRoot || el;
535
- const children = root.children || [];
536
-
537
- for (let i = 0; i < children.length && i < 50; i++) {
538
- result += _buildA11yTree(children[i], depth + 1, maxDepth);
539
- }
540
-
541
- return result;
542
- }
543
-
544
- function _interceptConsole() {
545
- const levels = ['log', 'warn', 'error'];
546
- for (const level of levels) {
547
- const original = console[level];
548
- console[level] = (...args) => {
549
- consoleLog.push({
550
- level,
551
- message: args.map((a) => (typeof a === 'object' ? JSON.stringify(a) : String(a))).join(' '),
552
- timestamp: Date.now(),
553
- });
554
- if (consoleLog.length > MAX_CONSOLE_LOG) consoleLog.shift();
555
- original.apply(console, args);
556
- };
557
- }
558
- }
559
-
560
- function _interceptNetwork() {
561
- // ── Intercept fetch() ──
562
- const originalFetch = window.fetch;
563
- window.fetch = async function (...args) {
564
- const start = Date.now();
565
- const req = args[0];
566
- const url = typeof req === 'string' ? req : req?.url || '';
567
- const method = args[1]?.method || (req?.method) || 'GET';
568
-
569
- try {
570
- const response = await originalFetch.apply(window, args);
571
- const size = parseInt(response.headers?.get('content-length') || '0', 10);
572
- networkLog.push({
573
- type: 'fetch', method: method.toUpperCase(), url,
574
- status: response.status, statusText: response.statusText,
575
- duration: Date.now() - start, size, timestamp: start,
576
- });
577
- if (networkLog.length > MAX_NETWORK_LOG) networkLog.shift();
578
- return response;
579
- } catch (err) {
580
- networkLog.push({
581
- type: 'fetch', method: method.toUpperCase(), url,
582
- status: 0, error: err.message,
583
- duration: Date.now() - start, timestamp: start,
584
- });
585
- if (networkLog.length > MAX_NETWORK_LOG) networkLog.shift();
586
- throw err;
587
- }
588
- };
589
-
590
- // ── Intercept XMLHttpRequest ──
591
- const origOpen = XMLHttpRequest.prototype.open;
592
- const origSend = XMLHttpRequest.prototype.send;
593
-
594
- XMLHttpRequest.prototype.open = function (method, url, ...rest) {
595
- this._wuMcp = { method: (method || 'GET').toUpperCase(), url: String(url), start: null };
596
- return origOpen.call(this, method, url, ...rest);
597
- };
598
-
599
- XMLHttpRequest.prototype.send = function (...args) {
600
- if (this._wuMcp) {
601
- this._wuMcp.start = Date.now();
602
- this.addEventListener('loadend', () => {
603
- networkLog.push({
604
- type: 'xhr',
605
- method: this._wuMcp.method,
606
- url: this._wuMcp.url,
607
- status: this.status,
608
- statusText: this.statusText,
609
- duration: Date.now() - this._wuMcp.start,
610
- size: parseInt(this.getResponseHeader('content-length') || '0', 10),
611
- timestamp: this._wuMcp.start,
612
- });
613
- if (networkLog.length > MAX_NETWORK_LOG) networkLog.shift();
614
- });
615
- }
616
- return origSend.apply(this, args);
617
- };
618
- }
619
-
620
- function _inlineComputedStyles(source, clone) {
621
- // Copy computed styles from source to clone for accurate Canvas rendering
622
- const sourceStyle = window.getComputedStyle(source);
623
- const important = ['color', 'background', 'background-color', 'font-family',
624
- 'font-size', 'font-weight', 'border', 'border-radius', 'padding', 'margin',
625
- 'display', 'flex-direction', 'align-items', 'justify-content', 'gap',
626
- 'width', 'height', 'max-width', 'max-height', 'overflow', 'opacity',
627
- 'box-shadow', 'text-align', 'line-height', 'position', 'top', 'left',
628
- 'right', 'bottom', 'z-index', 'transform', 'visibility'];
629
-
630
- for (const prop of important) {
631
- try {
632
- const val = sourceStyle.getPropertyValue(prop);
633
- if (val) clone.style?.setProperty(prop, val);
634
- } catch (_) { /* skip */ }
635
- }
636
-
637
- // Recurse into children (limit depth for performance)
638
- const sourceChildren = source.children || [];
639
- const cloneChildren = clone.children || [];
640
- const max = Math.min(sourceChildren.length, cloneChildren.length, 200);
641
- for (let i = 0; i < max; i++) {
642
- _inlineComputedStyles(sourceChildren[i], cloneChildren[i]);
643
- }
644
- }
645
-
646
430
  return { connect, disconnect, isConnected };
647
431
  }