@taole/dd-playwright 0.1.13 → 0.2.0
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/README.md +7 -2
- package/SKILL.md +7 -7
- package/bin/cli.mjs +10 -3
- package/bin/mcp-server.mjs +9 -13
- package/package.json +1 -1
- package/src/browser-manager.mjs +39 -45
- package/src/server/daemon.mjs +22 -1
- package/src/server/web-server.mjs +24 -96
- package/src/server/web-update.mjs +5 -59
- package/src/updater.mjs +4 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
| 工具 | 说明 |
|
|
10
10
|
|---|---|
|
|
11
|
-
| `browser_navigate` | 访问 URL
|
|
11
|
+
| `browser_navigate` | 访问 URL。每次必传 `identity: { mode: "default" }`、`{ mode: "anonymous" }` 或 `{ mode: "uid", uid: "账号UID" }`;支持 `device` 设备仿真和会话级 `userAgent` |
|
|
12
12
|
| `browser_resize` | 调整当前页面渲染视口(对标 playwright-mcp)。只改 innerWidth/innerHeight,不改 UA/触摸;完整移动仿真请用 `browser_navigate` 的 `device` 参数 |
|
|
13
13
|
| `browser_screenshot` | 页面/元素截图,落盘到数据目录并返回路径 |
|
|
14
14
|
| `browser_get_console_errors` | 收集 console.error 与未捕获异常 |
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
| `browser_load_proxy_rules_dir` | 批量加载项目规则目录(如 `.whistle-rules`),全量注入并激活域名映射与 Mock 规则 |
|
|
25
25
|
| `browser_status` | 查询登录态挂载情况、当前 UID、代理状态、数据目录,排查免登问题先调它 |
|
|
26
26
|
|
|
27
|
+
页面操作复用当前页面及身份;没有可用页面时须先导航。相同身份的导航复用上下文,切换身份会关闭旧页面并重建上下文。
|
|
28
|
+
|
|
27
29
|
## 安装与挂载
|
|
28
30
|
|
|
29
31
|
需要 Node.js >=22.13(使用内置 `node:sqlite`)。在本仓库目录执行:
|
|
@@ -67,6 +69,9 @@ dd-pw statusweb
|
|
|
67
69
|
# 停止后台运行的服务:
|
|
68
70
|
dd-pw stopweb
|
|
69
71
|
|
|
72
|
+
# 查看当前安装的版本:
|
|
73
|
+
dd-pw version
|
|
74
|
+
|
|
70
75
|
# 一键检查并升级到最新版本:
|
|
71
76
|
dd-pw update
|
|
72
77
|
|
|
@@ -74,7 +79,7 @@ dd-pw update
|
|
|
74
79
|
dd-pw serve [--port 12309]
|
|
75
80
|
```
|
|
76
81
|
|
|
77
|
-
1. 启动后在浏览器打开 `http://127.0.0.1:12309`。在「全局运行配置」的「软件更新」中可检查 npm
|
|
82
|
+
1. 启动后在浏览器打开 `http://127.0.0.1:12309`。在「全局运行配置」的「软件更新」中可检查 npm 最新版本;发现新版本时,可在终端执行 `dd-pw update`,更新后在 Agent 或 IDE 中重载 MCP。源码或项目依赖运行时请按对应安装方式手动更新。
|
|
78
83
|
2. 点击右上角 **「+ 添加绑定账号」**,后台会以独立的 Playwright 实例拉起扫码页并渲染二维码。
|
|
79
84
|
3. 使用微信或点点 App 扫码登录,完成后会自动捕获 Cookie 与 `storageState`,提取点点账号 UID、昵称与头像,并落库至 `$BROWSER_MCP_DATA_DIR/runtime.db`。
|
|
80
85
|
4. 支持多账号管理、一键设为当前激活账号或解绑账号。
|
package/SKILL.md
CHANGED
|
@@ -10,9 +10,9 @@ description: >-
|
|
|
10
10
|
本技能必须通过 **`dd-playwright` MCP 服务**提供的 `browser_*` 系列工具执行。
|
|
11
11
|
|
|
12
12
|
## 1. 登录态与身份切换
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
13
|
+
- **每次导航必须显式选择身份**:默认账号使用 `identity: { mode: "default" }`;游客使用 `identity: { mode: "anonymous" }`;指定账号使用 `identity: { mode: "uid", uid: "28689781" }`。缺少身份或参数冲突会报错。
|
|
14
|
+
- **身份隔离**:相同身份导航复用上下文;切换身份会关闭当前页面并重建上下文。游客模式不注入登录态,默认账号模式使用当前激活账号或配置的凭据。
|
|
15
|
+
- **页面交互**:填写、点击、截图等操作复用当前页面和身份,无需重复传身份;没有可用页面时先调用 `browser_navigate`。
|
|
16
16
|
|
|
17
17
|
## 2. 本地开发服务联调(Dev Server 代理映射)
|
|
18
18
|
- 当本地启动了前端开发服务器(如 Vite/Webpack 监听本地端口)时,**严禁修改系统 hosts**。
|
|
@@ -20,7 +20,7 @@ description: >-
|
|
|
20
20
|
- **单域名映射示例**:
|
|
21
21
|
- 本地 dev server 运行在 `127.0.0.1:9527` 时,可映射为自定义域名 `p9527-test.tuwan.com`:
|
|
22
22
|
`browser_set_proxy_rule({ domain: "p9527-test.tuwan.com", port: 9527 })`
|
|
23
|
-
- 随后调用 `browser_navigate({ url: "http://p9527-test.tuwan.com/" })` 访问,进程内隔离 Whistle
|
|
23
|
+
- 随后调用 `browser_navigate({ url: "http://p9527-test.tuwan.com/", identity: { mode: "default" } })` 访问,进程内隔离 Whistle 代理会自动转发流量并按 Cookie 规则携带登录态。
|
|
24
24
|
- **项目规则目录批量加载(推荐)**:
|
|
25
25
|
- 若项目中存在 `.whistle-rules` 等包含多份代理/Mock 规则文件的目录,直接调用 `browser_load_proxy_rules_dir({ rulesDir: "绝对路径" })` 全量注入激活。
|
|
26
26
|
|
|
@@ -40,16 +40,16 @@ description: >-
|
|
|
40
40
|
- **典型示例**:
|
|
41
41
|
```
|
|
42
42
|
// 完整移动端仿真(视口 390x844 + iPhone UA + 触摸),会话级保持
|
|
43
|
-
browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "iPhone 13" })
|
|
43
|
+
browser_navigate({ url: "https://xdd-test.tuwan.com/", identity: { mode: "default" }, device: "iPhone 13" })
|
|
44
44
|
|
|
45
45
|
// 模拟 App 内 WebView:设备仿真 + 自定义 UA(只换 UA,视口/触摸仍按 iPhone 13)
|
|
46
|
-
browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "iPhone 13", userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) TuwanApp/8.0.0" })
|
|
46
|
+
browser_navigate({ url: "https://xdd-test.tuwan.com/", identity: { mode: "default" }, device: "iPhone 13", userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) TuwanApp/8.0.0" })
|
|
47
47
|
|
|
48
48
|
// 只调视口验证响应式断点(不改 UA / 触摸)
|
|
49
49
|
browser_resize({ width: 375, height: 812 })
|
|
50
50
|
|
|
51
51
|
// 恢复桌面模式并清除自定义 UA
|
|
52
|
-
browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "desktop", userAgent: "default" })
|
|
52
|
+
browser_navigate({ url: "https://xdd-test.tuwan.com/", identity: { mode: "default" }, device: "desktop", userAgent: "default" })
|
|
53
53
|
```
|
|
54
54
|
|
|
55
55
|
## 4. 页面交互规范
|
package/bin/cli.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import {
|
|
|
6
6
|
startWebDaemon,
|
|
7
7
|
stopWebDaemon,
|
|
8
8
|
getWebDaemonStatus,
|
|
9
|
+
getStoredWebPort,
|
|
9
10
|
} from "../src/server/daemon.mjs";
|
|
10
|
-
import { handleUpdateCommand } from "../src/updater.mjs";
|
|
11
|
+
import { getLocalVersion, handleUpdateCommand } from "../src/updater.mjs";
|
|
11
12
|
|
|
12
13
|
const args = process.argv.slice(2);
|
|
13
14
|
const command = args[0];
|
|
@@ -21,6 +22,7 @@ dd-playwright (别名: dd-pw) CLI
|
|
|
21
22
|
dd-pw stopweb 停止后台运行的 Web 控制台
|
|
22
23
|
dd-pw statusweb 查看后台 Web 控制台运行状态与端口
|
|
23
24
|
dd-pw serve [--port 12309] 前台阻塞启动 Web 控制台(按 Ctrl+C 退出)
|
|
25
|
+
dd-pw version, --version, -v 查看当前版本
|
|
24
26
|
dd-pw update 检查远端最新版本并自动升级
|
|
25
27
|
dd-pw mcp 以 stdio 模式启动 MCP 浏览器服务
|
|
26
28
|
dd-pw --help, -h 查看帮助信息
|
|
@@ -37,7 +39,9 @@ async function main() {
|
|
|
37
39
|
process.exit(0);
|
|
38
40
|
}
|
|
39
41
|
|
|
40
|
-
if (command === "
|
|
42
|
+
if (command === "version" || command === "--version" || command === "-v") {
|
|
43
|
+
console.log(getLocalVersion());
|
|
44
|
+
} else if (command === "startweb") {
|
|
41
45
|
let port = 12309;
|
|
42
46
|
const portIdx = args.indexOf("--port");
|
|
43
47
|
if (portIdx !== -1 && args[portIdx + 1]) {
|
|
@@ -96,7 +100,10 @@ async function main() {
|
|
|
96
100
|
} else if (command === "statusweb") {
|
|
97
101
|
const config = resolveConfig();
|
|
98
102
|
try {
|
|
99
|
-
const status = await getWebDaemonStatus(
|
|
103
|
+
const status = await getWebDaemonStatus(
|
|
104
|
+
config.webPidFile,
|
|
105
|
+
getStoredWebPort(config.dataDir) || 12309,
|
|
106
|
+
);
|
|
100
107
|
if (status.running) {
|
|
101
108
|
console.log(`
|
|
102
109
|
[dd-playwright] 后台 Web 服务状态:
|
package/bin/mcp-server.mjs
CHANGED
|
@@ -11,7 +11,7 @@ import { resolveConfig } from "../src/config.mjs";
|
|
|
11
11
|
import { PlaywrightBrowserManager } from "../src/browser-manager.mjs";
|
|
12
12
|
import { pathToFileURL } from "node:url";
|
|
13
13
|
|
|
14
|
-
export const MCP_VERSION = "0.1.
|
|
14
|
+
export const MCP_VERSION = "0.1.14";
|
|
15
15
|
|
|
16
16
|
function textResult(payload) {
|
|
17
17
|
return {
|
|
@@ -37,20 +37,16 @@ export function createBrowserMcpServer({ manager, config }) {
|
|
|
37
37
|
{
|
|
38
38
|
description:
|
|
39
39
|
"使用内置 Playwright 访问目标 URL。" +
|
|
40
|
-
"
|
|
41
|
-
"若传 anonymous: true 则以完全未登录的游客身份访问;" +
|
|
40
|
+
"必须通过 identity 指定匿名、默认账号或指定 UID 的身份;" +
|
|
42
41
|
"移动端/H5 验收可传 device 启用完整设备仿真(视口+UA+触摸),也可单独传 userAgent 自定义 UA。" +
|
|
43
42
|
"返回 HTTP 状态码、页面标题与控制台报错统计。",
|
|
44
43
|
inputSchema: {
|
|
45
44
|
url: z.string().describe("需要访问的目标页面 URL"),
|
|
46
|
-
|
|
47
|
-
.
|
|
48
|
-
.
|
|
49
|
-
.
|
|
50
|
-
anonymous
|
|
51
|
-
.boolean()
|
|
52
|
-
.optional()
|
|
53
|
-
.describe("是否以匿名/未登录状态访问(默认 false)。若为 true,则不注入任何 Cookie/storageState"),
|
|
45
|
+
identity: z.discriminatedUnion("mode", [
|
|
46
|
+
z.strictObject({ mode: z.literal("anonymous") }),
|
|
47
|
+
z.strictObject({ mode: z.literal("default") }),
|
|
48
|
+
z.strictObject({ mode: z.literal("uid"), uid: z.string().min(1).refine((uid) => uid.trim().length > 0) }),
|
|
49
|
+
]).describe("必填身份:anonymous、default 或 uid(需提供非空 uid)"),
|
|
54
50
|
device: z
|
|
55
51
|
.string()
|
|
56
52
|
.optional()
|
|
@@ -78,10 +74,10 @@ export function createBrowserMcpServer({ manager, config }) {
|
|
|
78
74
|
.describe("导航与等待超时时间(毫秒),默认 15000"),
|
|
79
75
|
},
|
|
80
76
|
},
|
|
81
|
-
async ({ url,
|
|
77
|
+
async ({ url, identity, device, userAgent, waitFor, timeout }) => {
|
|
82
78
|
try {
|
|
83
79
|
return textResult(
|
|
84
|
-
await manager.navigate({ url,
|
|
80
|
+
await manager.navigate({ url, identity, device, userAgent, waitFor, timeout }),
|
|
85
81
|
);
|
|
86
82
|
} catch (err) {
|
|
87
83
|
return errorResult("导航失败", err);
|
package/package.json
CHANGED
package/src/browser-manager.mjs
CHANGED
|
@@ -137,8 +137,7 @@ export class PlaywrightBrowserManager {
|
|
|
137
137
|
storageState !== undefined
|
|
138
138
|
? storageState
|
|
139
139
|
: resolveStorageState({ dbPath, storageStateFile });
|
|
140
|
-
this.
|
|
141
|
-
this.isAnonymous = false;
|
|
140
|
+
this.identity = null;
|
|
142
141
|
this.device = null; // 当前设备描述符名;null 表示桌面模式
|
|
143
142
|
this.sessionUserAgent = null; // 会话级 UA(工具参数设置);null 表示回落到 this.userAgent / 设备描述符 / 默认
|
|
144
143
|
this.browser = null;
|
|
@@ -185,14 +184,17 @@ export class PlaywrightBrowserManager {
|
|
|
185
184
|
|
|
186
185
|
|
|
187
186
|
/**
|
|
188
|
-
*
|
|
189
|
-
* @param {{
|
|
190
|
-
* device 不传则保持当前模式;传 "desktop"/null 恢复桌面;传 Playwright 设备名启用移动端仿真。
|
|
191
|
-
* userAgent 不传则保持当前会话级 UA;传 "default"/null 清除会话级 UA(回落到启动配置/设备描述符/默认 UA)
|
|
187
|
+
* 根据身份、设备描述符与会话级 UA 准备浏览器上下文。
|
|
188
|
+
* @param {{identity: {mode: 'anonymous'|'default'|'uid', uid?: string}, device?: string|null, userAgent?: string|null}} options
|
|
192
189
|
*/
|
|
193
|
-
async ensurePage({
|
|
194
|
-
|
|
195
|
-
|
|
190
|
+
async ensurePage({ identity, device, userAgent } = {}) {
|
|
191
|
+
if (!identity || typeof identity !== "object" || Array.isArray(identity) ||
|
|
192
|
+
!["anonymous", "default", "uid"].includes(identity.mode) ||
|
|
193
|
+
Object.keys(identity).some((key) => key !== "mode" && key !== "uid") ||
|
|
194
|
+
(identity.mode === "uid" ? typeof identity.uid !== "string" || !identity.uid.trim() : Object.hasOwn(identity, "uid"))) {
|
|
195
|
+
throw new Error("identity 必须指定 anonymous、default 或带非空 uid 的 uid 模式");
|
|
196
|
+
}
|
|
197
|
+
const targetUid = identity.mode === "uid" ? identity.uid : null;
|
|
196
198
|
|
|
197
199
|
// 先解析并校验设备(非法设备名必须在关闭现有上下文之前抛错,避免误毁会话)
|
|
198
200
|
let targetDevice = this.device;
|
|
@@ -211,11 +213,15 @@ export class PlaywrightBrowserManager {
|
|
|
211
213
|
}
|
|
212
214
|
const uaChanged = targetSessionUA !== this.sessionUserAgent;
|
|
213
215
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
(
|
|
216
|
+
const authChanged = !this.identity || this.identity.mode !== identity.mode ||
|
|
217
|
+
(identity.mode === "uid" && this.identity.uid !== targetUid);
|
|
218
|
+
let state;
|
|
219
|
+
if (authChanged || deviceChanged || uaChanged || !this.context) {
|
|
220
|
+
if (identity.mode === "anonymous") state = null;
|
|
221
|
+
else if (identity.mode === "uid") state = resolveStorageState({ dbPath: this.dbPath, uid: targetUid });
|
|
222
|
+
else if (this.customStorageState !== undefined) state = this.customStorageState;
|
|
223
|
+
else state = resolveStorageState({ dbPath: this.dbPath, storageStateFile: this.storageStateFile });
|
|
224
|
+
}
|
|
219
225
|
|
|
220
226
|
if ((authChanged || deviceChanged || uaChanged) && this.context) {
|
|
221
227
|
if (this.page && !this.page.isClosed()) {
|
|
@@ -236,26 +242,8 @@ export class PlaywrightBrowserManager {
|
|
|
236
242
|
}
|
|
237
243
|
|
|
238
244
|
if (!this.context) {
|
|
239
|
-
let state = null;
|
|
240
|
-
if (targetAnonymous) {
|
|
241
|
-
state = null;
|
|
242
|
-
} else if (targetUid) {
|
|
243
|
-
state = resolveStorageState({
|
|
244
|
-
dbPath: this.dbPath,
|
|
245
|
-
uid: targetUid,
|
|
246
|
-
});
|
|
247
|
-
} else if (this.customStorageState !== undefined) {
|
|
248
|
-
state = this.customStorageState;
|
|
249
|
-
} else {
|
|
250
|
-
state = resolveStorageState({
|
|
251
|
-
dbPath: this.dbPath,
|
|
252
|
-
storageStateFile: this.storageStateFile,
|
|
253
|
-
});
|
|
254
|
-
}
|
|
255
|
-
|
|
256
245
|
this.storageState = state;
|
|
257
|
-
this.
|
|
258
|
-
this.isAnonymous = targetAnonymous;
|
|
246
|
+
this.identity = targetUid === null ? { mode: identity.mode } : { mode: "uid", uid: targetUid };
|
|
259
247
|
this.device = targetDevice;
|
|
260
248
|
this.sessionUserAgent = targetSessionUA;
|
|
261
249
|
|
|
@@ -303,8 +291,8 @@ export class PlaywrightBrowserManager {
|
|
|
303
291
|
return this.page;
|
|
304
292
|
}
|
|
305
293
|
|
|
306
|
-
async navigate({ url, waitFor, timeout = 15000,
|
|
307
|
-
const page = await this.ensurePage({
|
|
294
|
+
async navigate({ url, waitFor, timeout = 15000, identity, device, userAgent }) {
|
|
295
|
+
const page = await this.ensurePage({ identity, device, userAgent });
|
|
308
296
|
const response = await page.goto(url, {
|
|
309
297
|
timeout,
|
|
310
298
|
waitUntil: "domcontentloaded",
|
|
@@ -325,8 +313,13 @@ export class PlaywrightBrowserManager {
|
|
|
325
313
|
};
|
|
326
314
|
}
|
|
327
315
|
|
|
316
|
+
currentPage() {
|
|
317
|
+
if (!this.page || this.page.isClosed()) throw new Error("没有当前页面,请先使用 browser_navigate");
|
|
318
|
+
return this.page;
|
|
319
|
+
}
|
|
320
|
+
|
|
328
321
|
async screenshot({ selector, fullPage = false } = {}) {
|
|
329
|
-
const page =
|
|
322
|
+
const page = this.currentPage();
|
|
330
323
|
const dir = this.screenshotsDir;
|
|
331
324
|
mkdirSync(dir, { recursive: true });
|
|
332
325
|
const filename = `screenshot-${Date.now()}-${Math.random()
|
|
@@ -349,6 +342,7 @@ export class PlaywrightBrowserManager {
|
|
|
349
342
|
}
|
|
350
343
|
|
|
351
344
|
getConsoleErrors() {
|
|
345
|
+
this.currentPage();
|
|
352
346
|
return {
|
|
353
347
|
total: this.consoleErrors.length,
|
|
354
348
|
errors: [...this.consoleErrors],
|
|
@@ -356,7 +350,7 @@ export class PlaywrightBrowserManager {
|
|
|
356
350
|
}
|
|
357
351
|
|
|
358
352
|
async clickElement({ selector, timeout = 10000 }) {
|
|
359
|
-
const page =
|
|
353
|
+
const page = this.currentPage();
|
|
360
354
|
const locator = page.locator(selector).first();
|
|
361
355
|
await locator.click({ timeout });
|
|
362
356
|
return {
|
|
@@ -366,7 +360,7 @@ export class PlaywrightBrowserManager {
|
|
|
366
360
|
};
|
|
367
361
|
}
|
|
368
362
|
async fillText({ selector, text, timeout = 10000 }) {
|
|
369
|
-
const page =
|
|
363
|
+
const page = this.currentPage();
|
|
370
364
|
const locator = page.locator(selector).first();
|
|
371
365
|
await locator.fill(String(text), { timeout });
|
|
372
366
|
return {
|
|
@@ -378,7 +372,7 @@ export class PlaywrightBrowserManager {
|
|
|
378
372
|
}
|
|
379
373
|
|
|
380
374
|
async pressKey({ key, selector, timeout = 10000 }) {
|
|
381
|
-
const page =
|
|
375
|
+
const page = this.currentPage();
|
|
382
376
|
if (selector) {
|
|
383
377
|
const locator = page.locator(selector).first();
|
|
384
378
|
await locator.press(key, { timeout });
|
|
@@ -394,7 +388,7 @@ export class PlaywrightBrowserManager {
|
|
|
394
388
|
}
|
|
395
389
|
|
|
396
390
|
async hover({ selector, timeout = 10000 }) {
|
|
397
|
-
const page =
|
|
391
|
+
const page = this.currentPage();
|
|
398
392
|
const locator = page.locator(selector).first();
|
|
399
393
|
await locator.hover({ timeout });
|
|
400
394
|
return {
|
|
@@ -405,7 +399,7 @@ export class PlaywrightBrowserManager {
|
|
|
405
399
|
}
|
|
406
400
|
|
|
407
401
|
async scroll({ direction = "down", deltaY, selector } = {}) {
|
|
408
|
-
const page =
|
|
402
|
+
const page = this.currentPage();
|
|
409
403
|
if (selector) {
|
|
410
404
|
const locator = page.locator(selector).first();
|
|
411
405
|
await locator.scrollIntoViewIfNeeded();
|
|
@@ -432,7 +426,7 @@ export class PlaywrightBrowserManager {
|
|
|
432
426
|
}
|
|
433
427
|
|
|
434
428
|
async waitFor({ selector, state = "visible", timeout = 15000 }) {
|
|
435
|
-
const page =
|
|
429
|
+
const page = this.currentPage();
|
|
436
430
|
if (selector) {
|
|
437
431
|
await page.waitForSelector(selector, { state, timeout });
|
|
438
432
|
return {
|
|
@@ -451,7 +445,7 @@ export class PlaywrightBrowserManager {
|
|
|
451
445
|
}
|
|
452
446
|
|
|
453
447
|
async getDom({ interactiveOnly = true, maxDepth = 10 } = {}) {
|
|
454
|
-
const page =
|
|
448
|
+
const page = this.currentPage();
|
|
455
449
|
const snapshot = await page.evaluate(
|
|
456
450
|
({ onlyInteractive, maxD }) => {
|
|
457
451
|
function isVisible(el) {
|
|
@@ -554,7 +548,7 @@ export class PlaywrightBrowserManager {
|
|
|
554
548
|
* @param {{width: number, height: number}} params
|
|
555
549
|
*/
|
|
556
550
|
async resize({ width, height }) {
|
|
557
|
-
const page =
|
|
551
|
+
const page = this.currentPage();
|
|
558
552
|
const viewport = { width: Number(width), height: Number(height) };
|
|
559
553
|
await page.setViewportSize(viewport);
|
|
560
554
|
return {
|
|
@@ -565,7 +559,7 @@ export class PlaywrightBrowserManager {
|
|
|
565
559
|
}
|
|
566
560
|
|
|
567
561
|
async evalJs({ script }) {
|
|
568
|
-
const page =
|
|
562
|
+
const page = this.currentPage();
|
|
569
563
|
const result = await page.evaluate((code) => {
|
|
570
564
|
// eslint-disable-next-line no-eval
|
|
571
565
|
return window.eval(code);
|
package/src/server/daemon.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, openSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -110,6 +110,10 @@ export async function startWebDaemon({ port, dataDir, pidFile, logFile }) {
|
|
|
110
110
|
|
|
111
111
|
const pid = child.pid;
|
|
112
112
|
writeFileSync(pidFile, String(pid), "utf8");
|
|
113
|
+
// 记录监听端口,供 update 重启时恢复原端口(避免回落到默认 12309)
|
|
114
|
+
try {
|
|
115
|
+
writeFileSync(join(dataDir, "web.port"), String(port), "utf8");
|
|
116
|
+
} catch {}
|
|
113
117
|
child.unref();
|
|
114
118
|
|
|
115
119
|
// 等待最多 5 秒检测端口服务是否就绪
|
|
@@ -135,6 +139,21 @@ export async function startWebDaemon({ port, dataDir, pidFile, logFile }) {
|
|
|
135
139
|
};
|
|
136
140
|
}
|
|
137
141
|
|
|
142
|
+
/**
|
|
143
|
+
* 读取后台 Web 服务启动时记录的监听端口
|
|
144
|
+
* @param {string} dataDir
|
|
145
|
+
* @returns {number|null} 端口;未记录或非法时返回 null(调用方回落默认端口)
|
|
146
|
+
*/
|
|
147
|
+
export function getStoredWebPort(dataDir) {
|
|
148
|
+
try {
|
|
149
|
+
const raw = readFileSync(join(dataDir, "web.port"), "utf8").trim();
|
|
150
|
+
const port = parseInt(raw, 10);
|
|
151
|
+
return Number.isInteger(port) && port >= 1 && port <= 65535 ? port : null;
|
|
152
|
+
} catch {
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
138
157
|
/**
|
|
139
158
|
* 停止后台常驻 Web 服务
|
|
140
159
|
* @param {string} pidFile
|
|
@@ -151,6 +170,8 @@ export async function stopWebDaemon(pidFile) {
|
|
|
151
170
|
if (existsSync(pidFile)) {
|
|
152
171
|
unlinkSync(pidFile);
|
|
153
172
|
}
|
|
173
|
+
// 同步清理端口记录;update 场景需先读取端口再调用本函数
|
|
174
|
+
unlinkSync(join(dirname(pidFile), "web.port"));
|
|
154
175
|
} catch {}
|
|
155
176
|
|
|
156
177
|
return { stopped: true, pid };
|
|
@@ -337,18 +337,7 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
337
337
|
</button>
|
|
338
338
|
</div>
|
|
339
339
|
|
|
340
|
-
<!-- 动态运行环境提示卡片 -->
|
|
341
|
-
<div id="envBanner" style="padding: 12px 16px; border-radius: 6px; margin-bottom: 20px; font-size: 13px; line-height: 1.5; {{BANNER_STYLE}}">
|
|
342
|
-
{{BANNER_CONTENT}}
|
|
343
|
-
</div>
|
|
344
|
-
<!-- 配置建议区块 -->
|
|
345
340
|
{{CONFIG_SECTIONS}}
|
|
346
|
-
|
|
347
|
-
<div style="font-size: 12px; color: var(--muted); line-height: 1.6; background: #0f172a; padding: 12px; border-radius: 6px; border: 1px solid var(--border); margin-top: 20px;">
|
|
348
|
-
💡 <b>配置生效小提示:</b><br>
|
|
349
|
-
1. 挂载后启动 AI 客户端,AI 会自动识别 <code>browser_navigate</code>、<code>browser_screenshot</code> 等 13 个完整 Playwright 工具。<br>
|
|
350
|
-
2. 在本页面扫码绑定的账号,MCP 会自动读取同一个 SQLite 账号库免登运行。
|
|
351
|
-
</div>
|
|
352
341
|
</div>
|
|
353
342
|
</div>
|
|
354
343
|
|
|
@@ -358,9 +347,9 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
358
347
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
359
348
|
<div class="card-title" style="margin-bottom: 0;">Skill 规范说明 (SKILL.md)</div>
|
|
360
349
|
<div style="display: flex; gap: 8px;">
|
|
361
|
-
<a href="/api/skill/download" download="SKILL.
|
|
350
|
+
<a href="/api/skill/download" download="DD-PLAYWRIGHT-SKILL.MD" class="btn" style="background: #334155; color: white; display: flex; align-items: center; gap: 6px; font-weight: 500; padding: 7px 12px; text-decoration: none;">
|
|
362
351
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
|
|
363
|
-
下载 SKILL.
|
|
352
|
+
下载 DD-PLAYWRIGHT-SKILL.MD
|
|
364
353
|
</a>
|
|
365
354
|
<button class="btn" onclick="installSkillToCursor()" style="background: #0284c7; color: white; display: flex; align-items: center; gap: 7px; font-weight: 600; padding: 7px 14px;">
|
|
366
355
|
<svg width="14" height="16" viewBox="0 0 467 533" fill="currentColor"><path d="M457.43,125.94L244.42,2.96c-6.84-3.95-15.28-3.95-22.12,0L9.3,125.94c-5.75,3.32-9.3,9.46-9.3,16.11v247.99c0,6.65,3.55,12.79,9.3,16.11l213.01,122.98c6.84,3.95,15.28,3.95,22.12,0l213.01-122.98c5.75-3.32,9.3-9.46,9.3-16.11v-247.99c0-6.65-3.55-12.79-9.3-16.11h-.01ZM444.05,151.99l-205.63,356.16c-1.39,2.4-5.06,1.42-5.06-1.36v-233.21c0-4.66-2.49-8.97-6.53-11.31L24.87,145.67c-2.4-1.39-1.42-5.06,1.36-5.06h411.26c5.84,0,9.49,6.33,6.57,11.39h-.01Z"/></svg>
|
|
@@ -379,8 +368,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
379
368
|
|
|
380
369
|
<div style="background: #0f172a; border: 1px solid var(--border); border-radius: 6px; padding: 14px; margin-top: 10px; font-size: 13px; line-height: 1.8; color: #cbd5e1;">
|
|
381
370
|
<b>1. 登录态与身份切换:</b><br>
|
|
382
|
-
•
|
|
383
|
-
•
|
|
371
|
+
• 每次导航必须通过 <code>identity</code> 选择身份:默认账号 <code>{ mode: "default" }</code>、游客 <code>{ mode: "anonymous" }</code> 或指定账号 <code>{ mode: "uid", uid: "账号UID" }</code>。<br>
|
|
372
|
+
• 切换身份会重建上下文;页面交互复用当前页面与身份。<br><br>
|
|
384
373
|
<b>2. 本地开发服务联调(Dev Server 代理映射):</b><br>
|
|
385
374
|
• 本地服务启动后严禁改动系统 hosts,可通过单域名映射或项目规则目录全量注入:<br>
|
|
386
375
|
• <b>单条映射</b>:<code>browser_set_proxy_rule({ domain: "p9527-test.tuwan.com", port: 9527 })</code><br>
|
|
@@ -410,8 +399,8 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
410
399
|
<div class="code-block" style="color: #93c5fd; font-size: 12px; margin-bottom: 12px;">// 方式 A:单域名映射
|
|
411
400
|
1. 本地 Vite 启动在 127.0.0.1:9527
|
|
412
401
|
2. 映射代理域名:browser_set_proxy_rule({ domain: "p9527-test.tuwan.com", port: 9527 })
|
|
413
|
-
3. 免登访问页面:browser_navigate({ url: "http://p9527-test.tuwan.com/" })
|
|
414
|
-
->
|
|
402
|
+
3. 免登访问页面:browser_navigate({ url: "http://p9527-test.tuwan.com/", identity: { mode: "default" } })
|
|
403
|
+
-> 按浏览器 Cookie 规则携带登录态,无需改动系统 hosts!
|
|
415
404
|
|
|
416
405
|
// 方式 B:项目规则目录批量导入(推荐)
|
|
417
406
|
1. 项目中已存在 .whistle-rules 目录(含域名映射、接口 Mock 等多份规则)
|
|
@@ -420,13 +409,13 @@ const DASHBOARD_HTML = `<!DOCTYPE html>
|
|
|
420
409
|
|
|
421
410
|
<div class="code-title"><span>流程 2:游客拦截 vs 正常登录态对比测试</span></div>
|
|
422
411
|
<div class="code-block" style="color: #93c5fd; font-size: 12px; margin-bottom: 12px;">// 1. 游客态验证(验证未登录拦截弹窗/导流流程)
|
|
423
|
-
browser_navigate({ url: "https://y.tuwan.com/activity/...",
|
|
412
|
+
browser_navigate({ url: "https://y.tuwan.com/activity/...", identity: { mode: "anonymous" } })
|
|
424
413
|
|
|
425
414
|
// 2. 默认登录态验证(自动携带默认账号正常参与活动)
|
|
426
|
-
browser_navigate({ url: "https://y.tuwan.com/activity/..." })
|
|
415
|
+
browser_navigate({ url: "https://y.tuwan.com/activity/...", identity: { mode: "default" } })
|
|
427
416
|
|
|
428
417
|
// 3. 指定测试账号(切换特定身份排查权限或对账)
|
|
429
|
-
browser_navigate({ url: "https://y.tuwan.com/activity/...", uid: "28689781" })</div>
|
|
418
|
+
browser_navigate({ url: "https://y.tuwan.com/activity/...", identity: { mode: "uid", uid: "28689781" } })</div>
|
|
430
419
|
|
|
431
420
|
<div class="code-title"><span>流程 3:交互排查与元素定位</span></div>
|
|
432
421
|
<div class="code-block" style="color: #93c5fd; font-size: 12px; margin-bottom: 0;">// 1. 获取页面结构化交互元素,杜绝盲猜选择器
|
|
@@ -442,16 +431,16 @@ const errors = browser_get_console_errors()</div>
|
|
|
442
431
|
|
|
443
432
|
<div class="code-title"><span>流程 4:移动端 / H5 仿真验收(设备 · UA · 视口)</span></div>
|
|
444
433
|
<div class="code-block" style="color: #93c5fd; font-size: 12px; margin-bottom: 0;">// 1. 完整移动端仿真:视口 390x844 + iPhone UA + 触摸,会话级保持
|
|
445
|
-
browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "iPhone 13" })
|
|
434
|
+
browser_navigate({ url: "https://xdd-test.tuwan.com/", identity: { mode: "default" }, device: "iPhone 13" })
|
|
446
435
|
|
|
447
436
|
// 2. 模拟 App 内 WebView:设备仿真 + 自定义 UA(只换 UA,视口/触摸仍按 iPhone 13)
|
|
448
|
-
browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "iPhone 13", userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) TuwanApp/8.0.0" })
|
|
437
|
+
browser_navigate({ url: "https://xdd-test.tuwan.com/", identity: { mode: "default" }, device: "iPhone 13", userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) TuwanApp/8.0.0" })
|
|
449
438
|
|
|
450
439
|
// 3. 只调视口验证响应式断点(不改 UA / 触摸)
|
|
451
440
|
browser_resize({ width: 375, height: 812 })
|
|
452
441
|
|
|
453
442
|
// 4. 恢复桌面模式并清除自定义 UA
|
|
454
|
-
browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "desktop", userAgent: "default" })</div>
|
|
443
|
+
browser_navigate({ url: "https://xdd-test.tuwan.com/", identity: { mode: "default" }, device: "desktop", userAgent: "default" })</div>
|
|
455
444
|
</div>
|
|
456
445
|
</div>
|
|
457
446
|
</div>
|
|
@@ -474,17 +463,10 @@ browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "desktop", userAg
|
|
|
474
463
|
<span id="headlessKnob" style="position: absolute; height: 20px; width: 20px; left: 3px; bottom: 3px; background-color: white; transition: .2s; border-radius: 50%;"></span>
|
|
475
464
|
</label>
|
|
476
465
|
</div>
|
|
477
|
-
<div style="margin-top: 16px; font-size: 12px; color: var(--muted); line-height: 1.6; padding: 12px; background: #090d16; border-radius: 6px; border: 1px solid var(--border);">
|
|
478
|
-
📁 <b>数据存储说明:</b><br>
|
|
479
|
-
本服务所有数据与账号库已默认固化在用户主目录下的 <code>~/.dd-browser-mcp/runtime.db</code>,无需配置任何复杂环境变量,开箱即用。
|
|
480
|
-
</div>
|
|
481
|
-
|
|
482
466
|
<div style="margin-top: 20px; padding: 16px; background: #0f172a; border-radius: 8px; border: 1px solid var(--border);">
|
|
483
467
|
<div style="font-size: 14px; font-weight: 600; margin-bottom: 8px;">软件更新</div>
|
|
484
|
-
<div id="updateInfo" style="font-size: 12px; color: var(--muted); line-height: 1.8; margin-bottom: 12px;" aria-live="polite"
|
|
468
|
+
<div id="updateInfo" style="font-size: 12px; color: var(--muted); line-height: 1.8; margin-bottom: 12px;" aria-live="polite">点击检查更新,查看是否有新版本。</div>
|
|
485
469
|
<button id="checkUpdateBtn" class="btn btn-primary" onclick="checkUpdates()">检查更新</button>
|
|
486
|
-
<button id="installUpdateBtn" class="btn btn-primary" onclick="installUpdate()" style="display:none; margin-left: 8px;">立即更新</button>
|
|
487
|
-
<div style="font-size: 12px; color: var(--muted); margin-top: 10px;">更新仅针对全局安装的 npm 包。更新完成后需重启 Web 服务及 MCP 客户端;源码运行请手动更新。</div>
|
|
488
470
|
</div>
|
|
489
471
|
|
|
490
472
|
<!-- Chromium 浏览器内核状态展示卡片 -->
|
|
@@ -710,7 +692,7 @@ browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "desktop", userAg
|
|
|
710
692
|
'---',
|
|
711
693
|
'# dd-playwright 自动化指南',
|
|
712
694
|
'本技能必须通过 dd-playwright MCP 服务提供的 browser_* 系列工具执行:',
|
|
713
|
-
'1.
|
|
695
|
+
'1. 登录态与身份切换:每次 browser_navigate 必须传 identity:默认账号 { mode: "default" },游客 { mode: "anonymous" },指定账号 { mode: "uid", uid: "账号UID" }。页面操作复用当前身份。',
|
|
714
696
|
'2. 本地 Dev Server 联调:严禁改动系统 hosts。支持 browser_set_proxy_rule 单条映射,或 browser_load_proxy_rules_dir 批量加载 .whistle-rules 目录(语法如:域名映射 p9527-test.tuwan.com http://127.0.0.1:9527、接口 Mock yapi.tuwan.com/act resBody://{mock.json}、官方文档 https://wproxy.org/whistle/rules/)。',
|
|
715
697
|
'3. 页面交互规范:定位困难时优先调用 browser_get_dom 提取可见元素结构,严禁盲猜选择器;表单填写使用 browser_fill_text,按键使用 browser_press_key。'
|
|
716
698
|
].join('\\n');
|
|
@@ -786,63 +768,31 @@ browser_navigate({ url: "https://xdd-test.tuwan.com/", device: "desktop", userAg
|
|
|
786
768
|
async function checkUpdates() {
|
|
787
769
|
const info = document.getElementById('updateInfo');
|
|
788
770
|
const check = document.getElementById('checkUpdateBtn');
|
|
789
|
-
const install = document.getElementById('installUpdateBtn');
|
|
790
771
|
check.disabled = true;
|
|
791
|
-
install.style.display = 'none';
|
|
792
772
|
info.textContent = '正在查询 npm 最新版本…';
|
|
793
773
|
try {
|
|
794
774
|
const response = await fetch('/api/update/check', { cache: 'no-store' });
|
|
795
775
|
const data = await response.json();
|
|
796
776
|
if (!data.ok) throw new Error(data.error || data.message || '检查失败');
|
|
797
|
-
|
|
798
|
-
|
|
777
|
+
if (!data.updateAvailable) {
|
|
778
|
+
info.textContent = '当前已是最新版本(v' + data.currentVersion + ')。';
|
|
779
|
+
} else {
|
|
780
|
+
info.textContent = '发现新版本:v' + data.currentVersion + ' → v' + data.latestVersion + '。请在终端执行 dd-pw update,更新后在 Agent 或 IDE 中重载 MCP。';
|
|
781
|
+
}
|
|
799
782
|
} catch (err) {
|
|
800
783
|
info.textContent = '检查更新失败:' + err.message;
|
|
801
784
|
} finally { check.disabled = false; }
|
|
802
785
|
}
|
|
803
786
|
|
|
804
|
-
async function installUpdate() {
|
|
805
|
-
if (!confirm('确认更新全局安装的 dd-playwright?完成后需要重启 Web 服务和 MCP 客户端。')) return;
|
|
806
|
-
const info = document.getElementById('updateInfo');
|
|
807
|
-
const install = document.getElementById('installUpdateBtn');
|
|
808
|
-
const check = document.getElementById('checkUpdateBtn');
|
|
809
|
-
install.disabled = true;
|
|
810
|
-
check.disabled = true;
|
|
811
|
-
try {
|
|
812
|
-
const response = await fetch('/api/update', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
|
|
813
|
-
const data = await response.json();
|
|
814
|
-
if (!data.ok) throw new Error(data.error || data.message || '无法启动更新');
|
|
815
|
-
info.textContent = data.message || '正在更新,请稍候…';
|
|
816
|
-
const poll = async () => {
|
|
817
|
-
try {
|
|
818
|
-
const state = await (await fetch('/api/update/status', { cache: 'no-store' })).json();
|
|
819
|
-
if (!state.ok) throw new Error(state.error || '查询更新状态失败');
|
|
820
|
-
info.textContent = (state.message || '正在更新…') + (state.error ? ':' + state.error : '');
|
|
821
|
-
if (state.status === 'running') { setTimeout(poll, 1500); return; }
|
|
822
|
-
if (state.status === 'success') install.style.display = 'none';
|
|
823
|
-
install.disabled = false;
|
|
824
|
-
check.disabled = false;
|
|
825
|
-
} catch (err) {
|
|
826
|
-
info.textContent = '查询更新状态失败:' + err.message + ';请稍后重新检查。';
|
|
827
|
-
install.disabled = false; check.disabled = false;
|
|
828
|
-
}
|
|
829
|
-
};
|
|
830
|
-
setTimeout(poll, 1000);
|
|
831
|
-
} catch (err) {
|
|
832
|
-
info.textContent = '更新失败:' + err.message;
|
|
833
|
-
install.disabled = false; check.disabled = false;
|
|
834
|
-
}
|
|
835
|
-
}
|
|
836
|
-
|
|
837
787
|
loadAccounts();
|
|
838
788
|
loadSettings();
|
|
839
789
|
loadChromiumMeta();
|
|
840
|
-
checkUpdates();
|
|
841
790
|
</script>
|
|
842
791
|
</body>
|
|
843
792
|
</html>`;
|
|
844
793
|
|
|
845
|
-
export function createWebServer({ accountStore, authManager, updater =
|
|
794
|
+
export function createWebServer({ accountStore, authManager, updater, port = 12309 }) {
|
|
795
|
+
updater ??= createWebUpdater({ port });
|
|
846
796
|
const server = createServer(async (req, res) => {
|
|
847
797
|
// Update endpoints are privileged even when the listener binds 0.0.0.0.
|
|
848
798
|
if (req.url?.split("?")[0].startsWith("/api/update")) {
|
|
@@ -866,21 +816,6 @@ export function createWebServer({ accountStore, authManager, updater = createWeb
|
|
|
866
816
|
if (req.method === "GET" && path === "/api/update/check") {
|
|
867
817
|
try { sendUpdate(200, { ok: true, ...await updater.check() }); }
|
|
868
818
|
catch (err) { sendUpdate(502, { ok: false, error: err.message }); }
|
|
869
|
-
} else if (req.method === "GET" && path === "/api/update/status") {
|
|
870
|
-
sendUpdate(200, { ok: true, ...updater.status() });
|
|
871
|
-
} else if (req.method === "POST" && path === "/api/update") {
|
|
872
|
-
if (!/^application\/json(?:\s*;\s*charset=utf-8)?\s*$/i.test(req.headers["content-type"] || "")) {
|
|
873
|
-
sendUpdate(415, { ok: false, error: "需要 application/json" }); return;
|
|
874
|
-
}
|
|
875
|
-
let body = "";
|
|
876
|
-
for await (const chunk of req) {
|
|
877
|
-
body += chunk;
|
|
878
|
-
if (body.length > 4096) { sendUpdate(413, { ok: false, error: "请求过大" }); return; }
|
|
879
|
-
}
|
|
880
|
-
try { const data = JSON.parse(body); if (!data || typeof data !== "object" || Array.isArray(data)) throw new Error(); }
|
|
881
|
-
catch { sendUpdate(400, { ok: false, error: "需要 JSON 对象" }); return; }
|
|
882
|
-
const { started, ...result } = await updater.start();
|
|
883
|
-
sendUpdate(started ? 202 : 409, { ok: started, ...result });
|
|
884
819
|
} else { sendUpdate(404, { ok: false, error: "Not Found" }); }
|
|
885
820
|
return;
|
|
886
821
|
}
|
|
@@ -899,8 +834,6 @@ export function createWebServer({ accountStore, authManager, updater = createWeb
|
|
|
899
834
|
|
|
900
835
|
// 1. 首页控制台
|
|
901
836
|
if (pathname === "/" || pathname === "/index.html") {
|
|
902
|
-
const currentFilePath = fileURLToPath(import.meta.url).replaceAll("\\", "/");
|
|
903
|
-
const isInstalledViaNpm = /\/node_modules\/(@taole\/)?dd-playwright\//i.test(currentFilePath);
|
|
904
837
|
const serverScriptPath = fileURLToPath(
|
|
905
838
|
new URL("../../bin/mcp-server.mjs", import.meta.url),
|
|
906
839
|
).replaceAll("\\", "/");
|
|
@@ -918,12 +851,9 @@ export function createWebServer({ accountStore, authManager, updater = createWeb
|
|
|
918
851
|
2,
|
|
919
852
|
);
|
|
920
853
|
|
|
921
|
-
let bannerStyle = "background: rgba(59, 130, 246, 0.12); border: 1px solid rgba(59, 130, 246, 0.3); color: #60a5fa;";
|
|
922
|
-
let bannerContent = `💻 <b>当前代码位置:</b><code style="background: rgba(0,0,0,0.3); padding: 2px 6px; border-radius: 4px; color: #93c5fd;">${serverScriptPath}</code><br><span style="color:#cbd5e1; font-size:12px; margin-top: 4px; display: inline-block;">使用 Node 绝对路径方式挂载 MCP,不受系统 PATH 或环境切换影响,100% 稳定运行。</span>`;
|
|
923
|
-
|
|
924
854
|
let configSections = `
|
|
925
855
|
<div class="code-title">
|
|
926
|
-
<span
|
|
856
|
+
<span>MCP 配置</span>
|
|
927
857
|
<button class="copy-btn" onclick="copyCode('code-mcp-local')">复制代码</button>
|
|
928
858
|
</div>
|
|
929
859
|
<div id="code-mcp-local" class="code-block">${localConfigStr}</div>
|
|
@@ -938,8 +868,6 @@ export function createWebServer({ accountStore, authManager, updater = createWeb
|
|
|
938
868
|
|
|
939
869
|
const renderedHtml = DASHBOARD_HTML
|
|
940
870
|
.replace("{{VERSION}}", pkgVersion)
|
|
941
|
-
.replace("{{BANNER_STYLE}}", bannerStyle)
|
|
942
|
-
.replace("{{BANNER_CONTENT}}", bannerContent)
|
|
943
871
|
.replace("{{CONFIG_SECTIONS}}", configSections);
|
|
944
872
|
sendHtml(res, renderedHtml);
|
|
945
873
|
return;
|
|
@@ -977,7 +905,7 @@ export function createWebServer({ accountStore, authManager, updater = createWeb
|
|
|
977
905
|
const content = readFileSync(skillPath, "utf8");
|
|
978
906
|
res.writeHead(200, {
|
|
979
907
|
"Content-Type": "text/markdown; charset=utf-8",
|
|
980
|
-
"Content-Disposition": 'attachment; filename="SKILL.
|
|
908
|
+
"Content-Disposition": 'attachment; filename="DD-PLAYWRIGHT-SKILL.MD"',
|
|
981
909
|
"Cache-Control": "no-cache",
|
|
982
910
|
});
|
|
983
911
|
res.end(content);
|
|
@@ -1122,7 +1050,7 @@ export async function startWebServer({
|
|
|
1122
1050
|
} = {}) {
|
|
1123
1051
|
const store = accountStore || new AccountStore(dbPath);
|
|
1124
1052
|
const authManager = new DiandianAuthManager({ accountStore: store });
|
|
1125
|
-
const server = createWebServer({ accountStore: store, authManager });
|
|
1053
|
+
const server = createWebServer({ accountStore: store, authManager, port });
|
|
1126
1054
|
|
|
1127
1055
|
return new Promise((resolve, reject) => {
|
|
1128
1056
|
server.listen(port, "0.0.0.0", () => {
|
|
@@ -1,67 +1,13 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { realpath } from "node:fs/promises";
|
|
3
|
-
import { promisify } from "node:util";
|
|
4
|
-
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { PACKAGE_NAME, compareSemver, getLocalVersion, getRemoteLatestVersion } from "../updater.mjs";
|
|
1
|
+
import { compareSemver, getLocalVersion, getRemoteLatestVersion } from "../updater.mjs";
|
|
7
2
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
async function isGlobalInstall() {
|
|
12
|
-
try {
|
|
13
|
-
const windows = process.platform === "win32";
|
|
14
|
-
const { stdout } = await execFileAsync(windows ? process.env.ComSpec || "cmd.exe" : "npm", windows ? ["/d", "/s", "/c", "npm.cmd root -g"] : ["root", "-g"], { windowsHide: true, timeout: 10000 });
|
|
15
|
-
const [actual, global] = await Promise.all([realpath(packageDir), realpath(join(stdout.trim(), PACKAGE_NAME))]);
|
|
16
|
-
return windows ? actual.toLowerCase() === global.toLowerCase() : actual === global;
|
|
17
|
-
} catch { return false; }
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
function installGlobal() {
|
|
21
|
-
return new Promise((resolve, reject) => {
|
|
22
|
-
const windows = process.platform === "win32";
|
|
23
|
-
const child = spawn(windows ? process.env.ComSpec || "cmd.exe" : "npm", windows
|
|
24
|
-
? ["/d", "/s", "/c", `npm.cmd install -g ${PACKAGE_NAME}@latest`]
|
|
25
|
-
: ["install", "-g", `${PACKAGE_NAME}@latest`],
|
|
26
|
-
{ windowsHide: true, stdio: ["ignore", "ignore", "pipe"] });
|
|
27
|
-
let stderr = "";
|
|
28
|
-
child.stderr?.on("data", chunk => { stderr = (stderr + chunk.toString()).slice(-4000); });
|
|
29
|
-
child.once("error", reject);
|
|
30
|
-
child.once("close", (code, signal) => code === 0 ? resolve() : reject(new Error(stderr.trim() || `npm 退出: ${signal || code}`)));
|
|
31
|
-
});
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export function createWebUpdater({ getVersion = getLocalVersion, getLatest = getRemoteLatestVersion, isGlobal = isGlobalInstall, install = installGlobal } = {}) {
|
|
35
|
-
let state = { status: "idle", error: null, message: "尚未开始更新", restartRequired: false };
|
|
3
|
+
/** 仅检查版本,不执行安装或重启。 */
|
|
4
|
+
export function createWebUpdater({ getVersion = getLocalVersion, getLatest = getRemoteLatestVersion } = {}) {
|
|
36
5
|
return {
|
|
37
6
|
async check() {
|
|
38
7
|
const currentVersion = getVersion();
|
|
39
|
-
const
|
|
8
|
+
const latestVersion = await getLatest();
|
|
40
9
|
if (!latestVersion) throw new Error("无法查询 npm 最新版本,请检查网络");
|
|
41
|
-
return { currentVersion, latestVersion, updateAvailable: compareSemver(latestVersion, currentVersion) > 0
|
|
42
|
-
installType: global ? "global" : "local", canUpdate: global,
|
|
43
|
-
message: global ? "更新完成后请手动重启服务" : "当前运行的不是 npm 全局安装,更新全局包不会更新此服务,请按当前安装方式手动更新" };
|
|
44
|
-
},
|
|
45
|
-
status() { return { ...state }; },
|
|
46
|
-
async start() {
|
|
47
|
-
if (state.status === "running") return { started: false, ...state };
|
|
48
|
-
state = { status: "running", error: null, message: "正在确认安装位置", restartRequired: false };
|
|
49
|
-
try {
|
|
50
|
-
if (!await isGlobal()) {
|
|
51
|
-
state = { status: "error", error: "当前服务不是 npm 全局安装,请手动更新当前安装", message: "无法自动更新", restartRequired: false };
|
|
52
|
-
return { started: false, ...state };
|
|
53
|
-
}
|
|
54
|
-
state.message = "正在安装更新,完成后请手动重启服务";
|
|
55
|
-
Promise.resolve().then(() => install()).then(() => {
|
|
56
|
-
state = { status: "success", error: null, message: "安装完成,请手动重启服务以使用新版本", restartRequired: true };
|
|
57
|
-
}, err => {
|
|
58
|
-
state = { status: "error", error: String(err?.message || err), message: "安装失败,服务仍在运行", restartRequired: false };
|
|
59
|
-
});
|
|
60
|
-
return { started: true, ...state };
|
|
61
|
-
} catch (err) {
|
|
62
|
-
state = { status: "error", error: String(err?.message || err), message: "更新启动失败", restartRequired: false };
|
|
63
|
-
return { started: false, ...state };
|
|
64
|
-
}
|
|
10
|
+
return { currentVersion, latestVersion, updateAvailable: compareSemver(latestVersion, currentVersion) > 0 };
|
|
65
11
|
},
|
|
66
12
|
};
|
|
67
13
|
}
|
package/src/updater.mjs
CHANGED
|
@@ -4,6 +4,7 @@ import { execSync } from "node:child_process";
|
|
|
4
4
|
import { resolveConfig } from "./config.mjs";
|
|
5
5
|
import {
|
|
6
6
|
getStoredWebPid,
|
|
7
|
+
getStoredWebPort,
|
|
7
8
|
stopWebDaemon,
|
|
8
9
|
startWebDaemon,
|
|
9
10
|
} from "./server/daemon.mjs";
|
|
@@ -100,6 +101,8 @@ export async function handleUpdateCommand(overrideCurrentVersion = null) {
|
|
|
100
101
|
const config = resolveConfig();
|
|
101
102
|
const runningPid = getStoredWebPid(config.webPidFile);
|
|
102
103
|
const wasWebRunning = Boolean(runningPid);
|
|
104
|
+
// 停止前先读取服务启动时记录的端口,重启时恢复原端口
|
|
105
|
+
const webPort = getStoredWebPort(config.dataDir) || 12309;
|
|
103
106
|
|
|
104
107
|
if (wasWebRunning) {
|
|
105
108
|
console.log(
|
|
@@ -122,7 +125,7 @@ export async function handleUpdateCommand(overrideCurrentVersion = null) {
|
|
|
122
125
|
if (wasWebRunning) {
|
|
123
126
|
console.log(`[dd-playwright] 正在自动为您重启后台 Web 服务...`);
|
|
124
127
|
const restartRes = await startWebDaemon({
|
|
125
|
-
port:
|
|
128
|
+
port: webPort,
|
|
126
129
|
dataDir: config.dataDir,
|
|
127
130
|
pidFile: config.webPidFile,
|
|
128
131
|
logFile: config.webLogFile,
|