flower-trellis 0.5.3 → 0.5.4
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/enhancements/0.6/.agents/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/0.6/.claude/skills/trellis-release/SKILL.md +1 -1
- package/enhancements/MANIFEST.json +2 -2
- package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +82 -31
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +99 -210
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +256 -181
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +60 -29
- package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +82 -31
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/browser-controller.js +772 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/config.js +54 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/dashboard.html +16 -13
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/inject.js +84 -78
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +99 -210
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +256 -181
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/network-capture.js +339 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/package.json +2 -1
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-controller.test.js +90 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/browser-smoke.test.js +121 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/control-http.test.js +100 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/logger.test.js +14 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/network-capture.test.js +199 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/trace.test.js +36 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/control-client.js +95 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/jsonl-to-trace.js +122 -5
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/package.json +3 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +60 -29
- package/package.json +3 -3
- package/src/commands/init.js +13 -2
- package/src/commands/plugin.js +8 -4
- package/src/commands/update.js +7 -1
- package/src/lib/banner.js +10 -1
- package/src/lib/command-completion.js +43 -0
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
const fs = require('fs');
|
|
2
|
+
const path = require('path');
|
|
3
|
+
const {
|
|
4
|
+
MAX_OBSERVE_ARIA_BYTES,
|
|
5
|
+
MAX_OBSERVE_DOM_BYTES,
|
|
6
|
+
MAX_OBSERVE_ELEMENTS,
|
|
7
|
+
MAX_OBSERVE_FRAMES,
|
|
8
|
+
MAX_OBSERVE_METADATA_BYTES,
|
|
9
|
+
MAX_OBSERVE_SCREENSHOT_BYTES,
|
|
10
|
+
MAX_OBSERVE_TEXT_BYTES,
|
|
11
|
+
} = require('./config');
|
|
12
|
+
const { truncateUtf8 } = require('./network-capture');
|
|
13
|
+
|
|
14
|
+
const TARGET_KEYS = ['selector', 'role', 'text', 'label', 'placeholder', 'testId'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 带稳定错误码和 HTTP 状态的浏览器控制错误。
|
|
18
|
+
*/
|
|
19
|
+
class ControlError extends Error {
|
|
20
|
+
/**
|
|
21
|
+
* 创建控制错误。
|
|
22
|
+
*
|
|
23
|
+
* @param {string} code 稳定错误码。
|
|
24
|
+
* @param {string} message 人类可读说明。
|
|
25
|
+
* @param {Object|null} [details] 补充信息。
|
|
26
|
+
* @param {number} [statusCode] HTTP 状态码。
|
|
27
|
+
*/
|
|
28
|
+
constructor(code, message, details = null, statusCode = 400) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = 'ControlError';
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.details = details;
|
|
33
|
+
this.statusCode = statusCode;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 截取字符串并保留 UTF-8 字节边界。
|
|
39
|
+
*
|
|
40
|
+
* @param {string} value 原始文本。
|
|
41
|
+
* @param {number} maxBytes 最大字节数。
|
|
42
|
+
* @return {{value:string,size:number,truncated:boolean}} 截取结果。
|
|
43
|
+
*/
|
|
44
|
+
function limitText(value, maxBytes) {
|
|
45
|
+
const captured = truncateUtf8(Buffer.from(String(value || ''), 'utf8'), maxBytes);
|
|
46
|
+
return { value: captured.text, size: captured.size, truncated: captured.truncated };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 把文件名中的不稳定字符替换为安全字符。
|
|
51
|
+
*
|
|
52
|
+
* @param {string} value 原始文件名片段。
|
|
53
|
+
* @return {string} 安全文件名片段。
|
|
54
|
+
*/
|
|
55
|
+
function safeFilePart(value) {
|
|
56
|
+
return String(value || 'frame').replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 80) || 'frame';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 统一封装当前录制浏览器的页面感知和操作能力。
|
|
61
|
+
*/
|
|
62
|
+
class BrowserController {
|
|
63
|
+
/**
|
|
64
|
+
* 创建浏览器控制器。
|
|
65
|
+
*
|
|
66
|
+
* @param {Object} options 配置项。
|
|
67
|
+
* @param {()=>import('playwright').BrowserContext|null} options.getContext 获取当前 BrowserContext。
|
|
68
|
+
* @param {string} options.sessionDir 当前会话目录。
|
|
69
|
+
*/
|
|
70
|
+
constructor(options) {
|
|
71
|
+
this.getContext = options.getContext;
|
|
72
|
+
this.sessionDir = options.sessionDir;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* 分派 HTTP control action。
|
|
77
|
+
*
|
|
78
|
+
* @param {string} action 动作名。
|
|
79
|
+
* @param {Object} [body] 动作参数。
|
|
80
|
+
* @return {Promise<Object|Array<Object>>} 结构化执行结果。
|
|
81
|
+
*/
|
|
82
|
+
async dispatch(action, body = {}) {
|
|
83
|
+
switch (action) {
|
|
84
|
+
case 'pages': return this.listPages();
|
|
85
|
+
case 'open': return this.open(body.url, body);
|
|
86
|
+
case 'close': return this.close(body.index);
|
|
87
|
+
case 'focus': return this.focus(body.index);
|
|
88
|
+
case 'reload': return this.reload(body.index, body);
|
|
89
|
+
case 'back': return this.back(body.index, body);
|
|
90
|
+
case 'forward': return this.forward(body.index, body);
|
|
91
|
+
case 'navigate': return this.navigate(body.index, body.url, body);
|
|
92
|
+
case 'observe': return this.observe(body);
|
|
93
|
+
case 'click': return this.performElementAction('click', body);
|
|
94
|
+
case 'fill': return this.performElementAction('fill', body);
|
|
95
|
+
case 'type': return this.performElementAction('type', body);
|
|
96
|
+
case 'press': return this.performElementAction('press', body);
|
|
97
|
+
case 'select': return this.performElementAction('select', body);
|
|
98
|
+
case 'check': return this.performElementAction('check', body);
|
|
99
|
+
case 'uncheck': return this.performElementAction('uncheck', body);
|
|
100
|
+
default: throw new ControlError('UNKNOWN_ACTION', `unknown control action: ${action}`, { action }, 404);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* 列出当前所有 tab。
|
|
106
|
+
*
|
|
107
|
+
* @return {Promise<Array<{index:number,url:string,title:string}>>} 页面列表。
|
|
108
|
+
*/
|
|
109
|
+
async listPages() {
|
|
110
|
+
const context = this.getReadyContext(false);
|
|
111
|
+
if (!context) return [];
|
|
112
|
+
return Promise.all(context.pages().map(async (page, index) => {
|
|
113
|
+
const url = limitText(page.url(), MAX_OBSERVE_METADATA_BYTES);
|
|
114
|
+
const title = limitText(await page.title().catch(() => ''), MAX_OBSERVE_METADATA_BYTES);
|
|
115
|
+
return {
|
|
116
|
+
index,
|
|
117
|
+
url: url.value,
|
|
118
|
+
urlTruncated: url.truncated,
|
|
119
|
+
title: title.value,
|
|
120
|
+
titleTruncated: title.truncated,
|
|
121
|
+
};
|
|
122
|
+
}));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* 在当前最后一个 tab 或新 tab 打开 URL。
|
|
127
|
+
*
|
|
128
|
+
* @param {string} url 目标 URL。
|
|
129
|
+
* @param {{newTab?:boolean,timeoutMs?:number}} [options] 打开配置。
|
|
130
|
+
* @return {Promise<Object>} 打开结果。
|
|
131
|
+
*/
|
|
132
|
+
async open(url, options = {}) {
|
|
133
|
+
const context = this.getReadyContext();
|
|
134
|
+
const pages = context.pages();
|
|
135
|
+
const page = options.newTab || pages.length === 0 ? await context.newPage() : pages[pages.length - 1];
|
|
136
|
+
if (url && url !== 'about:blank') {
|
|
137
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: this.timeout(options) });
|
|
138
|
+
}
|
|
139
|
+
await page.bringToFront().catch(() => {});
|
|
140
|
+
return { ok: true, action: 'open', url: page.url(), index: context.pages().indexOf(page) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 关闭指定 tab。
|
|
145
|
+
*
|
|
146
|
+
* @param {number} index tab 序号。
|
|
147
|
+
* @return {Promise<Object>} 关闭结果。
|
|
148
|
+
*/
|
|
149
|
+
async close(index) {
|
|
150
|
+
const page = this.getPage(index);
|
|
151
|
+
await page.close();
|
|
152
|
+
return { ok: true, action: 'close', index: Number(index) };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 把指定 tab 提到最前。
|
|
157
|
+
*
|
|
158
|
+
* @param {number} index tab 序号。
|
|
159
|
+
* @return {Promise<Object>} 聚焦结果。
|
|
160
|
+
*/
|
|
161
|
+
async focus(index) {
|
|
162
|
+
const page = this.getPage(index);
|
|
163
|
+
await page.bringToFront();
|
|
164
|
+
return { ok: true, action: 'focus', index: Number(index), url: page.url() };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 刷新指定 tab。
|
|
169
|
+
*
|
|
170
|
+
* @param {number} [index] tab 序号。
|
|
171
|
+
* @param {{timeoutMs?:number}} [options] 导航配置。
|
|
172
|
+
* @return {Promise<Object>} 刷新结果。
|
|
173
|
+
*/
|
|
174
|
+
async reload(index = 0, options = {}) {
|
|
175
|
+
const page = this.getPage(index);
|
|
176
|
+
try {
|
|
177
|
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: this.timeout(options) });
|
|
178
|
+
} catch (error) {
|
|
179
|
+
throw this.navigationError('reload', error, page);
|
|
180
|
+
}
|
|
181
|
+
return { ok: true, action: 'reload', index: Number(index || 0), url: page.url() };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* 让指定 tab 后退。
|
|
186
|
+
*
|
|
187
|
+
* @param {number} [index] tab 序号。
|
|
188
|
+
* @param {{timeoutMs?:number}} [options] 导航配置。
|
|
189
|
+
* @return {Promise<Object>} 后退结果。
|
|
190
|
+
*/
|
|
191
|
+
async back(index = 0, options = {}) {
|
|
192
|
+
const page = this.getPage(index);
|
|
193
|
+
let response;
|
|
194
|
+
try {
|
|
195
|
+
response = await page.goBack({ waitUntil: 'domcontentloaded', timeout: this.timeout(options) });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
throw this.navigationError('back', error, page);
|
|
198
|
+
}
|
|
199
|
+
return { ok: true, action: 'back', navigated: response !== null, index: Number(index || 0), url: page.url() };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* 让指定 tab 前进。
|
|
204
|
+
*
|
|
205
|
+
* @param {number} [index] tab 序号。
|
|
206
|
+
* @param {{timeoutMs?:number}} [options] 导航配置。
|
|
207
|
+
* @return {Promise<Object>} 前进结果。
|
|
208
|
+
*/
|
|
209
|
+
async forward(index = 0, options = {}) {
|
|
210
|
+
const page = this.getPage(index);
|
|
211
|
+
let response;
|
|
212
|
+
try {
|
|
213
|
+
response = await page.goForward({ waitUntil: 'domcontentloaded', timeout: this.timeout(options) });
|
|
214
|
+
} catch (error) {
|
|
215
|
+
throw this.navigationError('forward', error, page);
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, action: 'forward', navigated: response !== null, index: Number(index || 0), url: page.url() };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* 在指定 tab 跳转 URL。
|
|
222
|
+
*
|
|
223
|
+
* @param {number} index tab 序号。
|
|
224
|
+
* @param {string} url 目标 URL。
|
|
225
|
+
* @param {{timeoutMs?:number}} [options] 导航配置。
|
|
226
|
+
* @return {Promise<Object>} 跳转结果。
|
|
227
|
+
*/
|
|
228
|
+
async navigate(index, url, options = {}) {
|
|
229
|
+
if (!url) throw new ControlError('INVALID_ARGUMENT', 'navigate requires url');
|
|
230
|
+
const page = this.getPage(index);
|
|
231
|
+
try {
|
|
232
|
+
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: this.timeout(options) });
|
|
233
|
+
} catch (error) {
|
|
234
|
+
throw this.navigationError('navigate', error, page);
|
|
235
|
+
}
|
|
236
|
+
return { ok: true, action: 'navigate', index: Number(index || 0), url: page.url() };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 获取页面、frame、文本、ARIA、交互元素及可选文件型快照。
|
|
241
|
+
*
|
|
242
|
+
* @param {Object} [options] 感知配置。
|
|
243
|
+
* @return {Promise<Object>} 有界页面状态。
|
|
244
|
+
*/
|
|
245
|
+
async observe(options = {}) {
|
|
246
|
+
const page = this.getPage(options.index);
|
|
247
|
+
const frames = page.frames();
|
|
248
|
+
const observedFrames = frames.slice(0, MAX_OBSERVE_FRAMES);
|
|
249
|
+
const includeText = options.includeText !== false;
|
|
250
|
+
const includeAria = options.includeAria !== false;
|
|
251
|
+
const includeElements = options.includeElements !== false;
|
|
252
|
+
const frameResults = [];
|
|
253
|
+
const deadline = Date.now() + this.timeout(options);
|
|
254
|
+
let remainingElements = MAX_OBSERVE_ELEMENTS;
|
|
255
|
+
|
|
256
|
+
for (let index = 0; index < observedFrames.length; index += 1) {
|
|
257
|
+
this.ensureObserveTime(deadline);
|
|
258
|
+
const frame = observedFrames[index];
|
|
259
|
+
const parent = frame.parentFrame();
|
|
260
|
+
const frameUrl = limitText(frame.url(), MAX_OBSERVE_METADATA_BYTES);
|
|
261
|
+
const frameName = limitText(frame.name(), MAX_OBSERVE_METADATA_BYTES);
|
|
262
|
+
const result = {
|
|
263
|
+
index,
|
|
264
|
+
url: frameUrl.value,
|
|
265
|
+
urlTruncated: frameUrl.truncated,
|
|
266
|
+
name: frameName.value,
|
|
267
|
+
nameTruncated: frameName.truncated,
|
|
268
|
+
parentIndex: parent ? frames.indexOf(parent) : null,
|
|
269
|
+
};
|
|
270
|
+
if (includeText) {
|
|
271
|
+
try {
|
|
272
|
+
const text = await frame.locator('body').innerText({ timeout: this.observeStepTimeout(deadline) });
|
|
273
|
+
const limited = limitText(text, MAX_OBSERVE_TEXT_BYTES);
|
|
274
|
+
result.text = limited.value;
|
|
275
|
+
result.textSize = limited.size;
|
|
276
|
+
result.textTruncated = limited.truncated;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
this.ensureObserveTime(deadline);
|
|
279
|
+
result.textError = limitText(error.message, MAX_OBSERVE_METADATA_BYTES).value;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (includeAria) {
|
|
283
|
+
try {
|
|
284
|
+
const aria = await frame.locator('body').ariaSnapshot({ timeout: this.observeStepTimeout(deadline) });
|
|
285
|
+
const limited = limitText(aria, MAX_OBSERVE_ARIA_BYTES);
|
|
286
|
+
result.aria = limited.value;
|
|
287
|
+
result.ariaSize = limited.size;
|
|
288
|
+
result.ariaTruncated = limited.truncated;
|
|
289
|
+
} catch (error) {
|
|
290
|
+
this.ensureObserveTime(deadline);
|
|
291
|
+
result.ariaError = limitText(error.message, MAX_OBSERVE_METADATA_BYTES).value;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (includeElements && remainingElements > 0) {
|
|
295
|
+
try {
|
|
296
|
+
const elements = await this.withObserveDeadline(
|
|
297
|
+
this.collectInteractiveElements(frame, remainingElements),
|
|
298
|
+
deadline,
|
|
299
|
+
);
|
|
300
|
+
result.elements = elements;
|
|
301
|
+
remainingElements -= elements.length;
|
|
302
|
+
} catch (error) {
|
|
303
|
+
this.ensureObserveTime(deadline);
|
|
304
|
+
result.elementsError = limitText(error.message, MAX_OBSERVE_METADATA_BYTES).value;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
frameResults.push(result);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const artifacts = {};
|
|
311
|
+
if (options.screenshot || options.dom) {
|
|
312
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
313
|
+
const dir = path.join(this.sessionDir, 'artifacts', `observe-${stamp}`);
|
|
314
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
315
|
+
if (options.screenshot) {
|
|
316
|
+
const screenshotPath = path.join(dir, 'page.png');
|
|
317
|
+
await this.withObserveDeadline(page.screenshot({
|
|
318
|
+
path: screenshotPath,
|
|
319
|
+
fullPage: Boolean(options.fullPage),
|
|
320
|
+
timeout: this.observeStepTimeout(deadline),
|
|
321
|
+
}), deadline);
|
|
322
|
+
const screenshotSize = fs.statSync(screenshotPath).size;
|
|
323
|
+
if (screenshotSize > MAX_OBSERVE_SCREENSHOT_BYTES) {
|
|
324
|
+
fs.rmSync(screenshotPath, { force: true });
|
|
325
|
+
artifacts.screenshot = {
|
|
326
|
+
error: 'screenshot exceeded artifact size limit',
|
|
327
|
+
size: screenshotSize,
|
|
328
|
+
limit: MAX_OBSERVE_SCREENSHOT_BYTES,
|
|
329
|
+
};
|
|
330
|
+
} else {
|
|
331
|
+
artifacts.screenshot = { path: screenshotPath, size: screenshotSize };
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
if (options.dom) {
|
|
335
|
+
artifacts.dom = [];
|
|
336
|
+
for (let index = 0; index < observedFrames.length; index += 1) {
|
|
337
|
+
const frame = observedFrames[index];
|
|
338
|
+
try {
|
|
339
|
+
const html = await this.withObserveDeadline(frame.content(), deadline);
|
|
340
|
+
const limited = limitText(html, MAX_OBSERVE_DOM_BYTES);
|
|
341
|
+
const file = path.join(dir, `frame-${index}-${safeFilePart(frame.name() || 'main')}.html`);
|
|
342
|
+
fs.writeFileSync(file, limited.value);
|
|
343
|
+
artifacts.dom.push({
|
|
344
|
+
frameIndex: index,
|
|
345
|
+
path: file,
|
|
346
|
+
size: limited.size,
|
|
347
|
+
storedSize: Buffer.byteLength(limited.value),
|
|
348
|
+
truncated: limited.truncated,
|
|
349
|
+
});
|
|
350
|
+
} catch (error) {
|
|
351
|
+
this.ensureObserveTime(deadline);
|
|
352
|
+
artifacts.dom.push({ frameIndex: index, error: error.message });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const pageResult = {
|
|
359
|
+
index: this.getReadyContext().pages().indexOf(page),
|
|
360
|
+
};
|
|
361
|
+
const pageUrl = limitText(page.url(), MAX_OBSERVE_METADATA_BYTES);
|
|
362
|
+
pageResult.url = pageUrl.value;
|
|
363
|
+
pageResult.urlTruncated = pageUrl.truncated;
|
|
364
|
+
try {
|
|
365
|
+
const title = limitText(await this.withObserveDeadline(page.title(), deadline), MAX_OBSERVE_METADATA_BYTES);
|
|
366
|
+
pageResult.title = title.value;
|
|
367
|
+
pageResult.titleTruncated = title.truncated;
|
|
368
|
+
} catch (error) {
|
|
369
|
+
this.ensureObserveTime(deadline);
|
|
370
|
+
pageResult.title = '';
|
|
371
|
+
pageResult.titleError = limitText(error.message, MAX_OBSERVE_METADATA_BYTES).value;
|
|
372
|
+
}
|
|
373
|
+
try {
|
|
374
|
+
pageResult.viewport = page.viewportSize() || await this.withObserveDeadline(
|
|
375
|
+
page.evaluate(() => ({ width: innerWidth, height: innerHeight })),
|
|
376
|
+
deadline,
|
|
377
|
+
);
|
|
378
|
+
} catch (error) {
|
|
379
|
+
this.ensureObserveTime(deadline);
|
|
380
|
+
pageResult.viewport = null;
|
|
381
|
+
pageResult.viewportError = limitText(error.message, MAX_OBSERVE_METADATA_BYTES).value;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
action: 'observe',
|
|
387
|
+
page: pageResult,
|
|
388
|
+
frames: frameResults,
|
|
389
|
+
frameLimit: MAX_OBSERVE_FRAMES,
|
|
390
|
+
framesTruncated: frames.length > observedFrames.length,
|
|
391
|
+
elementLimit: MAX_OBSERVE_ELEMENTS,
|
|
392
|
+
elementsTruncated: remainingElements === 0,
|
|
393
|
+
artifacts,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* 执行元素动作并返回实际命中元素和前后状态。
|
|
399
|
+
*
|
|
400
|
+
* @param {string} action click/fill/type/press/select/check/uncheck。
|
|
401
|
+
* @param {Object} options 动作参数。
|
|
402
|
+
* @return {Promise<Object>} 动作结果。
|
|
403
|
+
*/
|
|
404
|
+
async performElementAction(action, options) {
|
|
405
|
+
const startedAt = Date.now();
|
|
406
|
+
const page = this.getPage(options.index);
|
|
407
|
+
const pageIndex = this.getReadyContext().pages().indexOf(page);
|
|
408
|
+
const urlBefore = page.url();
|
|
409
|
+
try {
|
|
410
|
+
const resolved = await this.resolveTarget(page, options);
|
|
411
|
+
const timeout = this.timeout(options);
|
|
412
|
+
await this.ensureTargetActionable(resolved.locator, action);
|
|
413
|
+
let actionResult = null;
|
|
414
|
+
switch (action) {
|
|
415
|
+
case 'click':
|
|
416
|
+
await resolved.locator.click({ timeout });
|
|
417
|
+
break;
|
|
418
|
+
case 'fill':
|
|
419
|
+
if (options.value === undefined) throw new ControlError('INVALID_ARGUMENT', 'fill requires value');
|
|
420
|
+
await resolved.locator.fill(String(options.value), { timeout });
|
|
421
|
+
break;
|
|
422
|
+
case 'type':
|
|
423
|
+
if (options.value === undefined) throw new ControlError('INVALID_ARGUMENT', 'type requires value');
|
|
424
|
+
await resolved.locator.pressSequentially(String(options.value), { timeout, delay: Number(options.delayMs || 0) });
|
|
425
|
+
break;
|
|
426
|
+
case 'press':
|
|
427
|
+
if (!options.key) throw new ControlError('INVALID_ARGUMENT', 'press requires key');
|
|
428
|
+
await resolved.locator.press(String(options.key), { timeout });
|
|
429
|
+
break;
|
|
430
|
+
case 'select': {
|
|
431
|
+
const values = options.values !== undefined ? options.values : options.value;
|
|
432
|
+
if (values === undefined) throw new ControlError('INVALID_ARGUMENT', 'select requires value or values');
|
|
433
|
+
actionResult = await resolved.locator.selectOption(values, { timeout });
|
|
434
|
+
break;
|
|
435
|
+
}
|
|
436
|
+
case 'check':
|
|
437
|
+
await resolved.locator.check({ timeout });
|
|
438
|
+
break;
|
|
439
|
+
case 'uncheck':
|
|
440
|
+
await resolved.locator.uncheck({ timeout });
|
|
441
|
+
break;
|
|
442
|
+
default:
|
|
443
|
+
throw new ControlError('UNKNOWN_ACTION', `unknown element action: ${action}`, { action }, 404);
|
|
444
|
+
}
|
|
445
|
+
return {
|
|
446
|
+
ok: true,
|
|
447
|
+
action,
|
|
448
|
+
durationMs: Date.now() - startedAt,
|
|
449
|
+
page: { index: pageIndex, urlBefore, urlAfter: page.url() },
|
|
450
|
+
frame: resolved.frame,
|
|
451
|
+
target: resolved.summary,
|
|
452
|
+
result: actionResult,
|
|
453
|
+
};
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (error instanceof ControlError) throw error;
|
|
456
|
+
const code = error.name === 'TimeoutError' ? 'ACTION_TIMEOUT' : 'ACTION_FAILED';
|
|
457
|
+
throw new ControlError(code, error.message, { action, url: page.url() }, code === 'ACTION_TIMEOUT' ? 408 : 500);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* 收集 frame 内可交互元素的有界摘要。
|
|
463
|
+
*
|
|
464
|
+
* @param {import('playwright').Frame} frame 目标 frame。
|
|
465
|
+
* @param {number} limit 最大元素数。
|
|
466
|
+
* @return {Promise<Array<Object>>} 元素摘要。
|
|
467
|
+
*/
|
|
468
|
+
async collectInteractiveElements(frame, limit) {
|
|
469
|
+
return frame.locator('a[href],button,input,select,textarea,[role],[contenteditable="true"],[tabindex]').evaluateAll((elements, max) => {
|
|
470
|
+
function short(value, length = 240) {
|
|
471
|
+
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, length);
|
|
472
|
+
}
|
|
473
|
+
function roleOf(element) {
|
|
474
|
+
if (element.getAttribute('role')) return element.getAttribute('role');
|
|
475
|
+
if (element.tagName === 'INPUT') {
|
|
476
|
+
return {
|
|
477
|
+
submit: 'button', button: 'button', reset: 'button', image: 'button', file: 'button',
|
|
478
|
+
checkbox: 'checkbox', radio: 'radio', range: 'slider', number: 'spinbutton', search: 'searchbox',
|
|
479
|
+
}[String(element.type || 'text').toLowerCase()] || 'textbox';
|
|
480
|
+
}
|
|
481
|
+
return { A: 'link', BUTTON: 'button', SELECT: 'combobox', TEXTAREA: 'textbox' }[element.tagName] || '';
|
|
482
|
+
}
|
|
483
|
+
function labelOf(element) {
|
|
484
|
+
const label = element.labels && element.labels[0];
|
|
485
|
+
if (!label) return '';
|
|
486
|
+
const clone = label.cloneNode(true);
|
|
487
|
+
clone.querySelectorAll('input,select,textarea,button').forEach(control => control.remove());
|
|
488
|
+
return short(clone.textContent);
|
|
489
|
+
}
|
|
490
|
+
function targetOf(element, role, name) {
|
|
491
|
+
const testId = element.getAttribute('data-testid');
|
|
492
|
+
if (testId && testId.length <= 240) return { testId };
|
|
493
|
+
if (element.id && element.id.length <= 240) return { selector: `#${CSS.escape(element.id)}` };
|
|
494
|
+
if (role && name) return { role, name, exact: true };
|
|
495
|
+
const label = element.getAttribute('aria-label');
|
|
496
|
+
if (label) return { role: role || undefined, name: short(label), exact: true };
|
|
497
|
+
const placeholder = element.getAttribute('placeholder');
|
|
498
|
+
if (placeholder) return { placeholder: short(placeholder), exact: true };
|
|
499
|
+
if (name) return { text: name, exact: true };
|
|
500
|
+
return { selector: element.tagName.toLowerCase() };
|
|
501
|
+
}
|
|
502
|
+
return elements.filter(element => {
|
|
503
|
+
const rect = element.getBoundingClientRect();
|
|
504
|
+
const style = getComputedStyle(element);
|
|
505
|
+
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
|
|
506
|
+
}).slice(0, max).map(element => {
|
|
507
|
+
const rect = element.getBoundingClientRect();
|
|
508
|
+
const role = roleOf(element);
|
|
509
|
+
const name = short(element.getAttribute('aria-label') || labelOf(element) || element.innerText || element.getAttribute('value') || element.getAttribute('placeholder'));
|
|
510
|
+
return {
|
|
511
|
+
tag: element.tagName.toLowerCase(),
|
|
512
|
+
role,
|
|
513
|
+
name,
|
|
514
|
+
type: element.getAttribute('type') || '',
|
|
515
|
+
value: short(element.value),
|
|
516
|
+
disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'),
|
|
517
|
+
checked: 'checked' in element ? Boolean(element.checked) : null,
|
|
518
|
+
boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
519
|
+
target: targetOf(element, role, name),
|
|
520
|
+
};
|
|
521
|
+
});
|
|
522
|
+
}, limit);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* 在动作执行前检查稳定可判定的不可操作状态。
|
|
527
|
+
*
|
|
528
|
+
* @param {import('playwright').Locator} locator 唯一目标元素。
|
|
529
|
+
* @param {string} action 动作名。
|
|
530
|
+
* @return {Promise<void>} 元素可操作时完成。
|
|
531
|
+
*/
|
|
532
|
+
async ensureTargetActionable(locator, action) {
|
|
533
|
+
const needsEnabled = ['click', 'fill', 'type', 'press', 'select', 'check', 'uncheck'].includes(action);
|
|
534
|
+
if (needsEnabled && typeof locator.isEnabled === 'function' && !(await locator.isEnabled())) {
|
|
535
|
+
throw new ControlError('TARGET_NOT_ACTIONABLE', 'target is disabled', { action, reason: 'disabled' }, 409);
|
|
536
|
+
}
|
|
537
|
+
if (['fill', 'type'].includes(action) && typeof locator.isEditable === 'function' && !(await locator.isEditable())) {
|
|
538
|
+
throw new ControlError('TARGET_NOT_ACTIONABLE', 'target is not editable', { action, reason: 'not-editable' }, 409);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* 解析 frame 与 locator,并拒绝未显式消歧的多匹配。
|
|
544
|
+
*
|
|
545
|
+
* @param {import('playwright').Page} page 目标页面。
|
|
546
|
+
* @param {Object} options 动作参数。
|
|
547
|
+
* @return {Promise<{locator:import('playwright').Locator,frame:Object,summary:Object}>} 唯一目标。
|
|
548
|
+
*/
|
|
549
|
+
async resolveTarget(page, options) {
|
|
550
|
+
const frameInfo = this.resolveFrame(page, options.frame);
|
|
551
|
+
const target = options.target || {};
|
|
552
|
+
const strategies = TARGET_KEYS.filter(key => target[key] !== undefined && target[key] !== null && target[key] !== '');
|
|
553
|
+
if (strategies.length !== 1) {
|
|
554
|
+
throw new ControlError('INVALID_TARGET', 'target must contain exactly one locator strategy', { strategies });
|
|
555
|
+
}
|
|
556
|
+
let locator;
|
|
557
|
+
switch (strategies[0]) {
|
|
558
|
+
case 'selector': locator = frameInfo.frame.locator(String(target.selector)); break;
|
|
559
|
+
case 'role': locator = frameInfo.frame.getByRole(String(target.role), { name: target.name, exact: Boolean(target.exact) }); break;
|
|
560
|
+
case 'text': locator = frameInfo.frame.getByText(String(target.text), { exact: Boolean(target.exact) }); break;
|
|
561
|
+
case 'label': locator = frameInfo.frame.getByLabel(String(target.label), { exact: Boolean(target.exact) }); break;
|
|
562
|
+
case 'placeholder': locator = frameInfo.frame.getByPlaceholder(String(target.placeholder), { exact: Boolean(target.exact) }); break;
|
|
563
|
+
case 'testId': locator = frameInfo.frame.getByTestId(String(target.testId)); break;
|
|
564
|
+
default: throw new ControlError('INVALID_TARGET', 'unsupported locator strategy');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const count = await locator.count();
|
|
568
|
+
const visible = [];
|
|
569
|
+
for (let index = 0; index < count; index += 1) {
|
|
570
|
+
if (await locator.nth(index).isVisible().catch(() => false)) visible.push(index);
|
|
571
|
+
}
|
|
572
|
+
const requestedNth = target.nth !== undefined ? Number(target.nth) : options.nth !== undefined ? Number(options.nth) : null;
|
|
573
|
+
let selected;
|
|
574
|
+
if (requestedNth !== null) {
|
|
575
|
+
if (!Number.isInteger(requestedNth) || requestedNth < 0 || !visible.includes(requestedNth)) {
|
|
576
|
+
throw new ControlError('TARGET_NOT_FOUND', 'requested nth target is not visible', { nth: requestedNth, visible });
|
|
577
|
+
}
|
|
578
|
+
selected = locator.nth(requestedNth);
|
|
579
|
+
} else if (visible.length === 0) {
|
|
580
|
+
throw new ControlError('TARGET_NOT_FOUND', 'no visible target matched', { target });
|
|
581
|
+
} else if (visible.length > 1) {
|
|
582
|
+
const candidates = [];
|
|
583
|
+
for (const index of visible.slice(0, 10)) candidates.push(await this.summarizeLocator(locator.nth(index)));
|
|
584
|
+
throw new ControlError('AMBIGUOUS_TARGET', 'multiple visible targets matched; pass target.nth explicitly', {
|
|
585
|
+
count: visible.length,
|
|
586
|
+
candidates,
|
|
587
|
+
}, 409);
|
|
588
|
+
} else {
|
|
589
|
+
selected = locator.nth(visible[0]);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return {
|
|
593
|
+
locator: selected,
|
|
594
|
+
frame: { index: frameInfo.index, url: frameInfo.frame.url(), name: frameInfo.frame.name() },
|
|
595
|
+
summary: await this.summarizeLocator(selected),
|
|
596
|
+
};
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* 解析目标 frame。
|
|
601
|
+
*
|
|
602
|
+
* @param {import('playwright').Page} page 目标页面。
|
|
603
|
+
* @param {Object|undefined} selector frame 选择条件。
|
|
604
|
+
* @return {{frame:import('playwright').Frame,index:number}} frame 结果。
|
|
605
|
+
*/
|
|
606
|
+
resolveFrame(page, selector) {
|
|
607
|
+
const frames = page.frames();
|
|
608
|
+
if (!selector) return { frame: page.mainFrame(), index: frames.indexOf(page.mainFrame()) };
|
|
609
|
+
if (selector.index !== undefined) {
|
|
610
|
+
const index = Number(selector.index);
|
|
611
|
+
if (Number.isInteger(index) && frames[index]) return { frame: frames[index], index };
|
|
612
|
+
throw new ControlError('FRAME_NOT_FOUND', `no such frame: ${selector.index}`, { frames: frames.length });
|
|
613
|
+
}
|
|
614
|
+
if (selector.url) {
|
|
615
|
+
const matches = frames.map((frame, index) => ({ frame, index })).filter(item => (
|
|
616
|
+
selector.exact ? item.frame.url() === selector.url : item.frame.url().includes(selector.url)
|
|
617
|
+
));
|
|
618
|
+
if (matches.length === 1) return matches[0];
|
|
619
|
+
if (matches.length > 1) throw new ControlError('FRAME_NOT_FOUND', 'frame URL matched multiple frames', { count: matches.length }, 409);
|
|
620
|
+
}
|
|
621
|
+
if (selector.name !== undefined) {
|
|
622
|
+
const matches = frames.map((frame, index) => ({ frame, index })).filter(item => item.frame.name() === selector.name);
|
|
623
|
+
if (matches.length === 1) return matches[0];
|
|
624
|
+
if (matches.length > 1) throw new ControlError('FRAME_NOT_FOUND', 'frame name matched multiple frames', { count: matches.length }, 409);
|
|
625
|
+
}
|
|
626
|
+
throw new ControlError('FRAME_NOT_FOUND', 'frame did not match', { selector });
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* 生成实际元素摘要,供结果和歧义候选返回。
|
|
631
|
+
*
|
|
632
|
+
* @param {import('playwright').Locator} locator 唯一元素 locator。
|
|
633
|
+
* @return {Promise<Object>} 元素摘要。
|
|
634
|
+
*/
|
|
635
|
+
async summarizeLocator(locator) {
|
|
636
|
+
return locator.evaluate(element => {
|
|
637
|
+
const rect = element.getBoundingClientRect();
|
|
638
|
+
return {
|
|
639
|
+
tag: element.tagName.toLowerCase(),
|
|
640
|
+
id: element.id || '',
|
|
641
|
+
role: element.getAttribute('role') || '',
|
|
642
|
+
name: String(element.getAttribute('aria-label') || element.innerText || element.getAttribute('value') || '').replace(/\s+/g, ' ').trim().slice(0, 240),
|
|
643
|
+
type: element.getAttribute('type') || '',
|
|
644
|
+
disabled: Boolean(element.disabled || element.getAttribute('aria-disabled') === 'true'),
|
|
645
|
+
checked: 'checked' in element ? Boolean(element.checked) : null,
|
|
646
|
+
boundingBox: { x: rect.x, y: rect.y, width: rect.width, height: rect.height },
|
|
647
|
+
};
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/**
|
|
652
|
+
* 获取已就绪 BrowserContext。
|
|
653
|
+
*
|
|
654
|
+
* @param {boolean} [required] 未就绪时是否抛错。
|
|
655
|
+
* @return {import('playwright').BrowserContext|null} 当前上下文。
|
|
656
|
+
*/
|
|
657
|
+
getReadyContext(required = true) {
|
|
658
|
+
const context = this.getContext();
|
|
659
|
+
if (!context && required) throw new ControlError('BROWSER_NOT_READY', 'browser is not ready', null, 503);
|
|
660
|
+
return context || null;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* 获取指定页面。
|
|
665
|
+
*
|
|
666
|
+
* @param {number|undefined} index 页面序号。
|
|
667
|
+
* @return {import('playwright').Page} 目标页面。
|
|
668
|
+
*/
|
|
669
|
+
getPage(index = 0) {
|
|
670
|
+
const context = this.getReadyContext();
|
|
671
|
+
const normalized = index === undefined || index === null ? 0 : Number(index);
|
|
672
|
+
if (!Number.isInteger(normalized) || !context.pages()[normalized]) {
|
|
673
|
+
throw new ControlError('PAGE_NOT_FOUND', `no such page: ${index}`, { pageCount: context.pages().length }, 404);
|
|
674
|
+
}
|
|
675
|
+
return context.pages()[normalized];
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 读取动作超时并限制到合理范围。
|
|
680
|
+
*
|
|
681
|
+
* @param {Object} options 动作参数。
|
|
682
|
+
* @return {number} 超时毫秒数。
|
|
683
|
+
*/
|
|
684
|
+
timeout(options) {
|
|
685
|
+
const timeout = Number(options.timeoutMs || 10000);
|
|
686
|
+
return Number.isFinite(timeout) ? Math.max(100, Math.min(timeout, 120000)) : 10000;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* 规范化页面导航失败,避免 HTTP 层退化为无上下文的 500。
|
|
691
|
+
*
|
|
692
|
+
* @param {string} action 导航动作。
|
|
693
|
+
* @param {Error} error Playwright 错误。
|
|
694
|
+
* @param {import('playwright').Page} page 当前页面。
|
|
695
|
+
* @return {ControlError} 结构化控制错误。
|
|
696
|
+
*/
|
|
697
|
+
navigationError(action, error, page) {
|
|
698
|
+
const timedOut = error && error.name === 'TimeoutError';
|
|
699
|
+
return new ControlError(
|
|
700
|
+
timedOut ? 'NAVIGATION_TIMEOUT' : 'NAVIGATION_FAILED',
|
|
701
|
+
error.message,
|
|
702
|
+
{ action, url: page.url() },
|
|
703
|
+
timedOut ? 408 : 502,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* 确认 observe 仍在总时间预算内。
|
|
709
|
+
*
|
|
710
|
+
* @param {number} deadline 截止时间戳。
|
|
711
|
+
* @return {void}
|
|
712
|
+
*/
|
|
713
|
+
ensureObserveTime(deadline) {
|
|
714
|
+
if (Date.now() >= deadline) {
|
|
715
|
+
throw new ControlError('OBSERVE_TIMEOUT', 'observe exceeded its overall timeout', null, 408);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/**
|
|
720
|
+
* 返回单个 frame 步骤可使用的剩余超时。
|
|
721
|
+
*
|
|
722
|
+
* @param {number} deadline 截止时间戳。
|
|
723
|
+
* @return {number} 步骤超时毫秒数。
|
|
724
|
+
*/
|
|
725
|
+
observeStepTimeout(deadline) {
|
|
726
|
+
this.ensureObserveTime(deadline);
|
|
727
|
+
return Math.max(100, Math.min(3000, deadline - Date.now()));
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/**
|
|
731
|
+
* 给不支持 timeout 参数的 Playwright 调用套用 observe 总预算。
|
|
732
|
+
*
|
|
733
|
+
* @template T
|
|
734
|
+
* @param {Promise<T>} promise 待等待操作。
|
|
735
|
+
* @param {number} deadline 截止时间戳。
|
|
736
|
+
* @return {Promise<T>} 原操作结果。
|
|
737
|
+
*/
|
|
738
|
+
async withObserveDeadline(promise, deadline) {
|
|
739
|
+
this.ensureObserveTime(deadline);
|
|
740
|
+
const remaining = deadline - Date.now();
|
|
741
|
+
let timer;
|
|
742
|
+
try {
|
|
743
|
+
return await Promise.race([
|
|
744
|
+
promise,
|
|
745
|
+
new Promise((resolve, reject) => {
|
|
746
|
+
timer = setTimeout(() => reject(new ControlError(
|
|
747
|
+
'OBSERVE_TIMEOUT',
|
|
748
|
+
'observe exceeded its overall timeout',
|
|
749
|
+
null,
|
|
750
|
+
408,
|
|
751
|
+
)), remaining);
|
|
752
|
+
}),
|
|
753
|
+
]);
|
|
754
|
+
} finally {
|
|
755
|
+
clearTimeout(timer);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* 创建统一浏览器控制器。
|
|
762
|
+
*
|
|
763
|
+
* @param {Object} options 配置项。
|
|
764
|
+
* @param {()=>import('playwright').BrowserContext|null} options.getContext 获取 BrowserContext。
|
|
765
|
+
* @param {string} options.sessionDir 当前会话目录。
|
|
766
|
+
* @return {BrowserController} 控制器实例。
|
|
767
|
+
*/
|
|
768
|
+
function createBrowserController(options) {
|
|
769
|
+
return new BrowserController(options);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
module.exports = { BrowserController, ControlError, createBrowserController, limitText };
|