comate-ui-inspector 0.1.6-alpha.4 → 0.1.6-alpha.5

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/bridge/server.mjs CHANGED
@@ -9,6 +9,7 @@ import {createServer, get} from 'node:http';
9
9
  import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs';
10
10
  import {dirname, resolve} from 'node:path';
11
11
  import {homedir} from 'node:os';
12
+ import {buildQaContextText} from '../lib/context-text.mjs';
12
13
 
13
14
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
14
15
  const statusFile = resolve(homedir(), '.ui-inspector/status.json');
@@ -186,6 +187,56 @@ function figmaCapture(node) {
186
187
  writeFileSync(figmaPendingFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
187
188
  }
188
189
 
190
+ // QA 模式:读 append-only 事件流,返回有序的 {kind, item} 列表(与 hook 同源逻辑)。
191
+ function readEvents() {
192
+ const out = [];
193
+ let text = '';
194
+ try {
195
+ text = readFileSync(eventsFile, 'utf8');
196
+ } catch {
197
+ return out;
198
+ }
199
+ for (const line of text.split('\n')) {
200
+ if (!line.trim()) {
201
+ continue;
202
+ }
203
+ let ev;
204
+ try {
205
+ ev = JSON.parse(line);
206
+ } catch {
207
+ continue;
208
+ }
209
+ if (ev && ev.kind && ev.item) {
210
+ out.push(ev);
211
+ }
212
+ }
213
+ return out;
214
+ }
215
+
216
+ // QA 模式:读 figma 采集列表。
217
+ function readFigma() {
218
+ try {
219
+ const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
220
+ return Array.isArray(p) ? p : [];
221
+ } catch {
222
+ return [];
223
+ }
224
+ }
225
+
226
+ // QA 模式:把选中元素 + 已停止的录制 + figma 节点拼成一份可复制的上下文文本(供测试人员复制)。
227
+ // 录制与 hook 同一约束:仅 active === false(已停止)时输出,进行中不输出以免截断。
228
+ function qaContext() {
229
+ const list = readStore();
230
+ const meta = readMeta();
231
+ const evs = meta && meta.active === false ? readEvents() : [];
232
+ const figma = readFigma();
233
+ return {
234
+ ok: true,
235
+ text: buildQaContextText(list, meta, evs, figma),
236
+ counts: {elements: list.length, recEvents: evs.length, figma: figma.length},
237
+ };
238
+ }
239
+
189
240
  function readBody(req, cb) {
190
241
  let body = '';
191
242
  req.on('data', chunk => (body += chunk));
@@ -235,6 +286,10 @@ const server = createServer((req, res) => {
235
286
  json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
236
287
  return;
237
288
  }
289
+ if (req.method === 'GET' && url.pathname === '/qa/context') {
290
+ json(res, 200, qaContext());
291
+ return;
292
+ }
238
293
  if (req.method === 'POST' && url.pathname === '/rec/start') {
239
294
  readBody(req, (data, err) => {
240
295
  if (err) {
@@ -6,6 +6,37 @@
6
6
  />
7
7
  录制控制台输出
8
8
  </label>
9
+ <div
10
+ id="rec-console-filter-wrap"
11
+ style="margin-top: 4px"
12
+ >
13
+ <div style="margin-bottom: 4px">
14
+ <label class="radio">
15
+ <input
16
+ type="radio"
17
+ name="console-filter-mode"
18
+ value="black"
19
+ checked
20
+ />
21
+ 黑名单(剔除命中)
22
+ </label>
23
+ <label class="radio">
24
+ <input
25
+ type="radio"
26
+ name="console-filter-mode"
27
+ value="white"
28
+ />
29
+ 白名单(只保留命中)
30
+ </label>
31
+ </div>
32
+ <input
33
+ id="rec-console-filter"
34
+ type="text"
35
+ placeholder="正则,每行一条,如 .*warn.*"
36
+ style="width: 100%"
37
+ />
38
+ <div class="hint">每行一条正则:黑名单剔除命中输出,白名单只保留命中输出(空则全不保留)。</div>
39
+ </div>
9
40
  <label class="radio">
10
41
  <input
11
42
  type="checkbox"
@@ -13,6 +13,14 @@
13
13
  byId('rec-status').textContent = active ? '录制中…(操作页面后点停止)' : '';
14
14
  byId('rec-console').disabled = active;
15
15
  byId('rec-network').disabled = active;
16
+ byId('rec-console-filter').disabled = active;
17
+ for (const r of document.querySelectorAll('input[name="console-filter-mode"]')) {
18
+ r.disabled = active;
19
+ r.checked = (recording && recording.consoleFilterMode === r.value) || (!recording && r.value === 'black');
20
+ }
21
+ if (recording && typeof recording.consoleFilter === 'string') {
22
+ byId('rec-console-filter').value = recording.consoleFilter;
23
+ }
16
24
  }
17
25
 
18
26
  byId('rec-toggle').onclick = async () => {
@@ -26,6 +34,11 @@
26
34
  active: true,
27
35
  console: byId('rec-console').checked,
28
36
  network: byId('rec-network').checked,
37
+ consoleFilter: byId('rec-console-filter').value.trim(),
38
+ consoleFilterMode:
39
+ document.querySelector('input[name="console-filter-mode"]:checked')?.value === 'white'
40
+ ? 'white'
41
+ : 'black',
29
42
  startedAt: Date.now(),
30
43
  },
31
44
  });
@@ -167,17 +167,43 @@
167
167
  emit('keydown', {key: e.key, selector: el && el.nodeType === 1 ? cssPath(el) : '', ts: Date.now()});
168
168
  }
169
169
 
170
+ // 控制台过滤:黑白名单二选一。每行一条正则(非法跳过);黑名单命中即剔除,白名单只保留命中。
171
+ var consoleFilters = [];
172
+ var consoleFilterMode = 'black';
173
+ function compileFilters(raw) {
174
+ const out = [];
175
+ for (const line of String(raw || '').split('\n')) {
176
+ const s = line.trim();
177
+ if (!s) {
178
+ continue;
179
+ }
180
+ try {
181
+ out.push(new RegExp(s));
182
+ } catch (e) {
183
+ // 忽略非法正则
184
+ }
185
+ }
186
+ return out;
187
+ }
188
+ function shouldRecordConsole(text) {
189
+ if (!consoleFilters.length) {
190
+ // 无正则:黑名单 = 全记录;白名单 = 全不记录(符合白名单语义)
191
+ return consoleFilterMode !== 'white';
192
+ }
193
+ const hit = consoleFilters.some(f => f.test(text));
194
+ return consoleFilterMode === 'white' ? hit : !hit;
195
+ }
196
+
170
197
  function startConsole() {
171
198
  ['log', 'info', 'warn', 'error'].forEach(function (level) {
172
199
  saved['c_' + level] = console[level];
173
200
  console[level] = function () {
174
201
  try {
175
202
  if (on) {
176
- emit('console', {
177
- level: level,
178
- text: Array.prototype.map.call(arguments, fmt).join(' ').slice(0, 2000),
179
- ts: Date.now(),
180
- });
203
+ const text = Array.prototype.map.call(arguments, fmt).join(' ').slice(0, 2000);
204
+ if (shouldRecordConsole(text)) {
205
+ emit('console', {level: level, text: text, ts: Date.now()});
206
+ }
181
207
  }
182
208
  } catch (e) {
183
209
  // 记录失败不影响原 console
@@ -197,6 +223,75 @@
197
223
  }
198
224
 
199
225
  function startNetwork() {
226
+ // 请求/响应体字段级截断:解析成 JSON 后保留结构,仅长字符串截断、数组限量、层数限深;
227
+ // 解析失败(非 JSON 文本)则整段截断 maxTotal。长度上限防撑爆录制文件(可能含敏感信息,仅本地录制用)。
228
+ function compactJson(text, maxTotal) {
229
+ var raw = String(text == null ? '' : text);
230
+ var v;
231
+ try {
232
+ v = JSON.parse(raw);
233
+ } catch (e) {
234
+ return raw.slice(0, maxTotal);
235
+ }
236
+ var trunc = function (val, depth) {
237
+ if (typeof val === 'string') {
238
+ return val.length > 120 ? val.slice(0, 120) + '…' : val;
239
+ }
240
+ if (Array.isArray(val)) {
241
+ if (depth > 4) {
242
+ return '[Array(' + val.length + ')]';
243
+ }
244
+ var out = val.slice(0, 20).map(function (x) {
245
+ return trunc(x, depth + 1);
246
+ });
247
+ if (val.length > 20) {
248
+ out.push('…(' + (val.length - 20) + ' 项省略)');
249
+ }
250
+ return out;
251
+ }
252
+ if (val && typeof val === 'object') {
253
+ if (depth > 4) {
254
+ return '[Object]';
255
+ }
256
+ var o = {};
257
+ Object.keys(val).forEach(function (k) {
258
+ o[k] = trunc(val[k], depth + 1);
259
+ });
260
+ return o;
261
+ }
262
+ return val;
263
+ };
264
+ return trunc(v, 0);
265
+ }
266
+
267
+ // 请求体归一成文本:string 原样、URLSearchParams/FormData 拼成 query 形、对象序列化;Blob 不读。
268
+ function reqTextOf(body) {
269
+ if (typeof body === 'string') {
270
+ return body;
271
+ }
272
+ try {
273
+ if (body instanceof URLSearchParams) {
274
+ return body.toString();
275
+ }
276
+ if (body instanceof FormData) {
277
+ var parts = [];
278
+ body.forEach(function (val, key) {
279
+ parts.push(key + '=' + val);
280
+ });
281
+ return parts.join('&');
282
+ }
283
+ if (body instanceof Blob) {
284
+ return '';
285
+ }
286
+ if (body) {
287
+ return JSON.stringify(body);
288
+ }
289
+ } catch (e) {
290
+ return '';
291
+ }
292
+ return '';
293
+ }
294
+
200
295
  if (window.fetch) {
201
296
  saved.fetch = window.fetch;
202
297
  window.fetch = function (input, init) {
@@ -205,21 +300,52 @@
205
300
  var t0 = Date.now();
206
301
  var p = saved.fetch.apply(this, arguments);
207
302
  if (on && sameHost(url)) {
303
+ var qi = url.indexOf('?');
304
+ var query = qi >= 0 ? url.slice(qi + 1) : '';
305
+ var reqBody = reqTextOf(init && init.body);
208
306
  p.then(
209
307
  function (r) {
210
308
  var t1 = Date.now();
211
- emit('network', {
309
+ var item = {
212
310
  method: method,
213
311
  url: url,
214
312
  status: r.status,
215
313
  startTs: t0,
216
314
  endTs: t1,
217
315
  ms: t1 - t0,
218
- });
316
+ };
317
+ if (query) {
318
+ item.params = query.slice(0, 200);
319
+ }
320
+ if (reqBody) {
321
+ item.reqBody = compactJson(reqBody, 1200);
322
+ }
323
+ // 用 clone 读响应体,不影响页面拿原始流。
324
+ var cloned = null;
325
+ try {
326
+ cloned = r.clone();
327
+ } catch (e) {
328
+ cloned = null;
329
+ }
330
+ if (cloned) {
331
+ cloned
332
+ .text()
333
+ .then(function (body) {
334
+ if (on && body) {
335
+ item.resp = compactJson(body, 1200);
336
+ }
337
+ emit('network', item);
338
+ })
339
+ .catch(function () {
340
+ emit('network', item);
341
+ });
342
+ } else {
343
+ emit('network', item);
344
+ }
219
345
  },
220
346
  function () {
221
347
  var t1 = Date.now();
222
- emit('network', {
348
+ var item = {
223
349
  method: method,
224
350
  url: url,
225
351
  status: 0,
@@ -227,7 +353,14 @@
227
353
  endTs: t1,
228
354
  ms: t1 - t0,
229
355
  error: true,
230
- });
356
+ };
357
+ if (query) {
358
+ item.params = query.slice(0, 200);
359
+ }
360
+ if (reqBody) {
361
+ item.reqBody = compactJson(reqBody, 1200);
362
+ }
363
+ emit('network', item);
231
364
  }
232
365
  );
233
366
  }
@@ -240,21 +373,41 @@
240
373
  this.__rec = {method: method, url: url, t0: Date.now()};
241
374
  return saved.xhrOpen.apply(this, arguments);
242
375
  };
243
- XMLHttpRequest.prototype.send = function () {
376
+ XMLHttpRequest.prototype.send = function (body) {
244
377
  var xhr = this;
245
378
  var info = xhr.__rec;
246
379
  if (info) {
380
+ var reqBody = reqTextOf(body);
247
381
  xhr.addEventListener('loadend', function () {
248
382
  if (on && sameHost(info.url)) {
249
383
  var t1 = Date.now();
250
- emit('network', {
384
+ var item = {
251
385
  method: info.method,
252
386
  url: info.url,
253
387
  status: xhr.status,
254
388
  startTs: info.t0,
255
389
  endTs: t1,
256
390
  ms: t1 - info.t0,
257
- });
391
+ };
392
+ var qi = info.url.indexOf('?');
393
+ if (qi >= 0) {
394
+ item.params = info.url.slice(qi + 1).slice(0, 200);
395
+ }
396
+ if (reqBody) {
397
+ item.reqBody = compactJson(reqBody, 1200);
398
+ }
399
+ // 文本型响应才记 body(blob/arraybuffer 等不记)。
400
+ try {
401
+ if (!xhr.responseType || xhr.responseType === 'text') {
402
+ var bodyText = xhr.responseText;
403
+ if (bodyText) {
404
+ item.resp = compactJson(bodyText, 1200);
405
+ }
406
+ }
407
+ } catch (e) {
408
+ // 读不到就不记
409
+ }
410
+ emit('network', item);
258
411
  }
259
412
  });
260
413
  }
@@ -282,6 +435,9 @@
282
435
  return;
283
436
  }
284
437
  on = {console: !!opts.console, network: !!opts.network};
438
+ consoleFilters = compileFilters(opts.consoleFilter);
439
+ consoleFilterMode = opts.consoleFilterMode === 'white' ? 'white' : 'black';
440
+ startDomWatch();
285
441
  document.addEventListener('click', onClick, true);
286
442
  document.addEventListener('scroll', onScroll, true);
287
443
  document.addEventListener('change', onChange, true);
@@ -295,10 +451,279 @@
295
451
  }
296
452
  }
297
453
 
454
+ // ---------- 关键 DOM 变化采集 ----------
455
+ // 三类事件:出现/移除(dom-add/dom-remove)、可见性切换(dom-vis)、文本变化(dom-text)。
456
+ // 记录对象两档降级:优先「挂 Vue 组件」的节点(开发环境定位源码);线上无 __file 时退而记录
457
+ // 「有 id/class 可识别」的节点;两者都没有(裸 div/span)忽略。
458
+ // 去重/聚合(控量核心):
459
+ // - 同一批增删嵌套只记顶层(isDescendantOf);
460
+ // - 已记录节点的子树后续变化不重复记(recentAdds,弹窗内部内容归入弹窗);
461
+ // - 窗口内同构节点聚合为 ×N(selector 数字索引模板化,列表批量渲染只记一条示例)。
462
+ // 其余控量:同 key 限流(组件 2s / 无组件 6s);单次录制最多 DOM_LIMIT 条。
463
+ var domObserver = null;
464
+ var domCount = 0;
465
+ var domLastEmit = {};
466
+ var DOM_LIMIT = 50;
467
+ var DOM_THROTTLE = 2000;
468
+ var ANCESTOR_WINDOW = 1500; // 已记录节点的生效窗口(其子树变化视为内部内容)
469
+ var AGG_WINDOW = 1000; // 同类节点聚合窗口
470
+ var recentAdds = []; // [{el, ts}] 最近记过 dom-add 的节点
471
+ var pendingAggs = {}; // key -> {op, tpl, info, count, ts, timer}
472
+
473
+ // 记录对象判定(两档降级):
474
+ // 1) 优先「挂 Vue 组件」的节点(有 __vueParentComponent,链上带 __file/__name)——开发环境可定位源码;
475
+ // 2) 线上无 __file(生产构建不注入),退而记录「有 id/class 可识别」的节点(chain 为空,只描述现象)。
476
+ // 两者都没有(裸 div/span)→ 返回 null 忽略。
477
+ function vueInfo(el) {
478
+ var node = el;
479
+ while (node && node.nodeType === 1) {
480
+ if (node.__vueParentComponent) {
481
+ var chain = [];
482
+ var inst = node.__vueParentComponent;
483
+ while (inst && chain.length < 6) {
484
+ var t = inst.type || {};
485
+ if (t.__file || t.__name || t.name) {
486
+ chain.push({name: t.__name || t.name || null, file: t.__file || null});
487
+ }
488
+ inst = inst.parent;
489
+ }
490
+ return {selector: cssPath(node), tag: node.nodeName.toLowerCase(), chain: chain, component: true};
491
+ }
492
+ node = node.parentElement;
493
+ }
494
+ node = el;
495
+ while (node && node.nodeType === 1) {
496
+ if (node.id || (node.getAttribute('class') || '').trim()) {
497
+ return {selector: cssPath(node), tag: node.nodeName.toLowerCase(), chain: [], component: false};
498
+ }
499
+ node = node.parentElement;
500
+ }
501
+ return null;
502
+ }
503
+
504
+ // 通用控量:超出上限 / 同 key 限流窗口内已发 → 丢弃。无组件链的节点限流更严(3 倍窗口)。
505
+ // dom-add 时记录节点引用,供 recentAdds 祖先窗口去重(子树后续变化不再重复记)。
506
+ function pushDom(kind, node, info, extra) {
507
+ if (!on || domCount >= DOM_LIMIT) {
508
+ return;
509
+ }
510
+ var now = Date.now();
511
+ var throttle = info.component ? DOM_THROTTLE : DOM_THROTTLE * 3;
512
+ var key = kind + '|' + info.selector + '|' + (info.component ? 'c' : 'x');
513
+ if (domLastEmit[key] && now - domLastEmit[key] < throttle) {
514
+ return;
515
+ }
516
+ domLastEmit[key] = now;
517
+ domCount++;
518
+ var item = {kind: kind, selector: info.selector, tag: info.tag, componentChain: info.chain, ts: now};
519
+ if (extra) {
520
+ Object.keys(extra).forEach(function (k) {
521
+ item[k] = extra[k];
522
+ });
523
+ }
524
+ if (kind === 'dom-add' && node) {
525
+ recentAdds.push({el: node, ts: now});
526
+ if (recentAdds.length > 60) {
527
+ recentAdds = recentAdds.slice(-60);
528
+ }
529
+ }
530
+ emit(kind, item);
531
+ }
532
+
533
+ // 批量增删:如果 n 是本次 added/removed 里另一个节点的后代,则跳过(父级已代表该变化)。
534
+ function isDescendantOf(n, set) {
535
+ var p = n.parentElement;
536
+ while (p) {
537
+ if (set.has(p)) {
538
+ return true;
539
+ }
540
+ p = p.parentElement;
541
+ }
542
+ return false;
543
+ }
544
+
545
+ // 是否在「最近已记录节点」的子树内——弹窗内部晚到的内容归入弹窗,不重复记。
546
+ function isInsideRecentAdd(el) {
547
+ var now = Date.now();
548
+ recentAdds = recentAdds.filter(function (x) {
549
+ return now - x.ts < ANCESTOR_WINDOW;
550
+ });
551
+ var p = el.parentElement;
552
+ while (p) {
553
+ for (var i = 0; i < recentAdds.length; i++) {
554
+ if (recentAdds[i].el === p) {
555
+ return true;
556
+ }
557
+ }
558
+ p = p.parentElement;
559
+ }
560
+ return false;
561
+ }
562
+
563
+ // 同类节点聚合:selector 数字索引模板化(.item-3 → .item-*);AGG_WINDOW 内同模板合并为 ×N。
564
+ function aggregateDomChange(op, node, info) {
565
+ var tpl = info.selector.replace(/\d+/g, '*');
566
+ var key = op + '|' + info.tag + '|' + tpl + '|' + (info.component ? 'c' : 'x');
567
+ var now = Date.now();
568
+ var g = pendingAggs[key];
569
+ if (g && now - g.ts < AGG_WINDOW) {
570
+ g.count++;
571
+ g.ts = now;
572
+ clearTimeout(g.timer);
573
+ g.timer = setTimeout(function () {
574
+ flushAgg(g);
575
+ }, AGG_WINDOW);
576
+ return;
577
+ }
578
+ g = {op: op, tpl: tpl, info: info, count: 1, ts: now, key: key};
579
+ pendingAggs[key] = g;
580
+ g.timer = setTimeout(function () {
581
+ flushAgg(g);
582
+ }, AGG_WINDOW);
583
+ }
584
+
585
+ function flushAgg(g) {
586
+ if (!on) {
587
+ return;
588
+ }
589
+ delete pendingAggs[g.key];
590
+ var kind = g.op === 'add' ? 'dom-add' : 'dom-remove';
591
+ // 单条不聚合时用原 selector;聚合时用模板 + 示例 + 数量。
592
+ var extra = {selector: g.count > 1 ? g.tpl : g.info.selector, count: g.count};
593
+ if (g.count > 1) {
594
+ extra.sample = g.info.selector;
595
+ }
596
+ pushDom(kind, null, g.info, extra);
597
+ }
598
+
599
+ function onMutations(muts) {
600
+ if (!on) {
601
+ return;
602
+ }
603
+ for (var i = 0; i < muts.length; i++) {
604
+ var m = muts[i];
605
+ if (m.type === 'childList') {
606
+ if (m.addedNodes.length) {
607
+ var added = new Set();
608
+ for (var j = 0; j < m.addedNodes.length; j++) {
609
+ if (m.addedNodes[j].nodeType === 1) {
610
+ added.add(m.addedNodes[j]);
611
+ }
612
+ }
613
+ for (var k = 0; k < m.addedNodes.length; k++) {
614
+ var n = m.addedNodes[k];
615
+ // 顶层过滤:同批嵌套 / 已记录节点的子树(弹窗内部)都不重复记。
616
+ if (n.nodeType !== 1 || isDescendantOf(n, added) || isInsideRecentAdd(n)) {
617
+ continue;
618
+ }
619
+ var info = vueInfo(n);
620
+ if (info) {
621
+ aggregateDomChange('add', n, info);
622
+ }
623
+ }
624
+ }
625
+ if (m.removedNodes.length) {
626
+ var removed = new Set();
627
+ for (var r = 0; r < m.removedNodes.length; r++) {
628
+ if (m.removedNodes[r].nodeType === 1) {
629
+ removed.add(m.removedNodes[r]);
630
+ }
631
+ }
632
+ for (var rm = 0; rm < m.removedNodes.length; rm++) {
633
+ var rn = m.removedNodes[rm];
634
+ if (rn.nodeType !== 1 || isDescendantOf(rn, removed)) {
635
+ continue;
636
+ }
637
+ var rinfo = vueInfo(rn);
638
+ if (rinfo) {
639
+ aggregateDomChange('remove', rn, rinfo);
640
+ }
641
+ }
642
+ }
643
+ } else if (m.type === 'attributes') {
644
+ var el = m.target;
645
+ if (!el || el.nodeType !== 1) {
646
+ continue;
647
+ }
648
+ var ainfo = vueInfo(el);
649
+ if (!ainfo) {
650
+ continue;
651
+ }
652
+ var visible = false;
653
+ try {
654
+ var cs = getComputedStyle(el);
655
+ visible = cs.display !== 'none' && cs.visibility !== 'hidden' && !!el.getClientRects().length;
656
+ } catch (e) {
657
+ visible = false;
658
+ }
659
+ pushDom('dom-vis', el, ainfo, {visible: visible});
660
+ } else if (m.type === 'characterData') {
661
+ var textEl = m.target && m.target.parentElement;
662
+ if (!textEl || textEl.nodeType !== 1) {
663
+ continue;
664
+ }
665
+ var tagName = textEl.tagName || '';
666
+ // 表单值变化由 change 事件覆盖,characterData 只记普通容器文本,避免重复。
667
+ if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') {
668
+ continue;
669
+ }
670
+ var tinfo = vueInfo(textEl);
671
+ if (!tinfo) {
672
+ continue;
673
+ }
674
+ // 无组件链的文本变化要求可见(隐藏缓存/模板等不记,控量)。
675
+ if (!tinfo.component) {
676
+ try {
677
+ if (!textEl.getClientRects().length) {
678
+ continue;
679
+ }
680
+ } catch (e) {
681
+ continue;
682
+ }
683
+ }
684
+ pushDom('dom-text', textEl, tinfo, {text: String(m.target.data).slice(0, 80)});
685
+ }
686
+ }
687
+ }
688
+
689
+ function startDomWatch() {
690
+ if (domObserver) {
691
+ return;
692
+ }
693
+ domCount = 0;
694
+ domLastEmit = {};
695
+ recentAdds = [];
696
+ pendingAggs = {};
697
+ domObserver = new MutationObserver(onMutations);
698
+ domObserver.observe(document, {
699
+ childList: true,
700
+ subtree: true,
701
+ characterData: true,
702
+ attributes: true,
703
+ attributeFilter: ['style', 'class', 'hidden'],
704
+ });
705
+ }
706
+
707
+ function stopDomWatch() {
708
+ if (domObserver) {
709
+ domObserver.disconnect();
710
+ domObserver = null;
711
+ }
712
+ // 停止时把未到聚合窗口的组 flush 掉,避免丢事件。
713
+ for (var key in pendingAggs) {
714
+ var g = pendingAggs[key];
715
+ clearTimeout(g.timer);
716
+ flushAgg(g);
717
+ }
718
+ pendingAggs = {};
719
+ recentAdds = [];
720
+ }
721
+
298
722
  function stop() {
299
723
  if (!on) {
300
724
  return;
301
725
  }
726
+ stopDomWatch();
302
727
  document.removeEventListener('click', onClick, true);
303
728
  document.removeEventListener('scroll', onScroll, true);
304
729
  document.removeEventListener('change', onChange, true);
@@ -91,6 +91,10 @@ async function viaHttp(target, message) {
91
91
  });
92
92
  return await r.json();
93
93
  }
94
+ if (message.type === 'qa-context') {
95
+ const r = await fetch(`${base}/qa/context`);
96
+ return await r.json();
97
+ }
94
98
  const r = await fetch(`${base}/state`);
95
99
  return await r.json();
96
100
  } catch (error) {
@@ -113,7 +117,8 @@ chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
113
117
  msg.type === 'rec-start' ||
114
118
  msg.type === 'rec-event' ||
115
119
  msg.type === 'rec-stop' ||
116
- msg.type === 'figma-capture'
120
+ msg.type === 'figma-capture' ||
121
+ msg.type === 'qa-context'
117
122
  ) {
118
123
  dispatch(msg).then(sendResponse);
119
124
  return true; // 异步 sendResponse
@@ -38,6 +38,8 @@
38
38
  active: !!(r && r.active),
39
39
  console: !!(r && r.console),
40
40
  network: !!(r && r.network),
41
+ consoleFilter: (r && r.consoleFilter) || '',
42
+ consoleFilterMode: (r && r.consoleFilterMode) === 'white' ? 'white' : 'black',
41
43
  },
42
44
  '*'
43
45
  );
@@ -14,6 +14,7 @@
14
14
  import {readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs';
15
15
  import {dirname, resolve} from 'node:path';
16
16
  import {homedir} from 'node:os';
17
+ import {buildQaContextText} from '../../lib/context-text.mjs';
17
18
 
18
19
  // 共享落盘:一台机器一份,放用户主目录,不绑定任何仓库——一份代码可服务多个项目。
19
20
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
@@ -188,6 +189,56 @@ function figmaCapture(node) {
188
189
  writeFileSync(figmaPendingFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
189
190
  }
190
191
 
192
+ // QA 模式:读 append-only 事件流,返回有序的 {kind, item} 列表(与 hook 同源逻辑)。
193
+ function readEvents() {
194
+ const out = [];
195
+ let text = '';
196
+ try {
197
+ text = readFileSync(eventsFile, 'utf8');
198
+ } catch {
199
+ return out;
200
+ }
201
+ for (const line of text.split('\n')) {
202
+ if (!line.trim()) {
203
+ continue;
204
+ }
205
+ let ev;
206
+ try {
207
+ ev = JSON.parse(line);
208
+ } catch {
209
+ continue;
210
+ }
211
+ if (ev && ev.kind && ev.item) {
212
+ out.push(ev);
213
+ }
214
+ }
215
+ return out;
216
+ }
217
+
218
+ // QA 模式:读 figma 采集列表。
219
+ function readFigma() {
220
+ try {
221
+ const p = JSON.parse(readFileSync(figmaPendingFile, 'utf8'));
222
+ return Array.isArray(p) ? p : [];
223
+ } catch {
224
+ return [];
225
+ }
226
+ }
227
+
228
+ // QA 模式:把选中元素 + 已停止的录制 + figma 节点拼成一份可复制的上下文文本(供测试人员复制)。
229
+ // 录制与 hook 同一约束:仅 active === false(已停止)时输出,进行中不输出以免截断。
230
+ function qaContext() {
231
+ const list = readStore();
232
+ const meta = readMeta();
233
+ const evs = meta && meta.active === false ? readEvents() : [];
234
+ const figma = readFigma();
235
+ return {
236
+ ok: true,
237
+ text: buildQaContextText(list, meta, evs, figma),
238
+ counts: {elements: list.length, recEvents: evs.length, figma: figma.length},
239
+ };
240
+ }
241
+
191
242
  function send(obj) {
192
243
  const body = Buffer.from(JSON.stringify(obj), 'utf8');
193
244
  const header = Buffer.alloc(4);
@@ -216,6 +267,8 @@ function handle(msg) {
216
267
  } else if (msg && msg.type === 'figma-capture') {
217
268
  figmaCapture(msg.node);
218
269
  resp = {ok: true};
270
+ } else if (msg && msg.type === 'qa-context') {
271
+ resp = qaContext();
219
272
  } else {
220
273
  resp = {ok: false, error: 'unknown message'};
221
274
  }
@@ -175,11 +175,60 @@
175
175
  <div id="mode-panes"></div>
176
176
  </div>
177
177
 
178
- <!-- 「设置」页:生效域名 + 传输通路 -->
178
+ <!-- 「设置」页:RD/QA 模式切换 + 生效域名 + 传输通路 -->
179
179
  <div
180
180
  id="top-settings"
181
181
  style="display: none"
182
182
  >
183
+ <section>
184
+ <div class="label">模式</div>
185
+ <label class="radio">
186
+ <input
187
+ type="radio"
188
+ name="appmode"
189
+ value="rd"
190
+ />
191
+ RD(研发)
192
+ </label>
193
+ <label class="radio">
194
+ <input
195
+ type="radio"
196
+ name="appmode"
197
+ value="qa"
198
+ />
199
+ QA(测试)
200
+ </label>
201
+ </section>
202
+
203
+ <!-- QA 模式专属:复制复现信息(RD 模式隐藏) -->
204
+ <section
205
+ id="qa-block"
206
+ style="display: none"
207
+ >
208
+ <div class="label">QA:复制复现信息</div>
209
+ <div class="hint">
210
+ 录制结束并选中元素后,点下面按钮把「选中元素 + 操作录制(+ Figma 节点)」拼成一段文本复制,
211
+ 直接粘给开发 / Agent 即可复现问题。
212
+ </div>
213
+ <div
214
+ class="row"
215
+ style="margin-top: 8px"
216
+ >
217
+ <button
218
+ id="qa-copy"
219
+ class="primary"
220
+ style="flex: 1"
221
+ >
222
+ 复制选中元素与录制信息
223
+ </button>
224
+ </div>
225
+ <div
226
+ id="qa-status"
227
+ class="hint"
228
+ style="margin-top: 4px"
229
+ ></div>
230
+ </section>
231
+
183
232
  <section>
184
233
  <div class="label">生效域名</div>
185
234
  <div class="row">
@@ -199,49 +248,52 @@
199
248
  <div class="hint">添加时浏览器会请求该域名的访问权限,允许后即在该域名注入选取脚本。</div>
200
249
  </section>
201
250
 
202
- <section>
203
- <div class="label">传输通路</div>
204
- <label class="radio">
205
- <input
206
- type="radio"
207
- name="transport"
208
- value="native"
209
- />
210
- 本地(Native Messaging,默认)
211
- </label>
212
- <label class="radio">
213
- <input
214
- type="radio"
215
- name="transport"
216
- value="http"
217
- />
218
- 远程(HTTP bridge)
219
- </label>
220
- <div id="bridge-config">
221
- <div
222
- class="row"
223
- style="margin-top: 6px"
224
- >
251
+ <!-- 传输配置(RD 模式显示;QA 模式隐藏——QA 只复制本地上下文,走默认 native 即可) -->
252
+ <div id="transport-block">
253
+ <section>
254
+ <div class="label">传输通路</div>
255
+ <label class="radio">
225
256
  <input
226
- id="bridge-target"
227
- type="text"
228
- placeholder="<remote-ip>:<port>"
257
+ type="radio"
258
+ name="transport"
259
+ value="native"
229
260
  />
261
+ 本地(Native Messaging,默认)
262
+ </label>
263
+ <label class="radio">
264
+ <input
265
+ type="radio"
266
+ name="transport"
267
+ value="http"
268
+ />
269
+ 远程(HTTP bridge)
270
+ </label>
271
+ <div id="bridge-config">
272
+ <div
273
+ class="row"
274
+ style="margin-top: 6px"
275
+ >
276
+ <input
277
+ id="bridge-target"
278
+ type="text"
279
+ placeholder="<remote-ip>:<port>"
280
+ />
281
+ </div>
282
+ <div class="hint">
283
+ 填开发机 bridge 的完整地址(如
284
+ <code>&lt;remote-ip&gt;:&lt;port&gt;</code>
285
+ )
286
+ </div>
230
287
  </div>
231
- <div class="hint">
232
- 填开发机 bridge 的完整地址(如
233
- <code><\remote-ip>:<\port></code>
234
- )
235
- </div>
236
- </div>
237
- </section>
288
+ </section>
238
289
 
239
- <button
240
- id="save"
241
- class="primary"
242
- >
243
- 保存传输设置
244
- </button>
290
+ <button
291
+ id="save"
292
+ class="primary"
293
+ >
294
+ 保存传输设置
295
+ </button>
296
+ </div>
245
297
  <div id="msg"></div>
246
298
  </div>
247
299
 
@@ -3,6 +3,7 @@ const DEFAULTS = {
3
3
  domains: ['http://localhost/*', 'http://127.0.0.1/*'],
4
4
  transport: 'native',
5
5
  bridgeTarget: '8917',
6
+ appMode: 'rd', // rd(研发,展示传输配置)/ qa(测试,只展示复制复现信息)
6
7
  };
7
8
  const PROTECTED = new Set(DEFAULTS.domains); // 默认项不可删、不回收权限
8
9
 
@@ -136,6 +137,26 @@ $('domain-input').addEventListener('keydown', e => {
136
137
  }
137
138
  });
138
139
 
140
+ // ---------- 设置页模式:RD(研发)/ QA(测试)----------
141
+ // RD:显示传输通路配置;QA:隐藏传输通路(QA 只复制本地上下文,走默认 native 即可),显示复制复现信息。
142
+ async function renderAppMode() {
143
+ const {appMode} = await getConfig();
144
+ const mode = appMode === 'qa' ? 'qa' : 'rd';
145
+ for (const r of document.querySelectorAll('input[name="appmode"]')) {
146
+ r.checked = r.value === mode;
147
+ }
148
+ $('qa-block').style.display = mode === 'qa' ? 'block' : 'none';
149
+ $('transport-block').style.display = mode === 'rd' ? 'block' : 'none';
150
+ }
151
+
152
+ for (const r of document.querySelectorAll('input[name="appmode"]')) {
153
+ r.addEventListener('change', async () => {
154
+ await chrome.storage.local.set({appMode: r.value === 'qa' ? 'qa' : 'rd'});
155
+ await renderAppMode();
156
+ });
157
+ }
158
+ renderAppMode();
159
+
139
160
  renderDomains();
140
161
  renderTransport();
141
162
 
@@ -222,11 +243,74 @@ async function initModes() {
222
243
  }
223
244
 
224
245
  // 没有增强包:只有「选择 Element」一个模式,顶层「模式/设置」tab 失去意义——
225
- // 隐藏顶层 tab 与模式页,直接展示「设置」面板内容。
246
+ // 隐藏顶层 tab 与模式页,直接展示「设置」面板内容(QA 模式在设置页里切换)。
226
247
  if (!plugins.length) {
227
248
  $('top-tabs').style.display = 'none';
228
249
  $('top-mode').style.display = 'none';
229
250
  $('top-settings').style.display = 'block';
230
251
  }
231
252
  }
253
+
254
+ // ---------- QA 模式:把选中元素 + 录制信息复制成上下文文本(设置页 QA 区块)----------
255
+ const qaBtn = $('qa-copy');
256
+ const qaStatus = $('qa-status');
257
+
258
+ function qaSet(text, ok) {
259
+ qaStatus.textContent = text;
260
+ qaStatus.style.color = ok === false ? '#cf222e' : ok ? '#1a7f37' : '#656d76';
261
+ }
262
+
263
+ function fallbackCopy(text) {
264
+ const ta = document.createElement('textarea');
265
+ ta.value = text;
266
+ ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0;';
267
+ document.body.appendChild(ta);
268
+ ta.select();
269
+ let ok = false;
270
+ try {
271
+ ok = document.execCommand('copy');
272
+ } catch {
273
+ ok = false;
274
+ }
275
+ ta.remove();
276
+ return ok;
277
+ }
278
+
279
+ function copyText(text) {
280
+ return new Promise(resolve => {
281
+ if (navigator.clipboard && navigator.clipboard.writeText) {
282
+ navigator.clipboard
283
+ .writeText(text)
284
+ .then(() => resolve(true))
285
+ .catch(() => resolve(fallbackCopy(text)));
286
+ } else {
287
+ resolve(fallbackCopy(text));
288
+ }
289
+ });
290
+ }
291
+
292
+ qaBtn.onclick = () => {
293
+ qaSet('读取中…');
294
+ chrome.runtime.sendMessage({type: 'qa-context'}, resp => {
295
+ if (chrome.runtime.lastError || !resp || !resp.ok) {
296
+ qaSet((resp && resp.error) || '读取失败:确认已装 host/bridge(或远程 bridge 在跑)', false);
297
+ return;
298
+ }
299
+ const c = resp.counts || {};
300
+ const total = (c.elements || 0) + (c.recEvents || 0) + (c.figma || 0);
301
+ if (!total || !resp.text) {
302
+ qaSet('还没有内容:先「录制并停止」,或 Alt 选中元素', false);
303
+ return;
304
+ }
305
+ copyText(resp.text).then(ok => {
306
+ const parts = [
307
+ `${c.elements || 0} 个元素`,
308
+ `${c.recEvents || 0} 条录制事件`,
309
+ c.figma ? `${c.figma} 个 figma 节点` : null,
310
+ ].filter(Boolean);
311
+ qaSet(ok ? `已复制(${parts.join(' + ')}),可直接粘贴给开发/Agent` : '复制失败,请重试', ok);
312
+ });
313
+ });
314
+ };
315
+
232
316
  initModes();
@@ -13,6 +13,7 @@
13
13
  import {readFileSync, writeFileSync, mkdirSync} from 'node:fs';
14
14
  import {dirname, resolve} from 'node:path';
15
15
  import {homedir} from 'node:os';
16
+ import {describeElement, describeRecording, describeFigma} from '../lib/context-text.mjs';
16
17
 
17
18
  // 共享落盘:读用户主目录下的 pending / recording(与 host / bridge / mcp 同一路径),一份代码可服务多个仓库。
18
19
  const storeFile = resolve(homedir(), '.ui-inspector/pending.json');
@@ -77,95 +78,8 @@ if (!list.length && !hasRec && !hasFigma) {
77
78
  emit({decision: 'allow'});
78
79
  }
79
80
 
80
- function describe(target, index, total) {
81
- const files = (target.componentChain || []).map(item => item && item.file).filter(Boolean);
82
- const head = total > 1 ? `【元素 ${index + 1}/${total}】` : '';
83
- return [
84
- head + (files.length ? `组件源码(由近及远): ${files.join(' <- ')}` : '组件源码: 未捕获(不在 Vue 组件树内)'),
85
- ` CSS 选择器: ${target.selector || '(无)'}`,
86
- ` 标签/文本: <${target.tag || '?'}> ${JSON.stringify((target.text || '').slice(0, 120))}`,
87
- target.pageUrl ? ` 页面: ${target.pageUrl}` : '',
88
- ]
89
- .filter(Boolean)
90
- .join('\n');
91
- }
92
-
93
- // 把各类用户事件(点击/滚动/输入/提交/按键/控制台/网络)归并成一条按时间排序的时间线。
94
- function describeRecording(meta, evs) {
95
- const base = meta.startedAt || 0;
96
- const secs = Math.round(((meta.endedAt || 0) - base || 0) / 1000);
97
- const rel = t => (typeof t === 'number' ? `+${((t - base) / 1000).toFixed(2)}s` : '+?');
98
-
99
- const lines = [];
100
- for (const ev of evs) {
101
- const it = ev.item || {};
102
- if (ev.kind === 'click') {
103
- const files = (it.componentChain || []).map(x => x && x.file).filter(Boolean);
104
- lines.push({
105
- ts: it.ts,
106
- text:
107
- `点击 <${it.tag}> ${JSON.stringify((it.text || '').slice(0, 40))} @ ${it.selector}` +
108
- (files.length ? ` | 源码 ${files.join(' <- ')}` : ''),
109
- });
110
- } else if (ev.kind === 'scroll') {
111
- lines.push({ts: it.ts, text: `滚动 ${it.selector} → (${it.x}, ${it.y})`});
112
- } else if (ev.kind === 'change') {
113
- lines.push({
114
- ts: it.ts,
115
- text: `输入/变更 <${it.tag}> @ ${it.selector} = ${JSON.stringify(String(it.value ?? '').slice(0, 60))}`,
116
- });
117
- } else if (ev.kind === 'submit') {
118
- lines.push({ts: it.ts, text: `提交表单 @ ${it.selector}`});
119
- } else if (ev.kind === 'keydown') {
120
- lines.push({ts: it.ts, text: `按键 ${it.key}${it.selector ? ` @ ${it.selector}` : ''}`});
121
- } else if (ev.kind === 'console') {
122
- lines.push({ts: it.ts, text: `控制台[${it.level}] ${String(it.text).slice(0, 300)}`});
123
- } else if (ev.kind === 'network') {
124
- lines.push({ts: it.startTs, text: `网络→发起 ${it.method} ${it.url}`});
125
- lines.push({ts: it.endTs, text: `网络←完成 ${it.method} ${it.url} → ${it.status} (${it.ms}ms)`});
126
- } else {
127
- lines.push({ts: it.ts, text: `${ev.kind} ${JSON.stringify(it).slice(0, 120)}`});
128
- }
129
- }
130
- // V8 的 sort 稳定:同一时刻保持插入序(如网络发起在完成之前)。
131
- lines.sort((a, b) => (a.ts || 0) - (b.ts || 0));
132
-
133
- const parts = [
134
- `【ui-inspector 录制】用户录制了一段操作(页面 ${meta.pageUrl || ''},约 ${secs}s,共 ${lines.length} 个事件,时间为相对录制起点的偏移):`,
135
- ];
136
- lines.slice(0, 160).forEach(e => parts.push(` ${rel(e.ts)} ${e.text}`));
137
- if (lines.length > 160) {
138
- parts.push(` …(其余 ${lines.length - 160} 个事件省略)`);
139
- }
140
- return parts.join('\n');
141
- }
142
-
143
- // figma「带入对话」节点:把每个节点的 F2C HTML 结构铺开,供 Agent 据此生成代码。
144
- function describeFigma(nodes) {
145
- const parts = [
146
- `【ui-inspector Figma】用户在 Figma 选中并带入了 ${nodes.length} 个节点,请据下面的结构(F2C 导出的 HTML)生成对应前端代码:`,
147
- ];
148
- nodes.forEach((entry, i) => {
149
- const n = (entry && entry.node) || {};
150
- const html = String(n.html || '').slice(0, 20000);
151
- parts.push(`—— 节点 ${i + 1}:${n.name || ''}(id ${n.nodeId || '?'},${n.type || ''})——`);
152
- parts.push(html || '(无 HTML)');
153
- if ((n.html || '').length > 20000) {
154
- parts.push('…(结构过长已截断)');
155
- }
156
- const imgs = Array.isArray(n.images) ? n.images.filter(im => im && im.file) : [];
157
- if (imgs.length) {
158
- parts.push(
159
- ` 该节点含 ${imgs.length} 张图片,已导出为本地 PNG(HTML 里的图片是占位路径没有字节,请用下面的真实文件;可用 Read 工具查看图片内容,按「名称」与 HTML 占位 src 对应):`
160
- );
161
- imgs.forEach(im => {
162
- const dim = im.width && im.height ? ` ${im.width}x${im.height}` : '';
163
- parts.push(` - ${im.name || '(未命名)'}(id ${im.id || '?'}${dim}): ${im.file}`);
164
- });
165
- }
166
- });
167
- return parts.join('\n');
168
- }
81
+ // describe 系列(describeElement / describeRecording / describeFigma)统一在 ../lib/context-text.mjs,
82
+ // 与 QA 模式(host/bridge 的 qa-context)共用同一份文案,保证注入与复制输出一致。
169
83
 
170
84
  const sections = [];
171
85
  if (list.length) {
@@ -173,7 +87,7 @@ if (list.length) {
173
87
  sections.push(
174
88
  [
175
89
  `【ui-inspector】用户在页面上选中了 ${total} 个元素,接下来这条消息是针对它们的修改意见:`,
176
- list.map((entry, index) => describe(entry?.target ?? {}, index, total)).join('\n'),
90
+ list.map((entry, index) => describeElement(entry?.target ?? {}, index, total)).join('\n'),
177
91
  ].join('\n')
178
92
  );
179
93
  }
@@ -0,0 +1,164 @@
1
+ /*
2
+ * ui-inspector 上下文格式化(共享):把「选中元素 / 录制时间线 / figma 节点」拼成给 Agent 的文本。
3
+ *
4
+ * 三个消费方共用同一份文案逻辑,保证输出一致:
5
+ * - hooks/inject-ui-inspector.mjs(Comate 注入)
6
+ * - extension/native-host/host.mjs、bridge/server.mjs(QA 模式:测试人员复制上下文)
7
+ * 纯函数,无 IO;本文件不得 import 除 node 内置外的模块。
8
+ */
9
+
10
+ // 单个 DOM 选中元素的描述(与 hook 注入格式一致)。
11
+ export function describeElement(target, index, total) {
12
+ const files = (target.componentChain || []).map(item => item && item.file).filter(Boolean);
13
+ const head = total > 1 ? `【元素 ${index + 1}/${total}】` : '';
14
+ return [
15
+ head + (files.length ? `组件源码(由近及远): ${files.join(' <- ')}` : '组件源码: 未捕获(不在 Vue 组件树内)'),
16
+ ` CSS 选择器: ${target.selector || '(无)'}`,
17
+ ` 标签/文本: <${target.tag || '?'}> ${JSON.stringify((target.text || '').slice(0, 120))}`,
18
+ target.pageUrl ? ` 页面: ${target.pageUrl}` : '',
19
+ ]
20
+ .filter(Boolean)
21
+ .join('\n');
22
+ }
23
+
24
+ // 把各类用户事件(点击/滚动/输入/提交/按键/控制台/网络)归并成一条按时间排序的时间线。
25
+ export function describeRecording(meta, evs) {
26
+ const base = meta.startedAt || 0;
27
+ const secs = Math.round(((meta.endedAt || 0) - base || 0) / 1000);
28
+ const rel = t => (typeof t === 'number' ? `+${((t - base) / 1000).toFixed(2)}s` : '+?');
29
+
30
+ const lines = [];
31
+ for (const ev of evs) {
32
+ const it = ev.item || {};
33
+ if (ev.kind === 'click') {
34
+ const files = (it.componentChain || []).map(x => x && x.file).filter(Boolean);
35
+ lines.push({
36
+ ts: it.ts,
37
+ text:
38
+ `点击 <${it.tag}> ${JSON.stringify((it.text || '').slice(0, 40))} @ ${it.selector}` +
39
+ (files.length ? ` | 源码 ${files.join(' <- ')}` : ''),
40
+ });
41
+ } else if (ev.kind === 'scroll') {
42
+ lines.push({ts: it.ts, text: `滚动 ${it.selector} → (${it.x}, ${it.y})`});
43
+ } else if (ev.kind === 'change') {
44
+ lines.push({
45
+ ts: it.ts,
46
+ text: `输入/变更 <${it.tag}> @ ${it.selector} = ${JSON.stringify(String(it.value ?? '').slice(0, 60))}`,
47
+ });
48
+ } else if (ev.kind === 'submit') {
49
+ lines.push({ts: it.ts, text: `提交表单 @ ${it.selector}`});
50
+ } else if (ev.kind === 'keydown') {
51
+ lines.push({ts: it.ts, text: `按键 ${it.key}${it.selector ? ` @ ${it.selector}` : ''}`});
52
+ } else if (ev.kind === 'console') {
53
+ lines.push({ts: it.ts, text: `控制台[${it.level}] ${String(it.text).slice(0, 300)}`});
54
+ } else if (ev.kind === 'network') {
55
+ lines.push({ts: it.startTs, text: `网络→发起 ${it.method} ${it.url}`});
56
+ // 请求参数/请求体属于「发起」阶段,附在发起事件下(同 ts 保持插入序)。
57
+ if (it.params) {
58
+ lines.push({ts: it.startTs, text: ` 请求参数: ${String(it.params).slice(0, 200)}`});
59
+ }
60
+ if (it.reqBody) {
61
+ const b = typeof it.reqBody === 'string' ? it.reqBody : JSON.stringify(it.reqBody, null, 1);
62
+ lines.push({ts: it.startTs, text: ` 请求体: ${b.slice(0, 400)}${b.length > 400 ? '…' : ''}`});
63
+ }
64
+ const done = `网络←完成 ${it.method} ${it.url} → ${it.status} (${it.ms}ms)`;
65
+ lines.push({ts: it.endTs, text: done});
66
+ // 响应体属于「完成」阶段(采集端已字段级截断,保留结构、只截长值)。
67
+ if (it.resp) {
68
+ const b = typeof it.resp === 'string' ? it.resp : JSON.stringify(it.resp, null, 1);
69
+ lines.push({ts: it.endTs, text: ` 响应体: ${b.slice(0, 600)}${b.length > 600 ? '…' : ''}`});
70
+ }
71
+ } else if (ev.kind === 'dom-add' || ev.kind === 'dom-remove') {
72
+ const files = (it.componentChain || []).map(x => x && x.file).filter(Boolean);
73
+ const count = it.count > 1 ? ` ×${it.count}` : '';
74
+ const sample = it.sample ? ` (示例: ${it.sample})` : '';
75
+ lines.push({
76
+ ts: it.ts,
77
+ text:
78
+ `DOM${ev.kind === 'dom-add' ? '+' : '-'} <${it.tag}> ${it.selector}${count}${sample}` +
79
+ (files.length ? ` (${files[0].split('/').pop()})` : ''),
80
+ });
81
+ } else if (ev.kind === 'dom-vis') {
82
+ const files = (it.componentChain || []).map(x => x && x.file).filter(Boolean);
83
+ lines.push({
84
+ ts: it.ts,
85
+ text: `可见性 ${it.visible ? '显示' : '隐藏'} <${it.tag}> ${it.selector}` + (files.length ? ` (${files[0].split('/').pop()})` : ''),
86
+ });
87
+ } else if (ev.kind === 'dom-text') {
88
+ const files = (it.componentChain || []).map(x => x && x.file).filter(Boolean);
89
+ lines.push({
90
+ ts: it.ts,
91
+ text: `文本变化 <${it.tag}> ${it.selector} = ${JSON.stringify(it.text ?? '')}` + (files.length ? ` (${files[0].split('/').pop()})` : ''),
92
+ });
93
+ } else {
94
+ lines.push({ts: it.ts, text: `${ev.kind} ${JSON.stringify(it).slice(0, 120)}`});
95
+ }
96
+ }
97
+ // V8 的 sort 稳定:同一时刻保持插入序(如网络发起在完成之前)。
98
+ lines.sort((a, b) => (a.ts || 0) - (b.ts || 0));
99
+
100
+ const parts = [
101
+ `【ui-inspector 录制】用户录制了一段操作(页面 ${meta.pageUrl || ''},约 ${secs}s,共 ${lines.length} 个事件,时间为相对录制起点的偏移):`,
102
+ ];
103
+ lines.slice(0, 160).forEach(e => parts.push(` ${rel(e.ts)} ${e.text}`));
104
+ if (lines.length > 160) {
105
+ parts.push(` …(其余 ${lines.length - 160} 个事件省略)`);
106
+ }
107
+ return parts.join('\n');
108
+ }
109
+
110
+ // figma「带入对话」节点:把每个节点的 F2C HTML 结构铺开,供 Agent 据此生成代码。
111
+ export function describeFigma(nodes) {
112
+ const parts = [
113
+ `【ui-inspector Figma】用户在 Figma 选中并带入了 ${nodes.length} 个节点,请据下面的结构(F2C 导出的 HTML)生成对应前端代码:`,
114
+ ];
115
+ nodes.forEach((entry, i) => {
116
+ const n = (entry && entry.node) || {};
117
+ const html = String(n.html || '').slice(0, 20000);
118
+ parts.push(`—— 节点 ${i + 1}:${n.name || ''}(id ${n.nodeId || '?'},${n.type || ''})——`);
119
+ parts.push(html || '(无 HTML)');
120
+ if ((n.html || '').length > 20000) {
121
+ parts.push('…(结构过长已截断)');
122
+ }
123
+ const imgs = Array.isArray(n.images) ? n.images.filter(im => im && im.file) : [];
124
+ if (imgs.length) {
125
+ parts.push(
126
+ ` 该节点含 ${imgs.length} 张图片,已导出为本地 PNG(HTML 里的图片是占位路径没有字节,请用下面的真实文件;可用 Read 工具查看图片内容,按「名称」与 HTML 占位 src 对应):`
127
+ );
128
+ imgs.forEach(im => {
129
+ const dim = im.width && im.height ? ` ${im.width}x${im.height}` : '';
130
+ parts.push(` - ${im.name || '(未命名)'}(id ${im.id || '?'}${dim}): ${im.file}`);
131
+ });
132
+ }
133
+ });
134
+ return parts.join('\n');
135
+ }
136
+
137
+ // 把三份数据拼成上下文 sections(不含 Comate 特有尾部话术,调用方自加)。
138
+ // list = pending.json 的选中元素条目(entry.target);meta/evs = 已停止的录制;figmaNodes = figma-pending.json 条目。
139
+ export function buildContextSections(list, meta, evs, figmaNodes) {
140
+ const sections = [];
141
+ if (Array.isArray(list) && list.length) {
142
+ const total = list.length;
143
+ sections.push(
144
+ [
145
+ `【ui-inspector】用户在页面上选中了 ${total} 个元素:`,
146
+ list.map((entry, index) => describeElement(entry?.target ?? {}, index, total)).join('\n'),
147
+ ].join('\n')
148
+ );
149
+ }
150
+ if (meta && evs && evs.length) {
151
+ sections.push(describeRecording(meta, evs));
152
+ }
153
+ if (Array.isArray(figmaNodes) && figmaNodes.length) {
154
+ sections.push(describeFigma(figmaNodes));
155
+ }
156
+ return sections;
157
+ }
158
+
159
+ // QA 场景的完整文本:sections + 通用说明,测试人员复制后粘给开发/Agent 使用。
160
+ export function buildQaContextText(list, meta, evs, figmaNodes) {
161
+ const sections = buildContextSections(list, meta, evs, figmaNodes);
162
+ sections.push('以上为测试环境采集的复现信息(选中元素 + 操作录制),请据此定位并修复问题。');
163
+ return sections.join('\n\n');
164
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "comate-ui-inspector",
3
- "version": "0.1.6-alpha.4",
3
+ "version": "0.1.6-alpha.5",
4
4
  "description": "按住 Alt 点选页面元素,把「元素 + Vue 组件源码路径」交给 Comate 定位源码修改。浏览器扩展 + Native Messaging host + MCP + hook。",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,6 +14,7 @@
14
14
  "mcp",
15
15
  "bridge",
16
16
  "hooks",
17
+ "lib",
17
18
  "README.md",
18
19
  "!extension/enhancements",
19
20
  "!extension/enhancements.json",