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 +55 -0
- package/enhancements/recorder/pane.html +31 -0
- package/enhancements/recorder/pane.js +13 -0
- package/enhancements/recorder/recorder-main.js +437 -12
- package/extension/background.js +6 -1
- package/extension/bridge-content.js +2 -0
- package/extension/native-host/host.mjs +53 -0
- package/extension/popup.html +92 -40
- package/extension/popup.js +85 -1
- package/hooks/inject-ui-inspector.mjs +4 -90
- package/lib/context-text.mjs +164 -0
- package/package.json +2 -1
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
|
-
|
|
177
|
-
|
|
178
|
-
text:
|
|
179
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/extension/background.js
CHANGED
|
@@ -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
|
|
@@ -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
|
}
|
package/extension/popup.html
CHANGED
|
@@ -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
|
-
|
|
203
|
-
|
|
204
|
-
<
|
|
205
|
-
<
|
|
206
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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><remote-ip>:<port></code>
|
|
285
|
+
)
|
|
286
|
+
</div>
|
|
230
287
|
</div>
|
|
231
|
-
|
|
232
|
-
填开发机 bridge 的完整地址(如
|
|
233
|
-
<code><\remote-ip>:<\port></code>
|
|
234
|
-
)
|
|
235
|
-
</div>
|
|
236
|
-
</div>
|
|
237
|
-
</section>
|
|
288
|
+
</section>
|
|
238
289
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
|
package/extension/popup.js
CHANGED
|
@@ -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
|
-
|
|
81
|
-
|
|
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) =>
|
|
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.
|
|
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",
|