dsh-browser-playwright-codex 0.7.7 → 0.7.8

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/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## 0.7.8 — 2026-11-10
2
+
3
+ 一句话摘要:**工具调用不会再无限挂着** —— 打开网页之后"读页面内容"这一步一直没有超时上限,也不接取消信号,页面自己的脚本把浏览器主线程占住时,整个工具调用就停在那里等(GitHub issue #5 报的就是这个,实测能卡到分钟级)。现在读内容有硬期限、能按取消退出,超时后自动换一个干净的会话句柄。
4
+
5
+ ### Fixed
6
+
7
+ - **读页面内容没有超时(issue #5)**:`page.evaluate` 与 `ariaSnapshot` 都没有 timeout 参数,页面脚本占住主线程时它们永远不返回。实测定位:打开网页(`goto`)只用 **13 毫秒**就返回了,卡住的是之后读内容那一步——同一个页面读内容 **8.5 秒不返回**,此时按取消(1.5 秒)也**完全不生效**。新增 `src/read-deadline.ts`:`withDeadline()` 给可能永不返回的调用加硬期限,到点抛 `ACTION_TIMEOUT`(消息里写明是哪一步、多少毫秒、下次会重开会话),按取消抛 `AbortError`;原调用晚到的失败被吞掉,不会变成未捕获异常把宿主带崩。另有软超时模式返回空值,供可降级的场景使用。
8
+ - **超时后不会一直烂在那里**:`session.capture()` 是所有快照/读内容的必经点,超时时作废当前会话句柄、下次调用自动重建。持久化模式共用同一个浏览器上下文,所以**页面和登录状态都不丢,也不需要杀进程**——页面自己恢复后浏览器照常可用。修后实测:读内容 1500ms 硬期限 → 1509ms 抛错;取消 400ms → 406ms 抛 `AbortError`。
9
+ - **"等页面稳定"这一步也会卡**:`session.waitForPageStable()` 同样是一次页内调用,改成软超时——卡住就当"页面没稳定"照常往下走,保持原有的尽力而为语义。
10
+ - **取消信号能传到读内容这一步**:新增 `session.setSignal()` / `signal()`,工具层在 `acquire()` 时把这次调用的取消信号挂到会话上,于是快照、导航以及所有动作后的读取都能被取消,不用改十几个动作函数的签名。
11
+ - **`navigationTimeoutMs` 补下限**:原来只校验"是个数字",填 `0` 在 Playwright 里等于**永不超时**,正是这个 bug 的放大器。现在要求 ≥ 1000 毫秒,默认仍是 30 秒。
12
+ - **没有照抄报告人提的两个补丁**:他建议"启动加 20 秒超时""默认等 `domcontentloaded`",实测证明都解决不了问题(`goto` 本来就 13 毫秒返回)。真正对得上的只有上面两条。
13
+
14
+ ### Added
15
+
16
+ - **`tests/read-deadline.test.ts`(7 条)**:硬期限到点抛 `ACTION_TIMEOUT` 且消息含步骤名与毫秒数、到点前取消抛 `AbortError`(不被当成超时)、已 abort 的信号立即拒绝、正常返回不受影响、**原调用的迟到失败不会变成未捕获异常**、软超时返回空值不抛错、配置下限(`0` 与 `500` 被拒、`1000` 通过、默认 `30000`)。
17
+
18
+ ### Tests
19
+
20
+ - 全套 274 → **281 条**(+7):通过 278、跳过 3、失败 0。
21
+
1
22
  ## 0.7.7 — 2026-10-28
2
23
 
3
24
  一句话摘要:**首页和 Releases 页能看见版本号了** —— README 顶部加四枚徽章(npm 版本 / 许可证 / 要求的 Node / 最新发布),并把漏掉的 v0.7.5、v0.7.6 补成 GitHub Releases;此前仓库只有 tag,Releases 页停在 v0.7.4。
package/lib/config.js CHANGED
@@ -22,7 +22,10 @@ export const Config = z.object({
22
22
  viewport: z
23
23
  .object({ width: z.number().default(1280), height: z.number().default(800) })
24
24
  .default({ width: 1280, height: 800 }),
25
- navigationTimeoutMs: z.number().default(30000),
25
+ // 0 used to be accepted, and Playwright reads timeout: 0 as "no timeout",
26
+ // so a stuck page could hang the tool forever (issue #5). Require at least
27
+ // one second; the default stays 30s.
28
+ navigationTimeoutMs: z.number().min(1000).default(30000),
26
29
  ignoreHTTPSErrors: z.boolean().default(false),
27
30
  }),
28
31
  allowedDomains: z.array(z.string()),
@@ -0,0 +1,18 @@
1
+ import { isAbortError } from './page-diagnostics.ts';
2
+ /**
3
+ * 让一个可能挂住的 Promise 同时响应「到点」和「取消」。
4
+ *
5
+ * - 到点:抛 `ACTION_TIMEOUT`,消息里带上限和这一步在做什么,模型/用户能看懂。
6
+ * - 取消:原样抛 `AbortError`,和工具层其它取消行为一致。
7
+ * - 原始 Promise 无论多晚才 settle,都不会变成未捕获的 rejection(和 withAbort 同规矩)。
8
+ *
9
+ * @param promise - 可能挂住的调用
10
+ * @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
11
+ * @param label - 超时消息里说明卡在哪一步
12
+ * @param signal - 可选的取消信号
13
+ */
14
+ export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined'): Promise<T | undefined>;
15
+ /** 这个错误是不是「读内容超时」——调用方据此作废会话句柄,让下次自愈。 */
16
+ export declare function isReadTimeout(error: unknown): boolean;
17
+ /** 取消错误保持原样传递(不多包一层)。 */
18
+ export { isAbortError };
@@ -0,0 +1,70 @@
1
+ /**
2
+ * 给「读页面内容」这类**可能永远不返回**的调用加一道硬期限。
3
+ *
4
+ * 为什么需要它:Playwright 的 `page.evaluate` / `ariaSnapshot` 没有 timeout 参数。
5
+ * 页面脚本一旦占住主线程(着色器编译、同步重绘循环、死循环),这些调用会一直
6
+ * 挂着,工具调用、甚至整个对话就卡死在那里(GitHub issue #5:报告人卡了 12 分钟)。
7
+ * `withAbort` 只解决了「用户按取消」,没有「到点自己断」;而且取消信号也不会被
8
+ * 传进读内容那一步。这里把**硬期限 + 取消**合成一个包装。
9
+ *
10
+ * 注意:超时只能让我们这一侧返回,底层那个 evaluate 仍在浏览器里跑(Playwright
11
+ * 不提供取消它的办法)。所以调用方在超时后要作废会话句柄,让下一次调用别继承
12
+ * 这个卡住的页面;页面自己恢复后浏览器照常可用,不需要杀进程。
13
+ *
14
+ * @module dsh-browser-playwright-codex/read-deadline
15
+ */
16
+ import { BrowserError } from "./errors.js";
17
+ import { isAbortError } from "./page-diagnostics.js";
18
+ /**
19
+ * 让一个可能挂住的 Promise 同时响应「到点」和「取消」。
20
+ *
21
+ * - 到点:抛 `ACTION_TIMEOUT`,消息里带上限和这一步在做什么,模型/用户能看懂。
22
+ * - 取消:原样抛 `AbortError`,和工具层其它取消行为一致。
23
+ * - 原始 Promise 无论多晚才 settle,都不会变成未捕获的 rejection(和 withAbort 同规矩)。
24
+ *
25
+ * @param promise - 可能挂住的调用
26
+ * @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
27
+ * @param label - 超时消息里说明卡在哪一步
28
+ * @param signal - 可选的取消信号
29
+ */
30
+ export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject') {
31
+ const guarded = Promise.resolve(promise);
32
+ guarded.catch(() => { }); // 超时后原调用仍在跑:别让它的 rejection 冒到宿主
33
+ return new Promise((resolve, reject) => {
34
+ let settled = false;
35
+ const finish = (act) => {
36
+ if (settled)
37
+ return;
38
+ settled = true;
39
+ clearTimeout(timer);
40
+ signal?.removeEventListener('abort', onAbort);
41
+ act();
42
+ };
43
+ const onAbort = () => finish(() => reject(new DOMException('The operation was aborted', 'AbortError')));
44
+ const timer = setTimeout(() => {
45
+ if (onTimeout === 'resolve-undefined') {
46
+ finish(() => resolve(undefined));
47
+ return;
48
+ }
49
+ finish(() => reject(new BrowserError('ACTION_TIMEOUT', '[ACTION_TIMEOUT] ' +
50
+ label +
51
+ ' did not finish within ' +
52
+ String(timeoutMs) +
53
+ 'ms; the page may be stuck running script. The next browser call reopens the session.')));
54
+ }, timeoutMs);
55
+ timer.unref?.();
56
+ if (signal !== undefined && typeof signal.addEventListener === 'function') {
57
+ if (signal.aborted)
58
+ onAbort();
59
+ else
60
+ signal.addEventListener('abort', onAbort, { once: true });
61
+ }
62
+ guarded.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
63
+ });
64
+ }
65
+ /** 这个错误是不是「读内容超时」——调用方据此作废会话句柄,让下次自愈。 */
66
+ export function isReadTimeout(error) {
67
+ return error instanceof BrowserError && error.code === 'ACTION_TIMEOUT';
68
+ }
69
+ /** 取消错误保持原样传递(不多包一层)。 */
70
+ export { isAbortError };
package/lib/session.d.ts CHANGED
@@ -59,6 +59,17 @@ export declare class PlaywrightSession {
59
59
  lastRefSignatures: Map<string, RefSignature> | undefined;
60
60
  /** Nonce of the baseline ref space; a change means navigation reset. */
61
61
  lastRefNonce: string | undefined;
62
+ /**
63
+ * The cancellation signal of the tool call currently driving this session.
64
+ * Tools set it via `setSignal` before an action; `capture()` reads it so a
65
+ * stuck page read can be aborted without threading a signal through every
66
+ * action function (issue #5).
67
+ */
68
+ private currentSignal;
69
+ /** Bind the current tool call's cancellation signal to this session. */
70
+ setSignal(signal: AbortSignal | undefined): void;
71
+ /** Cancellation signal for the current tool call, if any. */
72
+ signal(): AbortSignal | undefined;
62
73
  constructor(provider: PlaywrightProvider, owner: string, context: BrowserContext);
63
74
  /**
64
75
  * Turn a navigation the host guard refused into a named refusal.
package/lib/session.js CHANGED
@@ -2,6 +2,7 @@ import { looksLikeToken, registrableDomain } from "./login-sites.js";
2
2
  import { BrowserError, cdpDisabled, evaluateDisabled } from "./errors.js";
3
3
  import { isCdpMethodAllowed, cdpDenialMessage } from "./cdp-policy.js";
4
4
  import { getGates } from "./runtime-state.js";
5
+ import { isReadTimeout, withDeadline } from "./read-deadline.js";
5
6
  import { asTimeoutError, isAbortError, isContextDestroyed, isCrashError } from "./page-diagnostics.js";
6
7
  import { consoleMessages, networkRequests, pageData, trackPage } from "./page-diagnostics.js";
7
8
  import { elementStabilityProbe, pageStabilityProbe } from "./page-probes.js";
@@ -53,6 +54,21 @@ export class PlaywrightSession {
53
54
  lastRefSignatures = undefined;
54
55
  /** Nonce of the baseline ref space; a change means navigation reset. */
55
56
  lastRefNonce = undefined;
57
+ /**
58
+ * The cancellation signal of the tool call currently driving this session.
59
+ * Tools set it via `setSignal` before an action; `capture()` reads it so a
60
+ * stuck page read can be aborted without threading a signal through every
61
+ * action function (issue #5).
62
+ */
63
+ currentSignal = undefined;
64
+ /** Bind the current tool call's cancellation signal to this session. */
65
+ setSignal(signal) {
66
+ this.currentSignal = signal;
67
+ }
68
+ /** Cancellation signal for the current tool call, if any. */
69
+ signal() {
70
+ return this.currentSignal;
71
+ }
56
72
  constructor(provider, owner, context) {
57
73
  this.provider = provider;
58
74
  this.owner = owner;
@@ -216,7 +232,11 @@ export class PlaywrightSession {
216
232
  }
217
233
  /** Page-level render stability, bounded — a busy page never stalls the agent. */
218
234
  async waitForPageStable(page, timeoutMs = 1200) {
219
- await page.evaluate(pageStabilityProbe, { frames: 2, timeoutMs }).catch(() => false);
235
+ // page.evaluate has no timeout: a page whose script holds the main thread
236
+ // would hang here before capture even starts (issue #5). Give the probe a
237
+ // hard outer deadline; on timeout degrade to "not stable" and carry on —
238
+ // the same best-effort semantics as the existing .catch(() => false).
239
+ await withDeadline(page.evaluate(pageStabilityProbe, { frames: 2, timeoutMs }).catch(() => false), Math.max(2000, timeoutMs + 1000), 'waiting for the page to settle', this.currentSignal, 'resolve-undefined').catch(() => false);
220
240
  }
221
241
  /** Element-level stability before a pointer/keyboard action, bounded. Returns true when stable. */
222
242
  async waitForElementStable(locator) {
@@ -294,13 +314,31 @@ export class PlaywrightSession {
294
314
  /** Read the snapshot tree from one page (must run inside a run()). */
295
315
  async capture(page, opts, afterAction = false) {
296
316
  this.provider.armDialogGuard(page);
297
- const raw = resolveSnapshotEngine(this.provider.config.snapshot.engine) === 'aria'
298
- ? await this.captureAria(page, opts)
299
- : await this.captureLegacy(page, opts);
300
- // Order matters: the response metadata is attached before the diff, so the
301
- // snapshot the model reads carries the same evidence the detector used, and
302
- // the no-progress backstop sees the final tree.
303
- return this.attachDiff(attachNoProgress(this, attachDocResponse(this, page, raw), afterAction));
317
+ // 读页面内容(page.evaluate / ariaSnapshot)没有 timeout 参数:页面脚本占住
318
+ // 主线程时它会永远挂着,连工具调用一起卡死(issue #5)。这里给整次读加
319
+ // 硬期限 + 取消信号。所有读内容都经过这个咽喉点,一处覆盖全部动作。
320
+ const read = async () => {
321
+ const raw = resolveSnapshotEngine(this.provider.config.snapshot.engine) === 'aria'
322
+ ? await this.captureAria(page, opts)
323
+ : await this.captureLegacy(page, opts);
324
+ // Order matters: the response metadata is attached before the diff, so the
325
+ // snapshot the model reads carries the same evidence the detector used, and
326
+ // the no-progress backstop sees the final tree.
327
+ return this.attachDiff(attachNoProgress(this, attachDocResponse(this, page, raw), afterAction));
328
+ };
329
+ try {
330
+ const snap = await withDeadline(read(), this.timeoutMs(), 'reading the page content', this.currentSignal);
331
+ return snap;
332
+ }
333
+ catch (error) {
334
+ // 超时只能让我们这一侧返回,页面里那个 evaluate 还在跑。作废会话句柄,
335
+ // 下一次调用会重新拿到干净窗口,而不是继承这个卡住的页面。
336
+ if (isReadTimeout(error)) {
337
+ this.closed = true;
338
+ this.provider.invalidateOwner(this.owner);
339
+ }
340
+ throw error;
341
+ }
304
342
  }
305
343
  /**
306
344
  * Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
package/lib/tool.js CHANGED
@@ -109,6 +109,11 @@ export function apply(ctx, config) {
109
109
  throw new BrowserError('DISABLED', 'dsh-browser-playwright-codex is disabled; open Settings → Plugins → 浏览器 and turn it on to use the browser');
110
110
  }
111
111
  return ctx.browser.acquire(ownerFor(exec), exec.signal).then((session) => {
112
+ // Let the session's page read honour this call's cancellation signal:
113
+ // a stuck page no longer hangs the whole tool call (issue #5).
114
+ const withSet = session;
115
+ if (typeof withSet.setSignal === 'function')
116
+ withSet.setSignal(exec.signal);
112
117
  publishLogins(session);
113
118
  return session;
114
119
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-browser-playwright-codex",
3
- "version": "0.7.7",
3
+ "version": "0.7.8",
4
4
  "description": "Browser automation for DeepSeek Harness, one plugin: fill in forms, scrape lists and work across tabs in real pages, using the logins you already have. Runs in a visible window that stays quiet when minimised; native dialogs are parked instead of confirmed, CAPTCHA pages are flagged with a note that says stop and hand the page back, password / one-time-code / card fields are refused by browser_fill, and the Settings card (enable/disable, page-reading mode, capability gates) now lives inside this package.",
5
5
  "license": "MIT",
6
6
  "type": "module",