comate-ui-inspector 0.1.6-alpha.3 → 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 +149 -3
- package/cli.mjs +33 -3
- 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 +17 -6
- package/extension/inspector-main.js +17 -1
- package/extension/native-host/host.mjs +53 -0
- package/extension/popup.html +92 -42
- 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
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
* 跑在「Comate agent / hook / MCP 所在的那台机器」(通常是远程开发机),读写和它们同一份
|
|
6
6
|
* ~/.ui-inspector/*.json。浏览器扩展(transport=http)直连本服务。
|
|
7
7
|
*/
|
|
8
|
-
import {createServer} from 'node:http';
|
|
8
|
+
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');
|
|
@@ -19,6 +20,8 @@ const eventsFile = resolve(homedir(), '.ui-inspector/recording-events.ndjson');
|
|
|
19
20
|
const figmaPendingFile = resolve(homedir(), '.ui-inspector/figma-pending.json');
|
|
20
21
|
// figma 节点内导出的图片字节落盘到这里(按采集批次分子目录)。
|
|
21
22
|
const figmaAssetsDir = resolve(homedir(), '.ui-inspector/figma-assets');
|
|
23
|
+
// bridge 实际绑定的端口写这里,供 cli(install --remote)读取并打印真正在用的端口。
|
|
24
|
+
const portFile = resolve(homedir(), '.ui-inspector/bridge.json');
|
|
22
25
|
const PORT = Number(process.env.UI_INSPECTOR_BRIDGE_PORT) || 8917;
|
|
23
26
|
const HOST = process.env.UI_INSPECTOR_BRIDGE_HOST || '127.0.0.1';
|
|
24
27
|
|
|
@@ -184,6 +187,56 @@ function figmaCapture(node) {
|
|
|
184
187
|
writeFileSync(figmaPendingFile, `${JSON.stringify(list, null, 4)}\n`, 'utf8');
|
|
185
188
|
}
|
|
186
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
|
+
|
|
187
240
|
function readBody(req, cb) {
|
|
188
241
|
let body = '';
|
|
189
242
|
req.on('data', chunk => (body += chunk));
|
|
@@ -233,6 +286,10 @@ const server = createServer((req, res) => {
|
|
|
233
286
|
json(res, 200, {ok: true, count: readStore().length, status: readStatus()});
|
|
234
287
|
return;
|
|
235
288
|
}
|
|
289
|
+
if (req.method === 'GET' && url.pathname === '/qa/context') {
|
|
290
|
+
json(res, 200, qaContext());
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
236
293
|
if (req.method === 'POST' && url.pathname === '/rec/start') {
|
|
237
294
|
readBody(req, (data, err) => {
|
|
238
295
|
if (err) {
|
|
@@ -276,6 +333,95 @@ const server = createServer((req, res) => {
|
|
|
276
333
|
json(res, 404, {ok: false, error: 'not found'});
|
|
277
334
|
});
|
|
278
335
|
|
|
279
|
-
|
|
280
|
-
|
|
336
|
+
let currentPort = PORT;
|
|
337
|
+
let triedReuse = false;
|
|
338
|
+
let bound = false;
|
|
339
|
+
const MAX_PORT_TRIES = 20;
|
|
340
|
+
|
|
341
|
+
// 绑定成功:把实际端口写进 bridge.json(cli install --remote 据此打印真正在用的端口)。
|
|
342
|
+
server.on('listening', () => {
|
|
343
|
+
bound = true;
|
|
344
|
+
const addr = server.address();
|
|
345
|
+
const actual = (addr && addr.port) || currentPort;
|
|
346
|
+
try {
|
|
347
|
+
mkdirSync(dirname(portFile), {recursive: true});
|
|
348
|
+
writeFileSync(
|
|
349
|
+
portFile,
|
|
350
|
+
`${JSON.stringify({host: HOST, port: actual, pid: process.pid, ts: Date.now()})}\n`,
|
|
351
|
+
'utf8'
|
|
352
|
+
);
|
|
353
|
+
} catch {
|
|
354
|
+
// 写端口文件失败不影响服务
|
|
355
|
+
}
|
|
356
|
+
process.stdout.write(`ui-inspector bridge listening on http://${HOST}:${actual}\n`);
|
|
281
357
|
});
|
|
358
|
+
|
|
359
|
+
// 顺延到下一个端口;连试 MAX_PORT_TRIES 个都不行才放弃。bound 后幂等,避免重复 listen 崩溃。
|
|
360
|
+
function hopNext() {
|
|
361
|
+
if (bound) {
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
if (currentPort - PORT >= MAX_PORT_TRIES) {
|
|
365
|
+
process.stderr.write(
|
|
366
|
+
`从 ${PORT} 起连续 ${MAX_PORT_TRIES} 个端口都被占用,放弃。手动指定 UI_INSPECTOR_BRIDGE_PORT。\n`
|
|
367
|
+
);
|
|
368
|
+
process.exit(1);
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
currentPort += 1;
|
|
372
|
+
server.listen(currentPort, HOST);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// 端口被占用是常见情况。基准端口被占时先探测是不是已有我们的 bridge:是就复用退出(幂等);
|
|
376
|
+
// 否则自动顺延到下一个可用端口。其它启动错误直接友好报错退出,不抛未捕获异常。
|
|
377
|
+
server.on('error', err => {
|
|
378
|
+
if (!err || err.code !== 'EADDRINUSE') {
|
|
379
|
+
process.stderr.write(`ui-inspector bridge 启动失败:${String(err)}\n`);
|
|
380
|
+
process.exit(1);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (currentPort === PORT && !triedReuse) {
|
|
384
|
+
triedReuse = true;
|
|
385
|
+
const probeHost = HOST === '0.0.0.0' ? '127.0.0.1' : HOST;
|
|
386
|
+
// probe 的 end/error/timeout 可能多次触发(如 destroy 又引发 error),用 settled 保证只处理一次。
|
|
387
|
+
let settled = false;
|
|
388
|
+
const settle = fn => {
|
|
389
|
+
if (settled) {
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
settled = true;
|
|
393
|
+
fn();
|
|
394
|
+
};
|
|
395
|
+
const req = get({host: probeHost, port: PORT, path: '/state', timeout: 1500}, res => {
|
|
396
|
+
let body = '';
|
|
397
|
+
res.on('data', c => (body += c));
|
|
398
|
+
res.on('end', () =>
|
|
399
|
+
settle(() => {
|
|
400
|
+
let ok = false;
|
|
401
|
+
try {
|
|
402
|
+
ok = JSON.parse(body).ok === true;
|
|
403
|
+
} catch {
|
|
404
|
+
ok = false;
|
|
405
|
+
}
|
|
406
|
+
if (ok) {
|
|
407
|
+
process.stdout.write(
|
|
408
|
+
`ui-inspector bridge 已在 http://${HOST}:${PORT} 运行,复用现有实例即可。\n`
|
|
409
|
+
);
|
|
410
|
+
process.exit(0);
|
|
411
|
+
} else {
|
|
412
|
+
hopNext(); // 被非 bridge 占用 → 换端口
|
|
413
|
+
}
|
|
414
|
+
})
|
|
415
|
+
);
|
|
416
|
+
});
|
|
417
|
+
req.on('error', () => settle(hopNext));
|
|
418
|
+
req.on('timeout', () => {
|
|
419
|
+
req.destroy();
|
|
420
|
+
settle(hopNext);
|
|
421
|
+
});
|
|
422
|
+
return;
|
|
423
|
+
}
|
|
424
|
+
hopNext();
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
server.listen(currentPort, HOST);
|
package/cli.mjs
CHANGED
|
@@ -119,6 +119,29 @@ function installNativeHost() {
|
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
// 后台拉起 bridge 并与本进程脱钩,install --remote 用:装完 hook/MCP 顺手把 bridge 跑起来。
|
|
122
|
+
// bridge 可能因端口被占而自动顺延端口,所以不能直接用传入的 port——读它落盘的 bridge.json 拿真正在用的端口。
|
|
123
|
+
function sleepSync(ms) {
|
|
124
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// 轮询 ~/.ui-inspector/bridge.json,拿一条「本次启动之后写入」的记录(ts >= since)。读不到返回 null。
|
|
128
|
+
function readActualBridgePort(since, timeoutMs = 4000) {
|
|
129
|
+
const f = resolve(pendingDir, 'bridge.json');
|
|
130
|
+
const deadline = Date.now() + timeoutMs;
|
|
131
|
+
while (Date.now() < deadline) {
|
|
132
|
+
try {
|
|
133
|
+
const j = JSON.parse(readFileSync(f, 'utf8'));
|
|
134
|
+
if (j && j.port && (j.ts || 0) >= since) {
|
|
135
|
+
return j.port;
|
|
136
|
+
}
|
|
137
|
+
} catch {
|
|
138
|
+
// 文件还没写出来
|
|
139
|
+
}
|
|
140
|
+
sleepSync(150);
|
|
141
|
+
}
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
122
145
|
function startBridgeDetached(port, host) {
|
|
123
146
|
mkdirSync(pendingDir, {recursive: true});
|
|
124
147
|
const logFile = resolve(pendingDir, 'bridge.log');
|
|
@@ -127,14 +150,21 @@ function startBridgeDetached(port, host) {
|
|
|
127
150
|
if (host) {
|
|
128
151
|
env.UI_INSPECTOR_BRIDGE_HOST = String(host);
|
|
129
152
|
}
|
|
153
|
+
const spawnedAt = Date.now();
|
|
130
154
|
const child = spawn(process.execPath, [resolve(pkgRoot, 'bridge/server.mjs')], {
|
|
131
155
|
detached: true,
|
|
132
156
|
stdio: ['ignore', fd, fd],
|
|
133
157
|
env,
|
|
134
158
|
});
|
|
135
159
|
child.unref();
|
|
136
|
-
|
|
160
|
+
// 端口可能被占而自动顺延;读 bridge.json 拿真正在用的端口。复用现有实例时子进程不写新记录,回落到传入 port。
|
|
161
|
+
const actual = readActualBridgePort(spawnedAt) || port;
|
|
162
|
+
out(`bridge 已后台启动(pid ${child.pid},监听 ${host || '127.0.0.1'}:${actual}),日志:${logFile}`);
|
|
163
|
+
if (String(actual) !== String(port)) {
|
|
164
|
+
out(` 注意:端口 ${port} 被占用,已自动改用 ${actual}——扩展弹窗地址请填这个端口。`);
|
|
165
|
+
}
|
|
137
166
|
out(` 停止:kill ${child.pid}`);
|
|
167
|
+
return actual;
|
|
138
168
|
}
|
|
139
169
|
|
|
140
170
|
function install({remote, port, host}) {
|
|
@@ -142,9 +172,9 @@ function install({remote, port, host}) {
|
|
|
142
172
|
if (remote) {
|
|
143
173
|
const bindHost = host || '0.0.0.0';
|
|
144
174
|
out('\n== 远程开发机:跳过 native host/扩展(这台没浏览器),后台启动 bridge ==');
|
|
145
|
-
startBridgeDetached(port, bindHost);
|
|
175
|
+
const actual = startBridgeDetached(port, bindHost);
|
|
146
176
|
out(
|
|
147
|
-
`\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${
|
|
177
|
+
`\n浏览器在扩展弹窗里把传输设为「远程(HTTP bridge)」、地址填「本机可达地址:${actual}」(如 devlocal.oreateai.com:${actual})即可直连。`
|
|
148
178
|
);
|
|
149
179
|
return;
|
|
150
180
|
}
|
|
@@ -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
|
});
|