dsh-browser-playwright-codex 0.7.8 → 0.8.1
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 +92 -0
- package/NOTICE.md +26 -5
- package/README.md +33 -10
- package/assets/swarm-rebuild.js +3864 -0
- package/lib/browser-lifecycle.js +27 -1
- package/lib/challenge.d.ts +7 -4
- package/lib/challenge.js +28 -6
- package/lib/client.js +1245 -647
- package/lib/config.d.ts +26 -0
- package/lib/config.js +19 -0
- package/lib/errors.d.ts +17 -0
- package/lib/errors.js +28 -0
- package/lib/gates-patch.d.ts +41 -1
- package/lib/gates-patch.js +103 -5
- package/lib/injected.js +45 -0
- package/lib/page-actions.d.ts +24 -0
- package/lib/page-actions.js +73 -11
- package/lib/provider.js +8 -0
- package/lib/read-deadline.d.ts +5 -1
- package/lib/read-deadline.js +10 -3
- package/lib/runtime-state.d.ts +26 -0
- package/lib/runtime-state.js +100 -0
- package/lib/session.d.ts +11 -0
- package/lib/session.js +80 -11
- package/lib/settings-card.js +123 -0
- package/lib/snapshot-render.js +52 -7
- package/lib/swarm-contract.d.ts +232 -0
- package/lib/swarm-contract.js +215 -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 +829 -0
- package/lib/tool-types.d.ts +10 -0
- package/lib/tool.js +20 -6
- package/lib/types.d.ts +6 -0
- package/package.json +18 -13
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
|
+
/** 出厂默认关:动效资产随包分发(0.8.1 起已获作者授权),但默认不播;想要的人在设置卡里打开。 */
|
|
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
|
@@ -45,6 +45,25 @@ export const Config = z.object({
|
|
|
45
45
|
diff: z.boolean().default(false),
|
|
46
46
|
})
|
|
47
47
|
.default({ engine: 'legacy', maxNodes: 500, maxNameLength: 120, maxTextLength: 300, diff: false }),
|
|
48
|
+
swarm: z
|
|
49
|
+
.object({
|
|
50
|
+
// 出厂关:资产在包里(0.8.1 起已获作者授权),但默认不播,避免每次开新站先把页面盖住。
|
|
51
|
+
enabled: z.boolean().default(false),
|
|
52
|
+
// 只在每个网站第一次打开时播,避免每次导航都把页面盖住。
|
|
53
|
+
oncePerSite: z.boolean().default(true),
|
|
54
|
+
// 1–3 档,脚本里对应点几次速度按钮。
|
|
55
|
+
speed: z.number().default(3),
|
|
56
|
+
// 硬上限:到点无条件退回真实页面。原版脚本在长页面上要约 12 秒,留 15 秒。
|
|
57
|
+
maxMs: z.number().default(15000),
|
|
58
|
+
// 默认关:遮罩后面的内容没解锁就不该被画出来。自己的面板想看可以打开。
|
|
59
|
+
playOnVeiled: z.boolean().default(false),
|
|
60
|
+
// 默认关:整个浏览器窗口一关就忘掉这趟播过哪些站,下次重开全部重播。
|
|
61
|
+
persistPlayed: z.boolean().default(false),
|
|
62
|
+
// 只画首屏:长页面不动,短页面也不会卡在"下半页永远等不到"。想看整页的人
|
|
63
|
+
// 到设置卡选「整页」——那会把滚动接过去几秒,所以不做默认。
|
|
64
|
+
range: z.union([z.const('viewport'), z.const('page')]).default('viewport'),
|
|
65
|
+
})
|
|
66
|
+
.default({ enabled: false, oncePerSite: true, speed: 3, maxMs: 15000, playOnVeiled: false, persistPlayed: false }),
|
|
48
67
|
});
|
|
49
68
|
/** Channels probed in order when none is configured. */
|
|
50
69
|
export const AUTO_CHANNELS = ['chromium', 'chrome', 'msedge', 'edge'];
|
package/lib/errors.d.ts
CHANGED
|
@@ -35,3 +35,20 @@ export declare function evaluateDisabled(): BrowserError;
|
|
|
35
35
|
* @returns the denial, ready to throw.
|
|
36
36
|
*/
|
|
37
37
|
export declare function cdpDisabled(): BrowserError;
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a `browser_evaluate` result so it survives the harness's
|
|
40
|
+
* lossless-JSON output boundary.
|
|
41
|
+
*
|
|
42
|
+
* The harness (dsh-tools) rejects a tool result that is not losslessly
|
|
43
|
+
* JSON-serializable, and it is strictest about *top-level* shape: a bare array
|
|
44
|
+
* or `undefined` at the root fails its check with an opaque
|
|
45
|
+
* "value is not lossless JSON" that does not tell the model how to fix it. This
|
|
46
|
+
* wraps only those shapes — arrays, `undefined` and functions — into a JSON
|
|
47
|
+
* string plus a marker note, and leaves every other value (objects, strings,
|
|
48
|
+
* numbers, booleans, null) untouched so callers get back what their expression
|
|
49
|
+
* actually returned.
|
|
50
|
+
*
|
|
51
|
+
* @param value - the raw value the page expression evaluated to.
|
|
52
|
+
* @returns a lossless-JSON-safe value, with a note when it had to be stringified.
|
|
53
|
+
*/
|
|
54
|
+
export declare function normalizeEvaluateResult(value: unknown): unknown;
|
package/lib/errors.js
CHANGED
|
@@ -35,3 +35,31 @@ export function evaluateDisabled() {
|
|
|
35
35
|
export function cdpDisabled() {
|
|
36
36
|
return new BrowserError('CDP_DISABLED', "browser_cdp is disabled; set tool.allowCdp: true on the single '- id: browser' row of cordis.patch.yml (the Settings card rewrites that row for you) to enable raw CDP inspection commands");
|
|
37
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Normalize a `browser_evaluate` result so it survives the harness's
|
|
40
|
+
* lossless-JSON output boundary.
|
|
41
|
+
*
|
|
42
|
+
* The harness (dsh-tools) rejects a tool result that is not losslessly
|
|
43
|
+
* JSON-serializable, and it is strictest about *top-level* shape: a bare array
|
|
44
|
+
* or `undefined` at the root fails its check with an opaque
|
|
45
|
+
* "value is not lossless JSON" that does not tell the model how to fix it. This
|
|
46
|
+
* wraps only those shapes — arrays, `undefined` and functions — into a JSON
|
|
47
|
+
* string plus a marker note, and leaves every other value (objects, strings,
|
|
48
|
+
* numbers, booleans, null) untouched so callers get back what their expression
|
|
49
|
+
* actually returned.
|
|
50
|
+
*
|
|
51
|
+
* @param value - the raw value the page expression evaluated to.
|
|
52
|
+
* @returns a lossless-JSON-safe value, with a note when it had to be stringified.
|
|
53
|
+
*/
|
|
54
|
+
export function normalizeEvaluateResult(value) {
|
|
55
|
+
if (Array.isArray(value)) {
|
|
56
|
+
return { note: 'array converted to JSON string', value: JSON.stringify(value) };
|
|
57
|
+
}
|
|
58
|
+
if (value === undefined) {
|
|
59
|
+
return { note: 'expression returned undefined' };
|
|
60
|
+
}
|
|
61
|
+
if (typeof value === 'function') {
|
|
62
|
+
return { note: 'function converted to string', value: String(value) };
|
|
63
|
+
}
|
|
64
|
+
return value;
|
|
65
|
+
}
|
package/lib/gates-patch.d.ts
CHANGED
|
@@ -20,7 +20,46 @@ export declare function homePatchPath(env?: NodeJS.ProcessEnv): string;
|
|
|
20
20
|
* @returns the YAML block, markers included
|
|
21
21
|
*/
|
|
22
22
|
export declare function renderBrowserBlock(allowEvaluate: boolean, allowCdp: boolean, diagnostics: boolean): string;
|
|
23
|
-
/**
|
|
23
|
+
/** The three gate keys, in the order a freshly created block writes them. */
|
|
24
|
+
declare const GATE_KEYS: readonly ["allowEvaluate", "allowCdp", "diagnostics"];
|
|
25
|
+
/** One of the three gate keys this writer owns. */
|
|
26
|
+
type GateKey = (typeof GATE_KEYS)[number];
|
|
27
|
+
/** The gate values as a record, for line-level rewriting. */
|
|
28
|
+
type GateValues = Record<GateKey, boolean>;
|
|
29
|
+
/**
|
|
30
|
+
* Rewrite ONLY the three gate shortcuts inside an existing block, keeping every
|
|
31
|
+
* other line byte for byte.
|
|
32
|
+
*
|
|
33
|
+
* Why not re-render the whole block: the loader replaces an entry's `config`
|
|
34
|
+
* wholesale, so the block has to carry every key the user cares about — and it
|
|
35
|
+
* cannot know all of them. Re-rendering the template silently reset whatever
|
|
36
|
+
* the template did not list: `profileDir` (which wiped the saved login), the
|
|
37
|
+
* launch channel, `snapshot.engine`, `toolPrefix`, `maxWaitMs`,
|
|
38
|
+
* `interactiveOnlyDefault`, `registerDisabledTools`, the swarm section. Measured
|
|
39
|
+
* 2026-11-10: toggling one switch in the settings card logged the user out of
|
|
40
|
+
* every site, because their block said profileDir: 'D:/dsh-profile' and the
|
|
41
|
+
* template does not.
|
|
42
|
+
*
|
|
43
|
+
* Documented consequence, deliberately kept: keys the user never wrote stay
|
|
44
|
+
* unwritten (the package default applies), which is exactly what the user chose
|
|
45
|
+
* by not writing them.
|
|
46
|
+
*
|
|
47
|
+
* @param body - the full block text, markers included
|
|
48
|
+
* @param gates - the three gate values to write
|
|
49
|
+
* @returns the block with only those lines changed or added
|
|
50
|
+
*/
|
|
51
|
+
export declare function patchBlockGates(body: string, gates: GateValues): string;
|
|
52
|
+
/**
|
|
53
|
+
* Merge the gate values into `original`.
|
|
54
|
+
*
|
|
55
|
+
* - No managed block yet → append the full template (nothing to preserve).
|
|
56
|
+
* - Block present → rewrite ONLY the three gate lines (see patchBlockGates).
|
|
57
|
+
* - Block present but its structure defeats line editing (a flow-style
|
|
58
|
+
* `tool: { ... }`, a truncated block) → fall back to the whole template, which
|
|
59
|
+
* is the old behaviour: at least the switch the user just flipped takes
|
|
60
|
+
* effect. Guarded by a read-back check so a half-edited block can never be
|
|
61
|
+
* written out.
|
|
62
|
+
*/
|
|
24
63
|
export declare function mergeBrowserBlock(original: string, allowEvaluate: boolean, allowCdp: boolean, diagnostics: boolean): string;
|
|
25
64
|
/** Read the current managed block, or undefined when there is none. */
|
|
26
65
|
export declare function parseBrowserBlock(text: string): {
|
|
@@ -47,3 +86,4 @@ export declare function writeGates(allowEvaluate: boolean, allowCdp: boolean, di
|
|
|
47
86
|
allowCdp: boolean;
|
|
48
87
|
diagnostics: boolean;
|
|
49
88
|
}>;
|
|
89
|
+
export {};
|
package/lib/gates-patch.js
CHANGED
|
@@ -80,6 +80,82 @@ export function renderBrowserBlock(allowEvaluate, allowCdp, diagnostics) {
|
|
|
80
80
|
'',
|
|
81
81
|
].join('\n');
|
|
82
82
|
}
|
|
83
|
+
/** The three gate keys, in the order a freshly created block writes them. */
|
|
84
|
+
const GATE_KEYS = ['allowEvaluate', 'allowCdp', 'diagnostics'];
|
|
85
|
+
/** One `key: value # note` line of the managed block. */
|
|
86
|
+
const GATE_LINE = /^(\s*)(allowEvaluate|allowCdp|diagnostics)(\s*:\s*)(?:true|false)?[ \t]*(#.*)?$/;
|
|
87
|
+
/**
|
|
88
|
+
* Rewrite ONLY the three gate shortcuts inside an existing block, keeping every
|
|
89
|
+
* other line byte for byte.
|
|
90
|
+
*
|
|
91
|
+
* Why not re-render the whole block: the loader replaces an entry's `config`
|
|
92
|
+
* wholesale, so the block has to carry every key the user cares about — and it
|
|
93
|
+
* cannot know all of them. Re-rendering the template silently reset whatever
|
|
94
|
+
* the template did not list: `profileDir` (which wiped the saved login), the
|
|
95
|
+
* launch channel, `snapshot.engine`, `toolPrefix`, `maxWaitMs`,
|
|
96
|
+
* `interactiveOnlyDefault`, `registerDisabledTools`, the swarm section. Measured
|
|
97
|
+
* 2026-11-10: toggling one switch in the settings card logged the user out of
|
|
98
|
+
* every site, because their block said profileDir: 'D:/dsh-profile' and the
|
|
99
|
+
* template does not.
|
|
100
|
+
*
|
|
101
|
+
* Documented consequence, deliberately kept: keys the user never wrote stay
|
|
102
|
+
* unwritten (the package default applies), which is exactly what the user chose
|
|
103
|
+
* by not writing them.
|
|
104
|
+
*
|
|
105
|
+
* @param body - the full block text, markers included
|
|
106
|
+
* @param gates - the three gate values to write
|
|
107
|
+
* @returns the block with only those lines changed or added
|
|
108
|
+
*/
|
|
109
|
+
export function patchBlockGates(body, gates) {
|
|
110
|
+
const nl = body.includes('\r\n') ? '\r\n' : '\n';
|
|
111
|
+
const lines = body.split(/\r?\n/);
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
let lastGateAt = -1;
|
|
114
|
+
let gateIndent = '';
|
|
115
|
+
let toolAt = -1;
|
|
116
|
+
let configIndent;
|
|
117
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
118
|
+
const line = lines[index] ?? '';
|
|
119
|
+
const gate = GATE_LINE.exec(line);
|
|
120
|
+
if (gate !== null) {
|
|
121
|
+
const key = gate[2];
|
|
122
|
+
seen.add(key);
|
|
123
|
+
const indent = gate[1] ?? '';
|
|
124
|
+
const comment = gate[4] === undefined ? '' : ' ' + gate[4];
|
|
125
|
+
lines[index] = indent + key + ': ' + (gates[key] ? 'true' : 'false') + comment;
|
|
126
|
+
lastGateAt = index;
|
|
127
|
+
gateIndent = indent;
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (toolAt === -1 && /^\s*tool\s*:\s*(#.*)?$/.test(line))
|
|
131
|
+
toolAt = index;
|
|
132
|
+
const config = /^(\s*)config\s*:/.exec(line);
|
|
133
|
+
if (configIndent === undefined && config !== null)
|
|
134
|
+
configIndent = config[1] ?? '';
|
|
135
|
+
}
|
|
136
|
+
const missing = GATE_KEYS.filter((key) => !seen.has(key));
|
|
137
|
+
if (missing.length === 0)
|
|
138
|
+
return lines.join(nl);
|
|
139
|
+
const rendered = missing.map((key) => key + ': ' + (gates[key] ? 'true' : 'false'));
|
|
140
|
+
if (lastGateAt >= 0) {
|
|
141
|
+
// Beside the gates already present: same indentation, same section.
|
|
142
|
+
lines.splice(lastGateAt + 1, 0, ...rendered.map((line) => gateIndent + line));
|
|
143
|
+
return lines.join(nl);
|
|
144
|
+
}
|
|
145
|
+
if (toolAt >= 0) {
|
|
146
|
+
const indent = (/^\s*/.exec(lines[toolAt] ?? '')?.[0] ?? '') + ' ';
|
|
147
|
+
lines.splice(toolAt + 1, 0, ...rendered.map((line) => indent + line));
|
|
148
|
+
return lines.join(nl);
|
|
149
|
+
}
|
|
150
|
+
// No tool section at all: add one just above the closing marker, inside config.
|
|
151
|
+
const endAt = lines.findIndex((line) => line.trim() === END || line.trim() === LEGACY_END);
|
|
152
|
+
const configPad = (configIndent ?? '') + ' ';
|
|
153
|
+
const insert = [configPad + 'tool:', ...rendered.map((line) => configPad + ' ' + line)];
|
|
154
|
+
if (endAt === -1)
|
|
155
|
+
return lines.concat(insert).join(nl);
|
|
156
|
+
lines.splice(endAt, 0, ...insert);
|
|
157
|
+
return lines.join(nl);
|
|
158
|
+
}
|
|
83
159
|
/** Locate the managed block, preferring the current markers over the legacy ones. */
|
|
84
160
|
function findBlock(original) {
|
|
85
161
|
const markers = [
|
|
@@ -98,13 +174,35 @@ function findBlock(original) {
|
|
|
98
174
|
}
|
|
99
175
|
return undefined;
|
|
100
176
|
}
|
|
101
|
-
/**
|
|
177
|
+
/**
|
|
178
|
+
* Merge the gate values into `original`.
|
|
179
|
+
*
|
|
180
|
+
* - No managed block yet → append the full template (nothing to preserve).
|
|
181
|
+
* - Block present → rewrite ONLY the three gate lines (see patchBlockGates).
|
|
182
|
+
* - Block present but its structure defeats line editing (a flow-style
|
|
183
|
+
* `tool: { ... }`, a truncated block) → fall back to the whole template, which
|
|
184
|
+
* is the old behaviour: at least the switch the user just flipped takes
|
|
185
|
+
* effect. Guarded by a read-back check so a half-edited block can never be
|
|
186
|
+
* written out.
|
|
187
|
+
*/
|
|
102
188
|
export function mergeBrowserBlock(original, allowEvaluate, allowCdp, diagnostics) {
|
|
103
|
-
const
|
|
189
|
+
const gates = { allowEvaluate, allowCdp, diagnostics };
|
|
104
190
|
const found = findBlock(original);
|
|
105
|
-
if (found
|
|
106
|
-
|
|
107
|
-
|
|
191
|
+
if (found === undefined) {
|
|
192
|
+
const block = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
|
|
193
|
+
return (original === '' || original.endsWith('\n') ? original : original + '\n') + '\n' + block;
|
|
194
|
+
}
|
|
195
|
+
const current = original.slice(found.start, found.after);
|
|
196
|
+
const edited = patchBlockGates(current, gates);
|
|
197
|
+
const check = parseBrowserBlock(edited);
|
|
198
|
+
if (check !== undefined &&
|
|
199
|
+
check.allowEvaluate === allowEvaluate &&
|
|
200
|
+
check.allowCdp === allowCdp &&
|
|
201
|
+
check.diagnostics === diagnostics) {
|
|
202
|
+
return original.slice(0, found.start) + edited + original.slice(found.after);
|
|
203
|
+
}
|
|
204
|
+
const rebuilt = renderBrowserBlock(allowEvaluate, allowCdp, diagnostics);
|
|
205
|
+
return original.slice(0, found.start) + rebuilt + original.slice(found.after);
|
|
108
206
|
}
|
|
109
207
|
/** Read the current managed block, or undefined when there is none. */
|
|
110
208
|
export function parseBrowserBlock(text) {
|
package/lib/injected.js
CHANGED
|
@@ -66,6 +66,22 @@ export const SNAPSHOT_SCRIPT = String.raw `
|
|
|
66
66
|
return text.length > max ? text.slice(0, max) + '…' : text
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Autocomplete tokens that mark a credential field. Mirrors CREDENTIAL_AUTOCOMPLETE
|
|
70
|
+
// in page-actions.ts so the snapshot never reads back what the user typed into one.
|
|
71
|
+
const CREDENTIAL_AUTOCOMPLETE = ['current-password', 'new-password', 'one-time-code', 'cc-number', 'cc-csc', 'cc-exp', 'cc-exp-month', 'cc-exp-year']
|
|
72
|
+
|
|
73
|
+
// Whether an element holds a secret (password, one-time code, card number).
|
|
74
|
+
// Same judgement as isCredentialField in page-actions.ts: type=password, or an
|
|
75
|
+
// autocomplete token list containing a credential token (not just the last one).
|
|
76
|
+
function isCredentialEl(el) {
|
|
77
|
+
const tag = el.tagName.toLowerCase()
|
|
78
|
+
if (tag !== 'input' && tag !== 'textarea') return false
|
|
79
|
+
const type = tag === 'input' ? (el.getAttribute('type') || '').toLowerCase() : ''
|
|
80
|
+
if (type === 'password') return true
|
|
81
|
+
const tokens = (el.getAttribute('autocomplete') || '').toLowerCase().split(/\s+/)
|
|
82
|
+
return tokens.some((token) => CREDENTIAL_AUTOCOMPLETE.indexOf(token) !== -1)
|
|
83
|
+
}
|
|
84
|
+
|
|
69
85
|
function ownText(el, maxText) {
|
|
70
86
|
let out = ''
|
|
71
87
|
for (const node of el.childNodes) {
|
|
@@ -77,6 +93,13 @@ export const SNAPSHOT_SCRIPT = String.raw `
|
|
|
77
93
|
}
|
|
78
94
|
|
|
79
95
|
function nameOf(el, maxName, maxText, actionable) {
|
|
96
|
+
// An <option> names itself with its own text — never the enclosing <label> or
|
|
97
|
+
// <select>, whose textContent contains every option. Without this every option
|
|
98
|
+
// in one <select> shares the same long name and the model cannot tell them
|
|
99
|
+
// apart (measured 2026-11-11 on selenium.dev's web-form).
|
|
100
|
+
if (el.tagName.toLowerCase() === 'option') {
|
|
101
|
+
return clip((el.textContent || '').replace(/\s+/g, ' ').trim(), maxName)
|
|
102
|
+
}
|
|
80
103
|
const aria = el.getAttribute('aria-label')
|
|
81
104
|
if (aria && aria.trim()) return clip(aria.trim(), maxName)
|
|
82
105
|
if (el.id) {
|
|
@@ -256,6 +279,28 @@ export const SNAPSHOT_SCRIPT = String.raw `
|
|
|
256
279
|
if (el.tagName.toLowerCase() === 'input' && (role === 'checkbox' || role === 'radio')) node.checked = !!el.checked
|
|
257
280
|
if (role === 'option') node.selected = !!el.selected
|
|
258
281
|
if (el.disabled) node.disabled = true
|
|
282
|
+
// Expose the current value of a text entry field so the model can see what a
|
|
283
|
+
// fill actually left behind, and so a value change registers as page change.
|
|
284
|
+
// Credential fields (password, one-time code, card number) are excluded: the
|
|
285
|
+
// user typed those in the visible window, and reading them back into the
|
|
286
|
+
// snapshot would send the secret to the model API. Mirrors isCredentialField
|
|
287
|
+
// in page-actions.ts.
|
|
288
|
+
//
|
|
289
|
+
// A real <select> is left out on purpose: its NAME already shows the chosen
|
|
290
|
+
// option (the branch above), so a value flag would print the same fact twice.
|
|
291
|
+
// Excluding by TAG, not by role, matters: autocomplete search boxes routinely
|
|
292
|
+
// carry an explicit role="combobox" and their value must still be visible.
|
|
293
|
+
// An <option> carries its value attribute only when it differs from its text.
|
|
294
|
+
const isSelectEl = el.tagName.toLowerCase() === 'select'
|
|
295
|
+
const textEntry =
|
|
296
|
+
role === 'textbox' || role === 'textarea' || role === 'searchbox' || role === 'combobox'
|
|
297
|
+
if (textEntry && !isSelectEl && !isCredentialEl(el)) {
|
|
298
|
+
const v = (el.value !== undefined ? String(el.value) : '').replace(/\s+/g, ' ').trim()
|
|
299
|
+
if (v !== '') node.value = clip(v, opts.maxTextLength)
|
|
300
|
+
} else if (role === 'option') {
|
|
301
|
+
const ov = (el.getAttribute('value') || '').trim()
|
|
302
|
+
if (ov !== '' && ov !== nodeName) node.value = clip(ov, opts.maxNameLength)
|
|
303
|
+
}
|
|
259
304
|
if (role === 'link') {
|
|
260
305
|
const href = el.getAttribute('href')
|
|
261
306
|
if (href && href.trim() && href.indexOf('javascript:') !== 0) node.href = href
|
package/lib/page-actions.d.ts
CHANGED
|
@@ -84,6 +84,30 @@ export declare function isCredentialField(spec: {
|
|
|
84
84
|
type?: string;
|
|
85
85
|
autocomplete?: string;
|
|
86
86
|
}): boolean;
|
|
87
|
+
/** What the live element says about itself, as far as the credential check needs. */
|
|
88
|
+
export interface FieldSpec {
|
|
89
|
+
tag: string;
|
|
90
|
+
type: string;
|
|
91
|
+
autocomplete: string;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Read tag/type/autocomplete from the live element — and refuse to guess.
|
|
95
|
+
*
|
|
96
|
+
* `browser_fill` promises it never types a secret. That promise used to depend
|
|
97
|
+
* on a read whose failure was swallowed (`.catch(() => ({tag:'input', type:'',
|
|
98
|
+
* autocomplete:''}))`): an unreadable field produced a spec that looks like a
|
|
99
|
+
* plain text box, the credential check said "not a credential", and the fill
|
|
100
|
+
* went ahead. Fail-open on a page whose attributes we cannot read is exactly
|
|
101
|
+
* the case where the answer is least certain, so it now fails closed: no spec,
|
|
102
|
+
* nothing typed, CREDENTIAL_FIELD with a clear next step.
|
|
103
|
+
*
|
|
104
|
+
* Cancel and deadline are NOT credential failures: they pass through untouched
|
|
105
|
+
* so the caller reports what actually happened.
|
|
106
|
+
*
|
|
107
|
+
* @param locator - the resolved target
|
|
108
|
+
* @returns the field spec read from the page
|
|
109
|
+
*/
|
|
110
|
+
export declare function readFieldSpec(locator: Locator): Promise<FieldSpec>;
|
|
87
111
|
export declare function fill(session: PlaywrightSession, ref: string, text: string, signal: AbortSignal | undefined): Promise<import("./types.ts").BrowserSnapshot>;
|
|
88
112
|
/**
|
|
89
113
|
* Normalize a field value before comparing. Two levels of forgiveness, both of them
|
package/lib/page-actions.js
CHANGED
|
@@ -7,8 +7,11 @@ import { clickTargetHrefProbe, hitTest } from "./page-probes.js";
|
|
|
7
7
|
import { BrowserError } from "./errors.js";
|
|
8
8
|
import { assertAllowedUrl } from "./url-policy.js";
|
|
9
9
|
import { asTimeoutError, isAbortError } from "./page-diagnostics.js";
|
|
10
|
+
import { isReadTimeout } from "./read-deadline.js";
|
|
10
11
|
import { clearLabelTargets, labelTargetProbe } from "./page-probes.js";
|
|
11
12
|
import { createBackgroundPage, isWindowMinimized } from "./window-focus.js";
|
|
13
|
+
import { maybePlaySwarm, stopSwarm, willPlaySwarm } from "./swarm-intro.js";
|
|
14
|
+
import { armCurtain, clearCurtainToken } from "./swarm-curtain.js";
|
|
12
15
|
/** Wrap a Playwright op so an aborted signal rejects while the op keeps draining in the background. */
|
|
13
16
|
export async function withAbort(promise, signal) {
|
|
14
17
|
// Attach handlers FIRST: the caller's promise may still be draining (a
|
|
@@ -99,6 +102,10 @@ export async function runAction(session, page, action) {
|
|
|
99
102
|
const already = session.provider.pendingDialogFor(page);
|
|
100
103
|
if (already !== undefined)
|
|
101
104
|
return { blocked: true, pending: already };
|
|
105
|
+
// 进场特效运行时页面最上层盖着覆盖层。它不接管鼠标(pointer-events:none),
|
|
106
|
+
// 拦不住点击,但 AI 动手时它该让开。放在对话框检查之后:页面被原生对话框
|
|
107
|
+
// 卡住时 evaluate 会一直挂着,那会把整个动作拖死,所以还加了个 300ms 上限。
|
|
108
|
+
await Promise.race([stopSwarm(page), new Promise((resolve) => setTimeout(resolve, 300))]);
|
|
102
109
|
const wait = session.provider.armDialogWait(page);
|
|
103
110
|
const outcome = action().then((value) => ({ ok: true, value }), (error) => ({ ok: false, error }));
|
|
104
111
|
const winner = await Promise.race([outcome, wait.promise]);
|
|
@@ -113,12 +120,20 @@ export async function navigate(session, url, waitUntil, signal) {
|
|
|
113
120
|
return session.run(async () => {
|
|
114
121
|
const target = assertAllowedUrl(url, session.provider.config.allowedDomains ?? []);
|
|
115
122
|
const page = await session.ensurePage();
|
|
123
|
+
// 提前启动要在导航**之前**打标记:addInitScript 读到它,就在页面脚本之前把
|
|
124
|
+
// 原版引擎拉起来(白底 + 18 个光标从第一帧就在),原页面不会闪出来。
|
|
125
|
+
if (willPlaySwarm(target.toString(), session.provider.config, session.provider.desiredHeadless())) {
|
|
126
|
+
await armCurtain(page);
|
|
127
|
+
}
|
|
116
128
|
try {
|
|
117
129
|
const racedNav = await session.runAction(page, () => withAbort(page.goto(target.toString(), { waitUntil, timeout: session.timeoutMs() }), signal));
|
|
118
130
|
if (racedNav.blocked)
|
|
119
131
|
return session.blockedSnapshot(page, racedNav.pending);
|
|
120
132
|
}
|
|
121
133
|
catch (error) {
|
|
134
|
+
// 导航没走成:标记还留在 window.name 里,而它跨导航保留。不清掉的话,
|
|
135
|
+
// 下一次普通跳转会意外提前启动引擎(2026-11-09 复核 find B)。
|
|
136
|
+
await clearCurtainToken(page);
|
|
122
137
|
if (isAbortError(error) || signal?.aborted === true)
|
|
123
138
|
throw error;
|
|
124
139
|
// A redirect chain can leave the allow-list: the guard refuses the hop and
|
|
@@ -126,7 +141,24 @@ export async function navigate(session, url, waitUntil, signal) {
|
|
|
126
141
|
session.throwIfNavigationBlocked();
|
|
127
142
|
throw asTimeoutError('navigation', error);
|
|
128
143
|
}
|
|
129
|
-
|
|
144
|
+
// **先放特效、再拍快照**:原版开场本身就是"页面被抹白 + 光标从两边飞进来打转",
|
|
145
|
+
// 让它立刻登场,用户就不会先看见网页、过一会儿才播动画(2026-11-09 用户的判断:
|
|
146
|
+
// 与其自己做一套过场,不如用原版那一套)。资产里"扫描页面"那一步被改成等放行信号,
|
|
147
|
+
// 所以拍快照期间页面不会被它滚走;快照拍完再放行。
|
|
148
|
+
maybePlaySwarm(page, session.provider.config, session.provider.desiredHeadless());
|
|
149
|
+
// 放行必须放 finally:快照阶段抛错、被原生弹窗拦住、导航被拒,都不能把页面
|
|
150
|
+
// 一直扣在白幕下等满 5 秒兜底(2026-11-09 复核建议 1)。
|
|
151
|
+
try {
|
|
152
|
+
return await session.settleAndSnapshot(page, true);
|
|
153
|
+
}
|
|
154
|
+
finally {
|
|
155
|
+
await page
|
|
156
|
+
.evaluate(() => {
|
|
157
|
+
;
|
|
158
|
+
window.__dshSwarmGo = true;
|
|
159
|
+
})
|
|
160
|
+
.catch(() => { });
|
|
161
|
+
}
|
|
130
162
|
});
|
|
131
163
|
}
|
|
132
164
|
/**
|
|
@@ -511,9 +543,45 @@ export function isCredentialField(spec) {
|
|
|
511
543
|
return false;
|
|
512
544
|
if ((spec.type ?? '').toLowerCase() === 'password')
|
|
513
545
|
return true;
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
546
|
+
// Every token, not just the last one. `autocomplete` is a space-separated
|
|
547
|
+
// token list ("section-a shipping new-password webauthn") and only its LAST
|
|
548
|
+
// token used to be checked, so any credential token that was not written last
|
|
549
|
+
// read as an ordinary field (measured 2026-11-10).
|
|
550
|
+
const tokens = (spec.autocomplete ?? '').toLowerCase().split(/\s+/);
|
|
551
|
+
return tokens.some((token) => CREDENTIAL_AUTOCOMPLETE.has(token));
|
|
552
|
+
}
|
|
553
|
+
/**
|
|
554
|
+
* Read tag/type/autocomplete from the live element — and refuse to guess.
|
|
555
|
+
*
|
|
556
|
+
* `browser_fill` promises it never types a secret. That promise used to depend
|
|
557
|
+
* on a read whose failure was swallowed (`.catch(() => ({tag:'input', type:'',
|
|
558
|
+
* autocomplete:''}))`): an unreadable field produced a spec that looks like a
|
|
559
|
+
* plain text box, the credential check said "not a credential", and the fill
|
|
560
|
+
* went ahead. Fail-open on a page whose attributes we cannot read is exactly
|
|
561
|
+
* the case where the answer is least certain, so it now fails closed: no spec,
|
|
562
|
+
* nothing typed, CREDENTIAL_FIELD with a clear next step.
|
|
563
|
+
*
|
|
564
|
+
* Cancel and deadline are NOT credential failures: they pass through untouched
|
|
565
|
+
* so the caller reports what actually happened.
|
|
566
|
+
*
|
|
567
|
+
* @param locator - the resolved target
|
|
568
|
+
* @returns the field spec read from the page
|
|
569
|
+
*/
|
|
570
|
+
export async function readFieldSpec(locator) {
|
|
571
|
+
try {
|
|
572
|
+
return await locator.evaluate((el) => {
|
|
573
|
+
const tag = el.tagName.toLowerCase();
|
|
574
|
+
const type = tag === 'input' ? String(el.type || '').toLowerCase() : '';
|
|
575
|
+
return { tag, type, autocomplete: el.getAttribute('autocomplete') ?? '' };
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
catch (error) {
|
|
579
|
+
if (isAbortError(error) || isReadTimeout(error))
|
|
580
|
+
throw error;
|
|
581
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
582
|
+
throw new BrowserError('CREDENTIAL_FIELD', "browser_fill could not read this field's attributes, so it cannot rule out a password / one-time code / card field and typed nothing. Take a fresh snapshot (the element may have been replaced) and retry, or ask the user to fill it in the visible window. Underlying failure: " +
|
|
583
|
+
detail.slice(0, 200));
|
|
584
|
+
}
|
|
517
585
|
}
|
|
518
586
|
export async function fill(session, ref, text, signal) {
|
|
519
587
|
return session.run(async () => {
|
|
@@ -521,13 +589,7 @@ export async function fill(session, ref, text, signal) {
|
|
|
521
589
|
const page = session.currentPageSync();
|
|
522
590
|
await session.waitForElementStable(locator);
|
|
523
591
|
await session.assertActionable(locator, { editable: true });
|
|
524
|
-
const spec = await locator
|
|
525
|
-
.evaluate((el) => {
|
|
526
|
-
const tag = el.tagName.toLowerCase();
|
|
527
|
-
const type = tag === 'input' ? String(el.type || '').toLowerCase() : '';
|
|
528
|
-
return { tag, type, autocomplete: el.getAttribute('autocomplete') ?? '' };
|
|
529
|
-
})
|
|
530
|
-
.catch(() => ({ tag: 'input', type: '', autocomplete: '' }));
|
|
592
|
+
const spec = await readFieldSpec(locator);
|
|
531
593
|
if (isCredentialField(spec)) {
|
|
532
594
|
throw new BrowserError('CREDENTIAL_FIELD', 'browser_fill refuses credential fields (password, one-time code, card number): a human types those in the visible window. Ask the user to type it there, then take a fresh snapshot and continue with the rest.');
|
|
533
595
|
}
|
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);
|
package/lib/read-deadline.d.ts
CHANGED
|
@@ -10,8 +10,12 @@ import { isAbortError } from './page-diagnostics.ts';
|
|
|
10
10
|
* @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
|
|
11
11
|
* @param label - 超时消息里说明卡在哪一步
|
|
12
12
|
* @param signal - 可选的取消信号
|
|
13
|
+
* @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
|
|
14
|
+
* @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
|
|
15
|
+
* 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
|
|
16
|
+
* 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
|
|
13
17
|
*/
|
|
14
|
-
export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined'): Promise<T | undefined>;
|
|
18
|
+
export declare function withDeadline<T>(promise: Promise<T>, timeoutMs: number, label: string, signal?: AbortSignal, onTimeout?: 'reject' | 'resolve-undefined', consequence?: string): Promise<T | undefined>;
|
|
15
19
|
/** 这个错误是不是「读内容超时」——调用方据此作废会话句柄,让下次自愈。 */
|
|
16
20
|
export declare function isReadTimeout(error: unknown): boolean;
|
|
17
21
|
/** 取消错误保持原样传递(不多包一层)。 */
|
package/lib/read-deadline.js
CHANGED
|
@@ -26,8 +26,12 @@ import { isAbortError } from "./page-diagnostics.js";
|
|
|
26
26
|
* @param timeoutMs - 硬期限(毫秒),应来自部署配置,且已校验有下限
|
|
27
27
|
* @param label - 超时消息里说明卡在哪一步
|
|
28
28
|
* @param signal - 可选的取消信号
|
|
29
|
+
* @param onTimeout - `reject` 抛 `ACTION_TIMEOUT`;`resolve-undefined` 供可降级场景
|
|
30
|
+
* @param consequence - 超时之后**实际发生了什么**。必须写真的后果:这里只报超时,
|
|
31
|
+
* 后续清理由调用方负责,别在这里替它许诺「下次会重开会话」——0.7.9 的超时消息
|
|
32
|
+
* 就写着这句,而实际做的是把那一页关掉,用户看到标签页凭空少了一个却没人告诉他。
|
|
29
33
|
*/
|
|
30
|
-
export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject') {
|
|
34
|
+
export async function withDeadline(promise, timeoutMs, label, signal, onTimeout = 'reject', consequence = 'The call was abandoned; the page may still be running its script.') {
|
|
31
35
|
const guarded = Promise.resolve(promise);
|
|
32
36
|
guarded.catch(() => { }); // 超时后原调用仍在跑:别让它的 rejection 冒到宿主
|
|
33
37
|
return new Promise((resolve, reject) => {
|
|
@@ -50,9 +54,12 @@ export async function withDeadline(promise, timeoutMs, label, signal, onTimeout
|
|
|
50
54
|
label +
|
|
51
55
|
' did not finish within ' +
|
|
52
56
|
String(timeoutMs) +
|
|
53
|
-
'ms; the page may be stuck running script.
|
|
57
|
+
'ms; the page may be stuck running script. ' +
|
|
58
|
+
consequence)));
|
|
54
59
|
}, timeoutMs);
|
|
55
|
-
|
|
60
|
+
// 不要 unref:期限是「到点必须断」的承诺,unref 之后若这是进程里最后一个句柄,
|
|
61
|
+
// 事件循环会判定无事可做直接退出,调用方永远等不到这条 ACTION_TIMEOUT
|
|
62
|
+
// (tests/read-deadline.test.ts 就是这么被打成 cancelledByParent 的)。
|
|
56
63
|
if (signal !== undefined && typeof signal.addEventListener === 'function') {
|
|
57
64
|
if (signal.aborted)
|
|
58
65
|
onAbort();
|
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. */
|