dsh-browser-playwright-codex 0.7.7 → 0.8.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/CHANGELOG.md +85 -0
- package/NOTICE.md +26 -5
- package/README.md +27 -4
- package/lib/browser-lifecycle.js +27 -1
- package/lib/client.js +1219 -647
- package/lib/config.d.ts +26 -0
- package/lib/config.js +24 -1
- package/lib/page-actions.js +32 -1
- package/lib/provider.js +8 -0
- package/lib/read-deadline.d.ts +22 -0
- package/lib/read-deadline.js +77 -0
- package/lib/runtime-state.d.ts +26 -0
- package/lib/runtime-state.js +100 -0
- package/lib/session.d.ts +22 -0
- package/lib/session.js +69 -8
- package/lib/settings-card.js +123 -0
- package/lib/swarm-contract.d.ts +226 -0
- package/lib/swarm-contract.js +209 -0
- package/lib/swarm-curtain.d.ts +22 -0
- package/lib/swarm-curtain.js +115 -0
- package/lib/swarm-intro.d.ts +66 -0
- package/lib/swarm-intro.js +827 -0
- package/lib/tool.js +19 -4
- package/package.json +4 -2
package/lib/config.d.ts
CHANGED
|
@@ -41,6 +41,32 @@ export interface PlaywrightConfig {
|
|
|
41
41
|
/** Incremental ref-diff mode; false = always full snapshots (zero behavioral change). */
|
|
42
42
|
diff: boolean;
|
|
43
43
|
};
|
|
44
|
+
/**
|
|
45
|
+
* 进场特效:把一个页面抹白,再用一群光标把它重新画出来。
|
|
46
|
+
*
|
|
47
|
+
* 可选:老配置对象里没有这一节时,由 Schemastery 的 Config 补齐默认值
|
|
48
|
+
* (见下面的 `swarm: z.object(...).default(...)`)。做成可选是为了让手写的
|
|
49
|
+
* 测试配置、以及旧部署的 patch 层都能直接通过类型检查。
|
|
50
|
+
*/
|
|
51
|
+
swarm?: {
|
|
52
|
+
/** 出厂就开着:这是装完就能看见的那一下「哇」。设置卡上的开关可以随时关掉。 */
|
|
53
|
+
enabled: boolean;
|
|
54
|
+
/** 只在某个网站第一次打开时播;false = 每次导航都播(不建议)。 */
|
|
55
|
+
oncePerSite: boolean;
|
|
56
|
+
/** 播放速度档位 1–3(3 最快)。 */
|
|
57
|
+
speed: number;
|
|
58
|
+
/** 最多播多久(毫秒)。0 = 不限制,完整播完。 */
|
|
59
|
+
maxMs: number;
|
|
60
|
+
/** 遮罩页面(登录墙、密码框)也播。默认关:会把没解锁的内容画出来。 */
|
|
61
|
+
playOnVeiled: boolean;
|
|
62
|
+
/** 一直记住播过哪些站:关掉整个浏览器窗口也不重播。默认关(关窗就重播)。 */
|
|
63
|
+
persistPlayed: boolean;
|
|
64
|
+
/**
|
|
65
|
+
* 播放范围:`viewport` = 只画当前这一屏(默认,1–10 秒结束);
|
|
66
|
+
* `page` = 由插件按节奏往下滚,一屏一屏把整页画完(会接管滚动,时间长得多)。
|
|
67
|
+
*/
|
|
68
|
+
range: 'viewport' | 'page';
|
|
69
|
+
};
|
|
44
70
|
}
|
|
45
71
|
/** Bounded page data the extraction consumer feeds to a model. */
|
|
46
72
|
export interface PageData {
|
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
|
-
|
|
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()),
|
|
@@ -42,6 +45,26 @@ export const Config = z.object({
|
|
|
42
45
|
diff: z.boolean().default(false),
|
|
43
46
|
})
|
|
44
47
|
.default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
|
|
48
|
+
swarm: z
|
|
49
|
+
.object({
|
|
50
|
+
// 出厂关:动效资产不再随 npm 包分发(0.8.0 起),默认开会让每个新用户
|
|
51
|
+
// 在"文件根本不存在"的状态下开箱报错。想要的人把资产放回 assets/ 再打开。
|
|
52
|
+
enabled: z.boolean().default(false),
|
|
53
|
+
// 只在每个网站第一次打开时播,避免每次导航都把页面盖住。
|
|
54
|
+
oncePerSite: z.boolean().default(true),
|
|
55
|
+
// 1–3 档,脚本里对应点几次速度按钮。
|
|
56
|
+
speed: z.number().default(3),
|
|
57
|
+
// 硬上限:到点无条件退回真实页面。原版脚本在长页面上要约 12 秒,留 15 秒。
|
|
58
|
+
maxMs: z.number().default(15000),
|
|
59
|
+
// 默认关:遮罩后面的内容没解锁就不该被画出来。自己的面板想看可以打开。
|
|
60
|
+
playOnVeiled: z.boolean().default(false),
|
|
61
|
+
// 默认关:整个浏览器窗口一关就忘掉这趟播过哪些站,下次重开全部重播。
|
|
62
|
+
persistPlayed: z.boolean().default(false),
|
|
63
|
+
// 只画首屏:长页面不动,短页面也不会卡在"下半页永远等不到"。想看整页的人
|
|
64
|
+
// 到设置卡选「整页」——那会把滚动接过去几秒,所以不做默认。
|
|
65
|
+
range: z.union([z.const('viewport'), z.const('page')]).default('viewport'),
|
|
66
|
+
})
|
|
67
|
+
.default({ enabled: false, oncePerSite: true, speed: 3, maxMs: 15000, playOnVeiled: false, persistPlayed: false }),
|
|
45
68
|
});
|
|
46
69
|
/** Channels probed in order when none is configured. */
|
|
47
70
|
export const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
|
package/lib/page-actions.js
CHANGED
|
@@ -9,6 +9,8 @@ import { assertAllowedUrl } from "./url-policy.js";
|
|
|
9
9
|
import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
|
|
10
10
|
import { clearLabelTargets, labelTargetProbe } from "./page-probes.js";
|
|
11
11
|
import { createBackgroundPage, isWindowMinimized } from "./window-focus.js";
|
|
12
|
+
import { maybePlaySwarm, stopSwarm, willPlaySwarm } from "./swarm-intro.js";
|
|
13
|
+
import { armCurtain, clearCurtainToken } from "./swarm-curtain.js";
|
|
12
14
|
/** Wrap a Playwright op so an aborted signal rejects while the op keeps draining in the background. */
|
|
13
15
|
export async function withAbort(promise, signal) {
|
|
14
16
|
// Attach handlers FIRST: the caller's promise may still be draining (a
|
|
@@ -99,6 +101,10 @@ export async function runAction(session, page, action) {
|
|
|
99
101
|
const already = session.provider.pendingDialogFor(page);
|
|
100
102
|
if (already !== undefined)
|
|
101
103
|
return { blocked: true, pending: already };
|
|
104
|
+
// 进场特效运行时页面最上层盖着覆盖层。它不接管鼠标(pointer-events:none),
|
|
105
|
+
// 拦不住点击,但 AI 动手时它该让开。放在对话框检查之后:页面被原生对话框
|
|
106
|
+
// 卡住时 evaluate 会一直挂着,那会把整个动作拖死,所以还加了个 300ms 上限。
|
|
107
|
+
await Promise.race([stopSwarm(page), new Promise((resolve) => setTimeout(resolve, 300))]);
|
|
102
108
|
const wait = session.provider.armDialogWait(page);
|
|
103
109
|
const outcome = action().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
104
110
|
const winner = await Promise.race([outcome, wait.promise]);
|
|
@@ -113,12 +119,20 @@ export async function navigate(session, url, waitUntil, signal) {
|
|
|
113
119
|
return session.run(async () => {
|
|
114
120
|
const target = assertAllowedUrl(url, session.provider.config.allowedDomains ?? []);
|
|
115
121
|
const page = await session.ensurePage();
|
|
122
|
+
// 提前启动要在导航**之前**打标记:addInitScript 读到它,就在页面脚本之前把
|
|
123
|
+
// 原版引擎拉起来(白底 + 18 个光标从第一帧就在),原页面不会闪出来。
|
|
124
|
+
if (willPlaySwarm(target.toString(), session.provider.config, session.provider.desiredHeadless())) {
|
|
125
|
+
await armCurtain(page);
|
|
126
|
+
}
|
|
116
127
|
try {
|
|
117
128
|
const racedNav = await session.runAction(page, () => withAbort(page.goto(target.toString(), { waitUntil, timeout: session.timeoutMs() }), signal));
|
|
118
129
|
if (racedNav.blocked)
|
|
119
130
|
return session.blockedSnapshot(page, racedNav.pending);
|
|
120
131
|
}
|
|
121
132
|
catch (error) {
|
|
133
|
+
// 导航没走成:标记还留在 window.name 里,而它跨导航保留。不清掉的话,
|
|
134
|
+
// 下一次普通跳转会意外提前启动引擎(2026-11-09 复核 find B)。
|
|
135
|
+
await clearCurtainToken(page);
|
|
122
136
|
if (isAbortError(error) || signal?.aborted === true)
|
|
123
137
|
throw error;
|
|
124
138
|
// A redirect chain can leave the allow-list: the guard refuses the hop and
|
|
@@ -126,7 +140,24 @@ export async function navigate(session, url, waitUntil, signal) {
|
|
|
126
140
|
session.throwIfNavigationBlocked();
|
|
127
141
|
throw asTimeoutError('navigation', error);
|
|
128
142
|
}
|
|
129
|
-
|
|
143
|
+
// **先放特效、再拍快照**:原版开场本身就是"页面被抹白 + 光标从两边飞进来打转",
|
|
144
|
+
// 让它立刻登场,用户就不会先看见网页、过一会儿才播动画(2026-11-09 用户的判断:
|
|
145
|
+
// 与其自己做一套过场,不如用原版那一套)。资产里"扫描页面"那一步被改成等放行信号,
|
|
146
|
+
// 所以拍快照期间页面不会被它滚走;快照拍完再放行。
|
|
147
|
+
maybePlaySwarm(page, session.provider.config, session.provider.desiredHeadless());
|
|
148
|
+
// 放行必须放 finally:快照阶段抛错、被原生弹窗拦住、导航被拒,都不能把页面
|
|
149
|
+
// 一直扣在白幕下等满 5 秒兜底(2026-11-09 复核建议 1)。
|
|
150
|
+
try {
|
|
151
|
+
return await session.settleAndSnapshot(page, true);
|
|
152
|
+
}
|
|
153
|
+
finally {
|
|
154
|
+
await page
|
|
155
|
+
.evaluate(() => {
|
|
156
|
+
;
|
|
157
|
+
window.__dshSwarmGo = true;
|
|
158
|
+
})
|
|
159
|
+
.catch(() => { });
|
|
160
|
+
}
|
|
130
161
|
});
|
|
131
162
|
}
|
|
132
163
|
/**
|
package/lib/provider.js
CHANGED
|
@@ -13,6 +13,8 @@ import { PlaywrightSession } from "./session.js";
|
|
|
13
13
|
import { getEffectiveHeadless, getEnabled, publishConfiguredEngine, publishLoginSites } from "./runtime-state.js";
|
|
14
14
|
import { BrowserError } from "./errors.js";
|
|
15
15
|
import { SNAPSHOT_SCRIPT } from "./injected.js";
|
|
16
|
+
import { swarmBootScript } from "./swarm-curtain.js";
|
|
17
|
+
import { forgetOnBrowserClose } from "./swarm-intro.js";
|
|
16
18
|
import { isWindowMinimized } from "./window-focus.js";
|
|
17
19
|
import { installHostGuard } from "./navigation-guard.js";
|
|
18
20
|
export class PlaywrightProvider {
|
|
@@ -267,11 +269,17 @@ export class PlaywrightProvider {
|
|
|
267
269
|
// Allow-list on the wire, not just at the entry points (navigation-guard.ts).
|
|
268
270
|
await this.installHostGuard(context);
|
|
269
271
|
await context.addInitScript(SNAPSHOT_SCRIPT);
|
|
272
|
+
// 提前启动「同一个引擎」:只有导航前打了标记的那次才在页面脚本之前启动原版特效
|
|
273
|
+
// (白底 + 18 个光标从第一帧就在),没标记的普通导航不会白闪。见 swarm-curtain.ts。
|
|
274
|
+
await context.addInitScript({ content: swarmBootScript() });
|
|
270
275
|
const session = new PlaywrightSession(this, owner, context);
|
|
271
276
|
const entry = { context, session, lastUsed: Date.now(), pending: 0, timer: undefined };
|
|
272
277
|
this.entries.set(owner, entry);
|
|
273
278
|
this.armIdle(entry);
|
|
274
279
|
void context.on('close', () => {
|
|
280
|
+
// 这个浏览器上下文关掉:默认只忘掉它自己那趟播过哪些站。
|
|
281
|
+
// 别的窗口还开着时不清它们的记录(2026-11-09 复核 find B)。
|
|
282
|
+
forgetOnBrowserClose(context);
|
|
275
283
|
if (this.entries.get(owner) === entry) {
|
|
276
284
|
if (entry.timer !== undefined)
|
|
277
285
|
clearTimeout(entry.timer);
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
+
* @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
|
|
14
|
+
* @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
|
|
15
|
+
* 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
|
|
16
|
+
* 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
|
|
17
|
+
*/
|
|
18
|
+
export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined', consequence?: string): Promise<T | undefined>;
|
|
19
|
+
/** 这个错误是不是「读内容超时」——调用方据此作废会话句柄,让下次自愈。 */
|
|
20
|
+
export declare function isReadTimeout(error: unknown): boolean;
|
|
21
|
+
/** 取消错误保持原样传递(不多包一层)。 */
|
|
22
|
+
export { isAbortError };
|
|
@@ -0,0 +1,77 @@
|
|
|
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
|
+
* @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
|
|
30
|
+
* @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
|
|
31
|
+
* 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
|
|
32
|
+
* 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
|
|
33
|
+
*/
|
|
34
|
+
export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject', consequence = 'The call was abandoned; the page may still be running its script.') {
|
|
35
|
+
const guarded = Promise.resolve(promise);
|
|
36
|
+
guarded.catch(() => { }); // 超时后原调用仍在跑:别让它的 rejection 冒到宿主
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let settled = false;
|
|
39
|
+
const finish = (act) => {
|
|
40
|
+
if (settled)
|
|
41
|
+
return;
|
|
42
|
+
settled = true;
|
|
43
|
+
clearTimeout(timer);
|
|
44
|
+
signal?.removeEventListener('abort', onAbort);
|
|
45
|
+
act();
|
|
46
|
+
};
|
|
47
|
+
const onAbort = () => finish(() => reject(new DOMException('The operation was aborted', 'AbortError')));
|
|
48
|
+
const timer = setTimeout(() => {
|
|
49
|
+
if (onTimeout === 'resolve-undefined') {
|
|
50
|
+
finish(() => resolve(undefined));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
finish(() => reject(new BrowserError('ACTION_TIMEOUT', '[ACTION_TIMEOUT] ' +
|
|
54
|
+
label +
|
|
55
|
+
' did not finish within ' +
|
|
56
|
+
String(timeoutMs) +
|
|
57
|
+
'ms; the page may be stuck running script. ' +
|
|
58
|
+
consequence)));
|
|
59
|
+
}, timeoutMs);
|
|
60
|
+
// 不要 unref:期限是「到点必须断」的承诺,unref 之后若这是进程里最后一个句柄,
|
|
61
|
+
// 事件循环会判定无事可做直接退出,调用方永远等不到这条 ACTION_TIMEOUT
|
|
62
|
+
// (tests/read-deadline.test.ts 就是这么被打成 cancelledByParent 的)。
|
|
63
|
+
if (signal !== undefined && typeof signal.addEventListener === 'function') {
|
|
64
|
+
if (signal.aborted)
|
|
65
|
+
onAbort();
|
|
66
|
+
else
|
|
67
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
68
|
+
}
|
|
69
|
+
guarded.then((value) => finish(() => resolve(value)), (error) => finish(() => reject(error)));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
/** 这个错误是不是「读内容超时」——调用方据此作废会话句柄,让下次自愈。 */
|
|
73
|
+
export function isReadTimeout(error) {
|
|
74
|
+
return error instanceof BrowserError && error.code === 'ACTION_TIMEOUT';
|
|
75
|
+
}
|
|
76
|
+
/** 取消错误保持原样传递(不多包一层)。 */
|
|
77
|
+
export { isAbortError };
|
package/lib/runtime-state.d.ts
CHANGED
|
@@ -27,6 +27,20 @@
|
|
|
27
27
|
import type { LoginSites } from './login-sites.ts';
|
|
28
28
|
/** How a page is read into a snapshot tree. */
|
|
29
29
|
export type SnapshotEngine = 'legacy' | 'aria';
|
|
30
|
+
/** 进场特效的播放参数(速度/时长/是否只播一次),只存用户改过的项。 */
|
|
31
|
+
export interface SwarmTuning {
|
|
32
|
+
speed?: number;
|
|
33
|
+
maxMs?: number;
|
|
34
|
+
oncePerSite?: boolean;
|
|
35
|
+
/** 遮罩页面(登录墙、密码框)也播。默认关:会把没解锁的内容画出来。 */
|
|
36
|
+
playOnVeiled?: boolean;
|
|
37
|
+
/** AI 要动手时怎么办:'auto-close' = 立刻关掉动画(默认);'keep-alive' = 动画继续但不挡点击。 */
|
|
38
|
+
whileAiWorks?: 'auto-close' | 'keep-alive';
|
|
39
|
+
/** 播放范围:只画首屏 / 整页(由插件接管滚动,一屏一屏画完)。 */
|
|
40
|
+
range?: 'viewport' | 'page';
|
|
41
|
+
/** 一直记住播过哪些站:关掉整个浏览器窗口也不重播。默认关(关窗就重播)。 */
|
|
42
|
+
persistPlayed?: boolean;
|
|
43
|
+
}
|
|
30
44
|
/** Current enable state (loads from disk on first call). */
|
|
31
45
|
export declare function getEnabled(): boolean;
|
|
32
46
|
/** Set the enable state. Returns true if the value actually changed. */
|
|
@@ -40,6 +54,18 @@ export declare function subscribe(fn: (enabled: boolean) => void): () => void;
|
|
|
40
54
|
export declare function getHeadless(): boolean | undefined;
|
|
41
55
|
/** Persist the window-mode choice. Returns true when the value changed. */
|
|
42
56
|
export declare function setHeadless(next: boolean): boolean;
|
|
57
|
+
/**
|
|
58
|
+
* 进场特效的开关:用户自己选过的值,没选过返回 undefined(由配置默认值说话)。
|
|
59
|
+
*/
|
|
60
|
+
export declare function getSwarm(): boolean | undefined;
|
|
61
|
+
/** 记下用户对进场特效的选择。Returns true when the value changed. */
|
|
62
|
+
export declare function setSwarm(next: boolean): boolean;
|
|
63
|
+
/** 用户自己选过吗?(设置卡用来区分「你选的」和「默认的」。) */
|
|
64
|
+
export declare function isSwarmUserChosen(): boolean;
|
|
65
|
+
/** 播放参数:只含用户改过的项,其余跟配置默认值。 */
|
|
66
|
+
export declare function getSwarmTuning(): SwarmTuning;
|
|
67
|
+
/** 改播放参数(只传要改的项)。Returns true when something changed. */
|
|
68
|
+
export declare function setSwarmTuning(patch: SwarmTuning): boolean;
|
|
43
69
|
/** Publish the configured default (launch.headless) for display. */
|
|
44
70
|
export declare function publishConfiguredHeadless(value: boolean): void;
|
|
45
71
|
/** The configured default, when the provider has published it. */
|
package/lib/runtime-state.js
CHANGED
|
@@ -14,6 +14,9 @@ let _enabled = true;
|
|
|
14
14
|
let _engine = undefined;
|
|
15
15
|
/** User's window-mode choice: true = headless, undefined = follow the config. */
|
|
16
16
|
let _headless = undefined;
|
|
17
|
+
/** 进场特效的开关:true/false = 用户自己选的,undefined = 跟配置的默认值。 */
|
|
18
|
+
let _swarm = undefined;
|
|
19
|
+
let _swarmTuning = undefined;
|
|
17
20
|
/** Config default pushed in by the provider (display only). */
|
|
18
21
|
let _configuredHeadless = undefined;
|
|
19
22
|
/** Default pushed in by the provider that is actually loaded (display only). */
|
|
@@ -40,6 +43,31 @@ function load() {
|
|
|
40
43
|
// Same contract as engine: absent/unknown means "follow the configured
|
|
41
44
|
// default", so files written before this setting existed stay valid.
|
|
42
45
|
_headless = typeof obj.headless === 'boolean' ? obj.headless : undefined;
|
|
46
|
+
// 同一个约定:没写过或值不认识 = 跟配置默认值,老状态文件照样有效。
|
|
47
|
+
_swarm = typeof obj.swarm === 'boolean' ? obj.swarm : undefined;
|
|
48
|
+
const tuning = obj.swarmTuning;
|
|
49
|
+
if (tuning !== undefined && tuning !== null && typeof tuning === 'object') {
|
|
50
|
+
// 只收认识的项;缺的项不写,读的时候自然落回配置默认值。
|
|
51
|
+
const next = {};
|
|
52
|
+
if (typeof tuning.speed === 'number')
|
|
53
|
+
next.speed = tuning.speed;
|
|
54
|
+
if (typeof tuning.maxMs === 'number')
|
|
55
|
+
next.maxMs = tuning.maxMs;
|
|
56
|
+
if (typeof tuning.oncePerSite === 'boolean')
|
|
57
|
+
next.oncePerSite = tuning.oncePerSite;
|
|
58
|
+
if (typeof tuning.playOnVeiled === 'boolean')
|
|
59
|
+
next.playOnVeiled = tuning.playOnVeiled;
|
|
60
|
+
if (tuning.whileAiWorks === 'auto-close' || tuning.whileAiWorks === 'keep-alive')
|
|
61
|
+
next.whileAiWorks = tuning.whileAiWorks;
|
|
62
|
+
if (tuning.range === 'viewport' || tuning.range === 'page')
|
|
63
|
+
next.range = tuning.range;
|
|
64
|
+
if (typeof tuning.persistPlayed === 'boolean')
|
|
65
|
+
next.persistPlayed = tuning.persistPlayed;
|
|
66
|
+
_swarmTuning = next;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
_swarmTuning = undefined;
|
|
70
|
+
}
|
|
43
71
|
}
|
|
44
72
|
}
|
|
45
73
|
catch {
|
|
@@ -69,6 +97,10 @@ function persist() {
|
|
|
69
97
|
const payload = _engine === undefined ? { enabled: _enabled } : { enabled: _enabled, engine: _engine };
|
|
70
98
|
if (_headless !== undefined)
|
|
71
99
|
payload.headless = _headless;
|
|
100
|
+
if (_swarm !== undefined)
|
|
101
|
+
payload.swarm = _swarm;
|
|
102
|
+
if (_swarmTuning !== undefined)
|
|
103
|
+
payload.swarmTuning = _swarmTuning;
|
|
72
104
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2));
|
|
73
105
|
fs.renameSync(tmp, target);
|
|
74
106
|
}
|
|
@@ -127,6 +159,74 @@ export function setHeadless(next) {
|
|
|
127
159
|
persist();
|
|
128
160
|
return true;
|
|
129
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* 进场特效的开关:用户自己选过的值,没选过返回 undefined(由配置默认值说话)。
|
|
164
|
+
*/
|
|
165
|
+
export function getSwarm() {
|
|
166
|
+
load();
|
|
167
|
+
return _swarm;
|
|
168
|
+
}
|
|
169
|
+
/** 记下用户对进场特效的选择。Returns true when the value changed. */
|
|
170
|
+
export function setSwarm(next) {
|
|
171
|
+
load();
|
|
172
|
+
if (typeof next !== 'boolean')
|
|
173
|
+
return false;
|
|
174
|
+
if (_swarm === next)
|
|
175
|
+
return false;
|
|
176
|
+
_swarm = next;
|
|
177
|
+
persist();
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/** 用户自己选过吗?(设置卡用来区分「你选的」和「默认的」。) */
|
|
181
|
+
export function isSwarmUserChosen() {
|
|
182
|
+
load();
|
|
183
|
+
return _swarm !== undefined;
|
|
184
|
+
}
|
|
185
|
+
/** 播放参数:只含用户改过的项,其余跟配置默认值。 */
|
|
186
|
+
export function getSwarmTuning() {
|
|
187
|
+
load();
|
|
188
|
+
return _swarmTuning ?? {};
|
|
189
|
+
}
|
|
190
|
+
/** 改播放参数(只传要改的项)。Returns true when something changed. */
|
|
191
|
+
export function setSwarmTuning(patch) {
|
|
192
|
+
load();
|
|
193
|
+
const next = { ...(_swarmTuning ?? {}) };
|
|
194
|
+
let changed = false;
|
|
195
|
+
if (typeof patch.speed === 'number' && next.speed !== patch.speed) {
|
|
196
|
+
next.speed = patch.speed;
|
|
197
|
+
changed = true;
|
|
198
|
+
}
|
|
199
|
+
if (typeof patch.maxMs === 'number' && next.maxMs !== patch.maxMs) {
|
|
200
|
+
next.maxMs = patch.maxMs;
|
|
201
|
+
changed = true;
|
|
202
|
+
}
|
|
203
|
+
if (typeof patch.oncePerSite === 'boolean' && next.oncePerSite !== patch.oncePerSite) {
|
|
204
|
+
next.oncePerSite = patch.oncePerSite;
|
|
205
|
+
changed = true;
|
|
206
|
+
}
|
|
207
|
+
if (typeof patch.playOnVeiled === 'boolean' && next.playOnVeiled !== patch.playOnVeiled) {
|
|
208
|
+
next.playOnVeiled = patch.playOnVeiled;
|
|
209
|
+
changed = true;
|
|
210
|
+
}
|
|
211
|
+
if ((patch.whileAiWorks === 'auto-close' || patch.whileAiWorks === 'keep-alive') &&
|
|
212
|
+
next.whileAiWorks !== patch.whileAiWorks) {
|
|
213
|
+
next.whileAiWorks = patch.whileAiWorks;
|
|
214
|
+
changed = true;
|
|
215
|
+
}
|
|
216
|
+
if ((patch.range === 'viewport' || patch.range === 'page') && next.range !== patch.range) {
|
|
217
|
+
next.range = patch.range;
|
|
218
|
+
changed = true;
|
|
219
|
+
}
|
|
220
|
+
if (typeof patch.persistPlayed === 'boolean' && next.persistPlayed !== patch.persistPlayed) {
|
|
221
|
+
next.persistPlayed = patch.persistPlayed;
|
|
222
|
+
changed = true;
|
|
223
|
+
}
|
|
224
|
+
if (!changed)
|
|
225
|
+
return false;
|
|
226
|
+
_swarmTuning = next;
|
|
227
|
+
persist();
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
130
230
|
/** Publish the configured default (launch.headless) for display. */
|
|
131
231
|
export function publishConfiguredHeadless(value) {
|
|
132
232
|
_configuredHeadless = value === true;
|
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.
|
|
@@ -138,6 +149,17 @@ export declare class PlaywrightSession {
|
|
|
138
149
|
capture(page: Page, opts: {
|
|
139
150
|
interactiveOnly?: boolean;
|
|
140
151
|
} | undefined, afterAction?: boolean): Promise<BrowserSnapshot>;
|
|
152
|
+
/**
|
|
153
|
+
* Close one page whose script has stopped answering, so the next acquire starts
|
|
154
|
+
* on a live tab instead of inheriting it. Bounded and best-effort: main thread
|
|
155
|
+
* wedged badly enough to stall an evaluate may never answer close either, and
|
|
156
|
+
* this runs on the error path — it must never turn a timeout into a second hang.
|
|
157
|
+
*
|
|
158
|
+
* 代价要说清楚:`runBeforeUnload:false` 意味着**不弹**「确定要离开吗」,所以页面上
|
|
159
|
+
* 填了一半的表单、没提交的内容直接丢弃;如果那是唯一一个标签,接下来是一张空白页。
|
|
160
|
+
* 对真卡死的页这笔账划算,但用户必须被告知——所以 capture() 的报错文案里写明这件事。
|
|
161
|
+
*/
|
|
162
|
+
private discardStuckPage;
|
|
141
163
|
/**
|
|
142
164
|
* Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
|
|
143
165
|
* An unavailable or unreadable aria tree (`missing`) degrades to the legacy
|
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
|
-
|
|
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,54 @@ 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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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, 'reject',
|
|
331
|
+
// 报错文案必须说人话、说真话:这一页真的被关掉了,页面里没提交的东西跟着没了。
|
|
332
|
+
// 关页不弹 beforeunload(弹了可能又卡死),所以用户只有从这条消息里才知道。
|
|
333
|
+
'That page has been closed; anything typed into it but not submitted is lost, and if it was the last tab the session now starts on a blank page.');
|
|
334
|
+
return snap;
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
// 超时只能让我们这一侧返回,页面里那个 evaluate 还在跑。作废会话句柄,
|
|
338
|
+
// 再把那张卡死的页面真正关掉——否则持久化模式下 ensurePage() 会把同一个
|
|
339
|
+
// 页面交还给下一次调用,所谓「自愈」只是换了个句柄继续超时(issue #5)。
|
|
340
|
+
if (isReadTimeout(error)) {
|
|
341
|
+
this.closed = true;
|
|
342
|
+
this.provider.invalidateOwner(this.owner);
|
|
343
|
+
await this.discardStuckPage(page);
|
|
344
|
+
}
|
|
345
|
+
throw error;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
/**
|
|
349
|
+
* Close one page whose script has stopped answering, so the next acquire starts
|
|
350
|
+
* on a live tab instead of inheriting it. Bounded and best-effort: main thread
|
|
351
|
+
* wedged badly enough to stall an evaluate may never answer close either, and
|
|
352
|
+
* this runs on the error path — it must never turn a timeout into a second hang.
|
|
353
|
+
*
|
|
354
|
+
* 代价要说清楚:`runBeforeUnload:false` 意味着**不弹**「确定要离开吗」,所以页面上
|
|
355
|
+
* 填了一半的表单、没提交的内容直接丢弃;如果那是唯一一个标签,接下来是一张空白页。
|
|
356
|
+
* 对真卡死的页这笔账划算,但用户必须被告知——所以 capture() 的报错文案里写明这件事。
|
|
357
|
+
*/
|
|
358
|
+
async discardStuckPage(page) {
|
|
359
|
+
try {
|
|
360
|
+
await withDeadline(page.close({ runBeforeUnload: false }), 2_000, 'closing the stuck page', undefined, 'resolve-undefined');
|
|
361
|
+
}
|
|
362
|
+
catch {
|
|
363
|
+
/* already gone, or too wedged to close: nothing better we can do */
|
|
364
|
+
}
|
|
304
365
|
}
|
|
305
366
|
/**
|
|
306
367
|
* Semantic-tree capture via Playwright's official ariaSnapshot(mode:'ai').
|