@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 CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  | 工具 | 说明 |
10
10
  |---|---|
11
- | `browser_navigate` | 访问 URL。支持显式指定 `uid` 选择绑定账号(默认使用当前默认账号);支持通过 `anonymous: true` 模拟未登录游客状态访问;支持 `device`(如 `iPhone 13`)启用完整移动端仿真(视口+UA+触摸),传 `desktop` 恢复桌面模式;支持 `userAgent` 会话级自定义 UA,传 `default` 清除 |
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 最新版本;当当前服务来自 npm 全局安装且有新版本时,可点击「立即更新」。安装完成后需手动重启 Web 服务和 MCP 客户端。源码或项目依赖运行时请按对应安装方式手动更新。
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
- - **默认免登**:访问点点业务页面必须调用本 MCP 的 `browser_navigate` 工具,自动注入当前激活账号的凭据(包含 `Tuwan_Passport` 等 Cookie)。
14
- - **测试游客态/未登录拦截**:调用 `browser_navigate` 时必须显式指定 `anonymous: true`,用于验证未登录弹窗、游客导流或未鉴权状态。
15
- - **指定测试账号**:传入特定账号的 `uid`(如 `browser_navigate({ url, uid: "28689781" })`),自动隔离并切换独立浏览器上下文。
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 代理会自动转发流量并正确携带点点线上 Cookie。
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 === "startweb") {
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(config.webPidFile);
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 服务状态:
@@ -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.13";
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
- "可显式指定 uid 选择绑定的点点账号(不传则使用默认激活账号);" +
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
- uid: z
47
- .string()
48
- .optional()
49
- .describe("显式指定的点点账号 UID。若不传,则默认使用当前激活账号"),
50
- anonymous: z
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, uid, anonymous, device, userAgent, waitFor, timeout }) => {
77
+ async ({ url, identity, device, userAgent, waitFor, timeout }) => {
82
78
  try {
83
79
  return textResult(
84
- await manager.navigate({ url, uid, anonymous, device, userAgent, waitFor, timeout }),
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@taole/dd-playwright",
3
- "version": "0.1.13",
3
+ "version": "0.2.0",
4
4
  "description": "Standalone stdio MCP & CLI: Playwright browser automation + isolated whistle proxy + diandian account storageState injection",
5
5
  "type": "module",
6
6
  "license": "ISC",
@@ -137,8 +137,7 @@ export class PlaywrightBrowserManager {
137
137
  storageState !== undefined
138
138
  ? storageState
139
139
  : resolveStorageState({ dbPath, storageStateFile });
140
- this.currentUid = null;
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
- * 根据指定的 uid、anonymous 状态、设备描述符与会话级 UA 准备浏览器上下文。
189
- * @param {{uid?: string|null, anonymous?: boolean, device?: string|null, userAgent?: string|null}} [options]
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({ uid = null, anonymous = false, device, userAgent } = {}) {
194
- const targetAnonymous = Boolean(anonymous);
195
- const targetUid = targetAnonymous ? null : (uid ? String(uid) : null);
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
- const authChanged =
216
- this.isAnonymous !== targetAnonymous ||
217
- (targetUid !== null && this.currentUid !== targetUid) ||
218
- (targetUid === null && this.currentUid !== null && !targetAnonymous);
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.currentUid = targetUid;
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, uid, anonymous = false, device, userAgent }) {
307
- const page = await this.ensurePage({ uid, anonymous, device, userAgent });
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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 = await this.ensurePage();
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);
@@ -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.md" class="btn" style="background: #334155; color: white; display: flex; align-items: center; gap: 6px; font-weight: 500; padding: 7px 12px; text-decoration: none;">
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.md
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
- • 访问点点业务页面必须调用本 MCP 的 <code>browser_navigate</code> 工具,自动注入当前激活账号的 storageState(含 <code>Tuwan_Passport</code> 鉴权 Cookie)免登。<br>
383
- • 多角色或指定账号测试时传入特定 <code>uid</code>,自动隔离并重建独立浏览器上下文。<br><br>
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
- -> 自动携带点点线上 Tuwan_Passport Cookie,无需改动系统 hosts!
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/...", anonymous: true })
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">准备检查更新…</div>
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. 登录态与身份切换:访问点点页面调用本 MCP 的 browser_navigate 自动注入激活账号免登;排查游客态或未登录拦截必须传 anonymous: true;指定测试账号传特定 uid。',
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
- info.textContent = '当前版本:' + data.currentVersion + ';最新版本:' + data.latestVersion + '。' + (data.message || '');
798
- if (data.updateAvailable && data.canUpdate) install.style.display = 'inline-block';
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 = createWebUpdater() }) {
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>本地绝对路径配置(推荐,开箱即用)</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.md"',
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 { spawn, execFile } from "node:child_process";
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
- const execFileAsync = promisify(execFile);
9
- const packageDir = fileURLToPath(new URL("../../", import.meta.url));
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 [latestVersion, global] = await Promise.all([getLatest(), isGlobal()]);
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: 12309,
128
+ port: webPort,
126
129
  dataDir: config.dataDir,
127
130
  pidFile: config.webPidFile,
128
131
  logFile: config.webLogFile,