@spec-box/sdd 0.6.4 → 0.7.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/README.md +28 -1
- package/assets/ci/Dockerfile +2 -0
- package/assets/hosts/claude/agent.md +13 -0
- package/assets/project/testing.md +4 -0
- package/assets/roles/challenger.md +4 -0
- package/assets/roles/distiller.md +4 -0
- package/assets/roles/implementer.md +4 -0
- package/assets/roles/planner.md +4 -0
- package/assets/roles/researcher.md +8 -3
- package/assets/roles/reviewer.md +4 -0
- package/assets/roles/tester.md +5 -1
- package/assets/roles/verifier.md +5 -1
- package/assets/skills/sbox-approve.md +11 -0
- package/assets/skills/sbox-browser.md +31 -0
- package/assets/skills/sbox-run.md +24 -0
- package/bin/sbox-browser.js +5 -0
- package/dist/adapters/host/claude/index.js +27 -85
- package/dist/adapters/host/claude/index.js.map +1 -1
- package/dist/adapters/host/index.js +25 -0
- package/dist/adapters/host/index.js.map +1 -0
- package/dist/adapters/host/skills.js +27 -0
- package/dist/adapters/host/skills.js.map +1 -0
- package/dist/browser/cli.js +488 -0
- package/dist/browser/cli.js.map +1 -0
- package/dist/browser/client.js +182 -0
- package/dist/browser/client.js.map +1 -0
- package/dist/browser/commands.js +598 -0
- package/dist/browser/commands.js.map +1 -0
- package/dist/browser/daemon.js +315 -0
- package/dist/browser/daemon.js.map +1 -0
- package/dist/browser/executable.js +138 -0
- package/dist/browser/executable.js.map +1 -0
- package/dist/browser/install.js +49 -0
- package/dist/browser/install.js.map +1 -0
- package/dist/browser/keys.js +44 -0
- package/dist/browser/keys.js.map +1 -0
- package/dist/browser/paths.js +34 -0
- package/dist/browser/paths.js.map +1 -0
- package/dist/browser/protocol.js +34 -0
- package/dist/browser/protocol.js.map +1 -0
- package/dist/browser/selectors.js +53 -0
- package/dist/browser/selectors.js.map +1 -0
- package/dist/browser/session.js +128 -0
- package/dist/browser/session.js.map +1 -0
- package/dist/browser/settings.js +51 -0
- package/dist/browser/settings.js.map +1 -0
- package/dist/browser/snapshot.js +89 -0
- package/dist/browser/snapshot.js.map +1 -0
- package/dist/cli/args.js +9 -0
- package/dist/cli/args.js.map +1 -0
- package/dist/cli/commands/change.js +2 -1
- package/dist/cli/commands/change.js.map +1 -1
- package/dist/cli/commands/doctor.js +16 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/host.js +4 -6
- package/dist/cli/commands/host.js.map +1 -1
- package/dist/cli/commands/init.js +9 -7
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/metrics.js +2 -1
- package/dist/cli/commands/metrics.js.map +1 -1
- package/dist/cli/commands/run.js +5 -4
- package/dist/cli/commands/run.js.map +1 -1
- package/dist/cli/main.js +2 -12
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/output.js +7 -3
- package/dist/cli/output.js.map +1 -1
- package/dist/core/archive.js +4 -0
- package/dist/core/archive.js.map +1 -1
- package/dist/core/async.js +15 -0
- package/dist/core/async.js.map +1 -0
- package/dist/core/change.js +2 -0
- package/dist/core/change.js.map +1 -1
- package/dist/core/config.js +16 -0
- package/dist/core/config.js.map +1 -1
- package/dist/core/deliver.js +3 -1
- package/dist/core/deliver.js.map +1 -1
- package/dist/core/lock.js +2 -1
- package/dist/core/lock.js.map +1 -1
- package/dist/core/metrics.js +28 -19
- package/dist/core/metrics.js.map +1 -1
- package/dist/core/packet.js +1 -0
- package/dist/core/packet.js.map +1 -1
- package/dist/core/paths.js +14 -0
- package/dist/core/paths.js.map +1 -1
- package/dist/core/report.js +5 -1
- package/dist/core/report.js.map +1 -1
- package/dist/core/roles.js +16 -5
- package/dist/core/roles.js.map +1 -1
- package/dist/core/run.js +2 -3
- package/dist/core/run.js.map +1 -1
- package/dist/core/skills.js +60 -0
- package/dist/core/skills.js.map +1 -0
- package/docs/design.md +18 -4
- package/package.json +11 -5
|
@@ -0,0 +1,598 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import vm from 'node:vm';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { sleep } from '../core/async.js';
|
|
6
|
+
import { SboxError } from '../core/errors.js';
|
|
7
|
+
import { browserDirs, ensureDir, timestampSlug } from './paths.js';
|
|
8
|
+
import { parseKeyCombo } from './keys.js';
|
|
9
|
+
import { normalizeUrl, parseTarget, urlMatches } from './selectors.js';
|
|
10
|
+
import { renderSnapshot } from './snapshot.js';
|
|
11
|
+
const BUFFER_LIMIT = 500;
|
|
12
|
+
export function pushLimited(list, item) {
|
|
13
|
+
list.push(item);
|
|
14
|
+
if (list.length > BUFFER_LIMIT)
|
|
15
|
+
list.splice(0, list.length - BUFFER_LIMIT);
|
|
16
|
+
}
|
|
17
|
+
const timeoutField = z.number().int().positive().optional();
|
|
18
|
+
const target = z.string().min(1);
|
|
19
|
+
const waitUntil = z.enum(['load', 'domcontentloaded', 'networkidle']).optional();
|
|
20
|
+
function lifecycle(w) {
|
|
21
|
+
return w === 'networkidle' ? 'networkidle2' : (w ?? 'load');
|
|
22
|
+
}
|
|
23
|
+
function err(code, message, fix) {
|
|
24
|
+
return new SboxError(code, message, fix);
|
|
25
|
+
}
|
|
26
|
+
function resolveUrl(ctx, url) {
|
|
27
|
+
const resolved = normalizeUrl(url, ctx.settings.baseUrl);
|
|
28
|
+
if (!resolved)
|
|
29
|
+
throw err('BROWSER_RELATIVE_URL', `Относительный адрес «${url}» требует базового URL.`, 'Укажите browser.baseUrl в .sbox/config.yaml, --base-url при старте или полный адрес.');
|
|
30
|
+
return resolved;
|
|
31
|
+
}
|
|
32
|
+
/** Разбирается ли код как обычный скрипт: проверка синтаксиса в Node, без отправки в страницу. */
|
|
33
|
+
function isScript(code) {
|
|
34
|
+
try {
|
|
35
|
+
new vm.Script(code);
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
return !(e instanceof SyntaxError);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function pageOrigin(page) {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(page.url()).origin;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Ссылка из снимка → живой ElementHandle; отсоединённый узел это BROWSER_REF_STALE, а не ошибка протокола. */
|
|
51
|
+
async function refHandle(state, ref) {
|
|
52
|
+
if (!state.snapshot)
|
|
53
|
+
throw err('BROWSER_NO_SNAPSHOT', 'Ссылки [eN] появляются после `sbox-browser snapshot`.', 'Сделайте snapshot и возьмите ссылку из него.');
|
|
54
|
+
const node = state.snapshot.refs.get(ref);
|
|
55
|
+
if (!node)
|
|
56
|
+
throw err('BROWSER_REF_UNKNOWN', `В последнем снимке нет ссылки ${ref}.`, 'Сделайте snapshot заново: ссылки нумеруются при каждом снимке.');
|
|
57
|
+
let handle = null;
|
|
58
|
+
try {
|
|
59
|
+
handle = await node.elementHandle();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
handle = null;
|
|
63
|
+
}
|
|
64
|
+
const connected = handle ? await handle.evaluate((el) => el.isConnected).catch(() => false) : false;
|
|
65
|
+
if (!handle || !connected) {
|
|
66
|
+
await handle?.dispose().catch(() => { });
|
|
67
|
+
throw err('BROWSER_REF_STALE', `Элемент ${ref} исчез со страницы (снимок от ${state.snapshot.at}, ${state.snapshot.url}).`, 'Сделайте snapshot заново после изменения страницы.');
|
|
68
|
+
}
|
|
69
|
+
return handle;
|
|
70
|
+
}
|
|
71
|
+
async function resolveHandle(ctx, raw, opts = {}) {
|
|
72
|
+
const state = ctx.current();
|
|
73
|
+
const t = parseTarget(raw);
|
|
74
|
+
if (t.kind === 'ref')
|
|
75
|
+
return refHandle(state, t.ref);
|
|
76
|
+
const timeout = opts.timeout ?? ctx.settings.timeoutMs;
|
|
77
|
+
try {
|
|
78
|
+
const handle = await state.page.waitForSelector(t.selector, { timeout, visible: opts.visible ?? true, signal: opts.signal });
|
|
79
|
+
if (!handle)
|
|
80
|
+
throw new Error('пусто');
|
|
81
|
+
return handle;
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
throw err('BROWSER_TARGET_NOT_FOUND', `Элемент «${t.raw}» не найден за ${timeout} мс${opts.visible === false ? '' : ' (ожидался видимым)'}: ${e.message.split('\n')[0]}`, 'Сделайте `sbox-browser snapshot` и используйте ссылку [eN], либо проверьте селектор (text=, aria=, xpath=, css).');
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** Handle живёт ровно на время действия: иначе Chrome держит элемент до навигации, а демон живёт часами. */
|
|
88
|
+
async function withHandle(ctx, raw, opts, fn) {
|
|
89
|
+
const handle = await resolveHandle(ctx, raw, opts);
|
|
90
|
+
try {
|
|
91
|
+
return await fn(handle);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
await handle.dispose().catch(() => { });
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
async function elementInfo(handle) {
|
|
98
|
+
return handle.evaluate((el) => ({ tag: el.tagName.toLowerCase(), text: (el.innerText ?? el.textContent ?? '').trim().slice(0, 80) }));
|
|
99
|
+
}
|
|
100
|
+
function navigation(run) {
|
|
101
|
+
return async (ctx, raw, signal) => {
|
|
102
|
+
const a = z.object({ wait: waitUntil, timeout: timeoutField }).parse(raw);
|
|
103
|
+
const page = ctx.current().page;
|
|
104
|
+
const response = await run(page, { waitUntil: lifecycle(a.wait), timeout: a.timeout ?? ctx.settings.timeoutMs, signal });
|
|
105
|
+
return { url: page.url(), title: await page.title(), status: response?.status() ?? null };
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export const handlers = {
|
|
109
|
+
async ping(ctx) {
|
|
110
|
+
const cur = ctx.pages.length ? ctx.current() : null;
|
|
111
|
+
return {
|
|
112
|
+
pid: process.pid,
|
|
113
|
+
session: ctx.session,
|
|
114
|
+
headless: ctx.settings.headless,
|
|
115
|
+
executable: ctx.settings.executable,
|
|
116
|
+
profile: ctx.settings.profile,
|
|
117
|
+
pages: ctx.pages.length,
|
|
118
|
+
current: ctx.currentIndex(),
|
|
119
|
+
url: cur?.page.url() ?? null,
|
|
120
|
+
uptimeMs: Date.now() - ctx.startedAt,
|
|
121
|
+
};
|
|
122
|
+
},
|
|
123
|
+
/** Остановку выполняет демон после отправки ответа: обработчик только подтверждает команду. */
|
|
124
|
+
async stop() {
|
|
125
|
+
return { stopped: true };
|
|
126
|
+
},
|
|
127
|
+
async goto(ctx, raw, signal) {
|
|
128
|
+
const a = z.object({ url: z.string().min(1), wait: waitUntil, timeout: timeoutField }).parse(raw);
|
|
129
|
+
const url = resolveUrl(ctx, a.url);
|
|
130
|
+
const page = ctx.current().page;
|
|
131
|
+
const response = await page.goto(url, { waitUntil: lifecycle(a.wait), timeout: a.timeout ?? ctx.settings.timeoutMs, signal });
|
|
132
|
+
return { url: page.url(), title: await page.title(), status: response?.status() ?? null };
|
|
133
|
+
},
|
|
134
|
+
back: navigation((page, o) => page.goBack(o)),
|
|
135
|
+
forward: navigation((page, o) => page.goForward(o)),
|
|
136
|
+
reload: navigation((page, o) => page.reload(o)),
|
|
137
|
+
async url(ctx) {
|
|
138
|
+
return { url: ctx.current().page.url() };
|
|
139
|
+
},
|
|
140
|
+
async title(ctx) {
|
|
141
|
+
return { title: await ctx.current().page.title() };
|
|
142
|
+
},
|
|
143
|
+
async pages(ctx) {
|
|
144
|
+
const list = await Promise.all(ctx.pages.map(async (s, index) => ({ index, url: s.page.url(), title: await s.page.title().catch(() => ''), current: s === ctx.currentPage })));
|
|
145
|
+
return { pages: list };
|
|
146
|
+
},
|
|
147
|
+
async 'page.new'(ctx, raw) {
|
|
148
|
+
const a = z.object({ url: z.string().optional() }).parse(raw);
|
|
149
|
+
const page = await ctx.browser.newPage();
|
|
150
|
+
const state = ctx.attach(page);
|
|
151
|
+
ctx.currentPage = state;
|
|
152
|
+
if (a.url)
|
|
153
|
+
await page.goto(resolveUrl(ctx, a.url), { waitUntil: 'load', timeout: ctx.settings.timeoutMs });
|
|
154
|
+
return { index: ctx.currentIndex(), url: page.url() };
|
|
155
|
+
},
|
|
156
|
+
async 'page.switch'(ctx, raw) {
|
|
157
|
+
const a = z.object({ index: z.number().int().nonnegative() }).parse(raw);
|
|
158
|
+
const state = ctx.pages[a.index];
|
|
159
|
+
if (!state)
|
|
160
|
+
throw err('BROWSER_PAGE_INDEX', `Нет вкладки с индексом ${a.index}; всего ${ctx.pages.length}.`);
|
|
161
|
+
ctx.currentPage = state;
|
|
162
|
+
await state.page.bringToFront();
|
|
163
|
+
return { index: a.index, url: state.page.url() };
|
|
164
|
+
},
|
|
165
|
+
async 'page.close'(ctx, raw) {
|
|
166
|
+
const a = z.object({ index: z.number().int().nonnegative().optional() }).parse(raw);
|
|
167
|
+
const state = a.index === undefined ? ctx.current() : ctx.pages[a.index];
|
|
168
|
+
if (!state)
|
|
169
|
+
throw err('BROWSER_PAGE_INDEX', `Нет вкладки с индексом ${a.index}.`);
|
|
170
|
+
if (ctx.pages.length === 1) {
|
|
171
|
+
await state.page.goto('about:blank');
|
|
172
|
+
return { closed: false, note: 'последняя вкладка очищена, а не закрыта' };
|
|
173
|
+
}
|
|
174
|
+
await state.page.close();
|
|
175
|
+
return { closed: true, pages: ctx.pages.length };
|
|
176
|
+
},
|
|
177
|
+
async click(ctx, raw, signal) {
|
|
178
|
+
const a = z.object({ target, button: z.enum(['left', 'right', 'middle']).optional(), count: z.number().int().positive().optional(), timeout: timeoutField }).parse(raw);
|
|
179
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, signal }, async (handle) => {
|
|
180
|
+
const info = await elementInfo(handle);
|
|
181
|
+
await handle.scrollIntoView().catch(() => { });
|
|
182
|
+
await handle.click({ button: a.button ?? 'left', count: a.count ?? 1 });
|
|
183
|
+
await sleep(50);
|
|
184
|
+
return { clicked: info, url: ctx.current().page.url() };
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
async hover(ctx, raw, signal) {
|
|
188
|
+
const a = z.object({ target, timeout: timeoutField }).parse(raw);
|
|
189
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, signal }, async (handle) => {
|
|
190
|
+
await handle.hover();
|
|
191
|
+
return { hovered: await elementInfo(handle) };
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
async focus(ctx, raw, signal) {
|
|
195
|
+
const a = z.object({ target, timeout: timeoutField }).parse(raw);
|
|
196
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, signal }, async (handle) => {
|
|
197
|
+
await handle.focus();
|
|
198
|
+
return { focused: await elementInfo(handle) };
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
async type(ctx, raw, signal) {
|
|
202
|
+
const a = z.object({ target, text: z.string(), delay: z.number().nonnegative().optional(), clear: z.boolean().optional(), timeout: timeoutField }).parse(raw);
|
|
203
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, signal }, async (handle) => {
|
|
204
|
+
await handle.focus();
|
|
205
|
+
if (a.clear) {
|
|
206
|
+
await handle.evaluate((el) => {
|
|
207
|
+
const input = el;
|
|
208
|
+
if (typeof input.select === 'function')
|
|
209
|
+
input.select();
|
|
210
|
+
else if (el.isContentEditable) {
|
|
211
|
+
const range = document.createRange();
|
|
212
|
+
range.selectNodeContents(el);
|
|
213
|
+
const sel = window.getSelection();
|
|
214
|
+
sel?.removeAllRanges();
|
|
215
|
+
sel?.addRange(range);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
await ctx.current().page.keyboard.press('Backspace');
|
|
219
|
+
}
|
|
220
|
+
await handle.type(a.text, { delay: a.delay ?? 0 });
|
|
221
|
+
const value = await handle.evaluate((el) => el.value ?? el.innerText ?? '');
|
|
222
|
+
return { typed: a.text.length, value: String(value).slice(0, 200) };
|
|
223
|
+
});
|
|
224
|
+
},
|
|
225
|
+
async press(ctx, raw, signal) {
|
|
226
|
+
const a = z.object({ key: z.string().min(1), target: z.string().optional(), timeout: timeoutField }).parse(raw);
|
|
227
|
+
const page = ctx.current().page;
|
|
228
|
+
if (a.target)
|
|
229
|
+
await withHandle(ctx, a.target, { timeout: a.timeout, signal }, (handle) => handle.focus());
|
|
230
|
+
const { modifiers, key } = parseKeyCombo(a.key);
|
|
231
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
232
|
+
for (const m of modifiers)
|
|
233
|
+
await page.keyboard.down(m);
|
|
234
|
+
try {
|
|
235
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
236
|
+
await page.keyboard.press(key);
|
|
237
|
+
}
|
|
238
|
+
finally {
|
|
239
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
240
|
+
for (const m of [...modifiers].reverse())
|
|
241
|
+
await page.keyboard.up(m);
|
|
242
|
+
}
|
|
243
|
+
await sleep(50);
|
|
244
|
+
return { pressed: a.key, url: page.url() };
|
|
245
|
+
},
|
|
246
|
+
async select(ctx, raw, signal) {
|
|
247
|
+
const a = z.object({ target, values: z.array(z.string()).min(1), timeout: timeoutField }).parse(raw);
|
|
248
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, signal }, async (handle) => ({ selected: await handle.select(...a.values) }));
|
|
249
|
+
},
|
|
250
|
+
async check(ctx, raw, signal) {
|
|
251
|
+
const a = z.object({ target, checked: z.boolean(), timeout: timeoutField }).parse(raw);
|
|
252
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, signal }, async (handle) => {
|
|
253
|
+
const before = await handle.evaluate((el) => Boolean(el.checked));
|
|
254
|
+
if (before !== a.checked)
|
|
255
|
+
await handle.click();
|
|
256
|
+
const after = await handle.evaluate((el) => Boolean(el.checked));
|
|
257
|
+
return { checked: after, changed: before !== after };
|
|
258
|
+
});
|
|
259
|
+
},
|
|
260
|
+
async upload(ctx, raw, signal) {
|
|
261
|
+
const a = z.object({ target, files: z.array(z.string()).min(1), timeout: timeoutField }).parse(raw);
|
|
262
|
+
for (const f of a.files)
|
|
263
|
+
if (!fs.existsSync(f))
|
|
264
|
+
throw err('BROWSER_FILE_NOT_FOUND', `Файл для загрузки не найден: ${f}`);
|
|
265
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, visible: false, signal }, async (handle) => {
|
|
266
|
+
await handle.uploadFile(...a.files);
|
|
267
|
+
return { uploaded: a.files.map((f) => path.basename(f)) };
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
async scroll(ctx, raw, signal) {
|
|
271
|
+
const a = z.object({ target: z.string().optional(), dx: z.number().optional(), dy: z.number().optional(), timeout: timeoutField }).parse(raw);
|
|
272
|
+
const page = ctx.current().page;
|
|
273
|
+
if (a.target) {
|
|
274
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, visible: false, signal }, async (handle) => {
|
|
275
|
+
await handle.scrollIntoView();
|
|
276
|
+
return { scrolledTo: await elementInfo(handle) };
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
const pos = await page.evaluate((dx, dy) => {
|
|
280
|
+
window.scrollBy(dx, dy);
|
|
281
|
+
return { x: window.scrollX, y: window.scrollY };
|
|
282
|
+
}, a.dx ?? 0, a.dy ?? 600);
|
|
283
|
+
return { position: pos };
|
|
284
|
+
},
|
|
285
|
+
async wait(ctx, raw, signal) {
|
|
286
|
+
const a = z
|
|
287
|
+
.object({ target: z.string().optional(), state: z.enum(['visible', 'hidden', 'attached']).optional(), text: z.string().optional(), url: z.string().optional(), fn: z.string().optional(), ms: z.number().nonnegative().optional(), timeout: timeoutField })
|
|
288
|
+
.parse(raw);
|
|
289
|
+
const page = ctx.current().page;
|
|
290
|
+
const timeout = a.timeout ?? ctx.settings.timeoutMs;
|
|
291
|
+
const done = [];
|
|
292
|
+
if (a.ms !== undefined) {
|
|
293
|
+
await sleep(a.ms, signal);
|
|
294
|
+
done.push(`пауза ${a.ms} мс`);
|
|
295
|
+
}
|
|
296
|
+
if (a.target) {
|
|
297
|
+
const t = parseTarget(a.target);
|
|
298
|
+
if (t.kind === 'ref')
|
|
299
|
+
throw err('BROWSER_WAIT_REF', 'Ожидание по ссылке [eN] не поддерживается: укажите селектор.');
|
|
300
|
+
const state = a.state ?? 'visible';
|
|
301
|
+
try {
|
|
302
|
+
const handle = await page.waitForSelector(t.selector, { timeout, visible: state === 'visible', hidden: state === 'hidden', signal });
|
|
303
|
+
await handle?.dispose().catch(() => { });
|
|
304
|
+
}
|
|
305
|
+
catch (e) {
|
|
306
|
+
if (signal.aborted)
|
|
307
|
+
throw err('BROWSER_ABORTED', 'Клиент отключился, ожидание прервано.');
|
|
308
|
+
throw err('BROWSER_TIMEOUT', `Элемент «${t.raw}» не стал ${state} за ${timeout} мс: ${e.message.split('\n')[0]}`);
|
|
309
|
+
}
|
|
310
|
+
done.push(`${t.raw}: ${state}`);
|
|
311
|
+
}
|
|
312
|
+
if (a.text !== undefined) {
|
|
313
|
+
try {
|
|
314
|
+
const handle = await page.waitForFunction((needle) => (document.body?.innerText ?? '').includes(needle), { timeout, polling: 200, signal }, a.text);
|
|
315
|
+
await handle.dispose().catch(() => { });
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
if (signal.aborted)
|
|
319
|
+
throw err('BROWSER_ABORTED', 'Клиент отключился, ожидание прервано.');
|
|
320
|
+
throw err('BROWSER_TIMEOUT', `Текст «${a.text}» не появился за ${timeout} мс.`);
|
|
321
|
+
}
|
|
322
|
+
done.push(`текст «${a.text}»`);
|
|
323
|
+
}
|
|
324
|
+
if (a.url !== undefined) {
|
|
325
|
+
const deadline = Date.now() + timeout;
|
|
326
|
+
while (!urlMatches(page.url(), a.url)) {
|
|
327
|
+
if (signal.aborted)
|
|
328
|
+
throw err('BROWSER_ABORTED', 'Клиент отключился, ожидание прервано.');
|
|
329
|
+
if (Date.now() > deadline)
|
|
330
|
+
throw err('BROWSER_TIMEOUT', `Адрес не совпал с «${a.url}» за ${timeout} мс; сейчас ${page.url()}.`);
|
|
331
|
+
await sleep(100, signal);
|
|
332
|
+
}
|
|
333
|
+
done.push(`url ${a.url}`);
|
|
334
|
+
}
|
|
335
|
+
if (a.fn !== undefined) {
|
|
336
|
+
try {
|
|
337
|
+
const handle = await page.waitForFunction(a.fn, { timeout, polling: 200, signal });
|
|
338
|
+
await handle.dispose().catch(() => { });
|
|
339
|
+
}
|
|
340
|
+
catch (e) {
|
|
341
|
+
if (signal.aborted)
|
|
342
|
+
throw err('BROWSER_ABORTED', 'Клиент отключился, ожидание прервано.');
|
|
343
|
+
throw err('BROWSER_TIMEOUT', `Условие «${a.fn}» не выполнилось за ${timeout} мс: ${e.message.split('\n')[0]}`);
|
|
344
|
+
}
|
|
345
|
+
done.push(`условие ${a.fn}`);
|
|
346
|
+
}
|
|
347
|
+
if (done.length === 0)
|
|
348
|
+
throw err('BROWSER_WAIT_EMPTY', 'Укажите, чего ждать: элемент, --text, --url, --fn или --ms.');
|
|
349
|
+
return { waited: done, url: page.url() };
|
|
350
|
+
},
|
|
351
|
+
async text(ctx, raw, signal) {
|
|
352
|
+
const a = z.object({ target: z.string().optional(), limit: z.number().int().positive().optional(), timeout: timeoutField }).parse(raw);
|
|
353
|
+
const page = ctx.current().page;
|
|
354
|
+
const limit = a.limit ?? 20_000;
|
|
355
|
+
// Обрезка внутри страницы: через CDP уходит только нужный кусок, а не мегабайты текста.
|
|
356
|
+
const cut = a.target
|
|
357
|
+
? await withHandle(ctx, a.target, { timeout: a.timeout, visible: false, signal }, (handle) => handle.evaluate((el, max) => {
|
|
358
|
+
const s = (el.innerText ?? el.textContent ?? '').trim();
|
|
359
|
+
return { text: s.slice(0, max), length: s.length };
|
|
360
|
+
}, limit))
|
|
361
|
+
: await page.evaluate((max) => {
|
|
362
|
+
const s = (document.body?.innerText ?? '').trim();
|
|
363
|
+
return { text: s.slice(0, max), length: s.length };
|
|
364
|
+
}, limit);
|
|
365
|
+
return { text: cut.text, truncated: cut.length > limit, length: cut.length, url: page.url() };
|
|
366
|
+
},
|
|
367
|
+
async html(ctx, raw, signal) {
|
|
368
|
+
const a = z.object({ target: z.string().optional(), outer: z.boolean().optional(), limit: z.number().int().positive().optional(), timeout: timeoutField }).parse(raw);
|
|
369
|
+
const page = ctx.current().page;
|
|
370
|
+
const limit = a.limit ?? 50_000;
|
|
371
|
+
const cut = a.target
|
|
372
|
+
? await withHandle(ctx, a.target, { timeout: a.timeout, visible: false, signal }, (handle) => handle.evaluate((el, outer, max) => {
|
|
373
|
+
const s = outer ? el.outerHTML : el.innerHTML;
|
|
374
|
+
return { html: s.slice(0, max), length: s.length };
|
|
375
|
+
}, a.outer ?? true, limit))
|
|
376
|
+
: await page.evaluate((max) => {
|
|
377
|
+
const s = document.documentElement?.outerHTML ?? '';
|
|
378
|
+
return { html: s.slice(0, max), length: s.length };
|
|
379
|
+
}, limit);
|
|
380
|
+
return { html: cut.html, truncated: cut.length > limit, length: cut.length };
|
|
381
|
+
},
|
|
382
|
+
async attr(ctx, raw, signal) {
|
|
383
|
+
const a = z.object({ target, name: z.string().min(1), timeout: timeoutField }).parse(raw);
|
|
384
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, visible: false, signal }, async (handle) => ({ name: a.name, value: await handle.evaluate((el, name) => el.getAttribute(name), a.name) }));
|
|
385
|
+
},
|
|
386
|
+
async value(ctx, raw, signal) {
|
|
387
|
+
const a = z.object({ target, timeout: timeoutField }).parse(raw);
|
|
388
|
+
return withHandle(ctx, a.target, { timeout: a.timeout, visible: false, signal }, async (handle) => ({ value: await handle.evaluate((el) => el.value ?? null) }));
|
|
389
|
+
},
|
|
390
|
+
async count(ctx, raw) {
|
|
391
|
+
const a = z.object({ target }).parse(raw);
|
|
392
|
+
const t = parseTarget(a.target);
|
|
393
|
+
if (t.kind === 'ref')
|
|
394
|
+
throw err('BROWSER_COUNT_REF', 'Подсчёт работает по селектору, а не по ссылке [eN].');
|
|
395
|
+
// Считаем внутри страницы: без N remote-объектов на каждый вызов.
|
|
396
|
+
const count = await ctx.current().page.$$eval(t.selector, (els) => els.length);
|
|
397
|
+
return { count };
|
|
398
|
+
},
|
|
399
|
+
async exists(ctx, raw) {
|
|
400
|
+
const a = z.object({ target }).parse(raw);
|
|
401
|
+
const t = parseTarget(a.target);
|
|
402
|
+
const state = ctx.current();
|
|
403
|
+
if (t.kind === 'ref') {
|
|
404
|
+
const node = state.snapshot?.refs.get(t.ref);
|
|
405
|
+
if (!node)
|
|
406
|
+
return { exists: false, visible: false };
|
|
407
|
+
let handle = null;
|
|
408
|
+
try {
|
|
409
|
+
handle = await node.elementHandle();
|
|
410
|
+
}
|
|
411
|
+
catch {
|
|
412
|
+
handle = null;
|
|
413
|
+
}
|
|
414
|
+
if (!handle)
|
|
415
|
+
return { exists: false, visible: false };
|
|
416
|
+
try {
|
|
417
|
+
const connected = await handle.evaluate((el) => el.isConnected).catch(() => false);
|
|
418
|
+
const visible = connected ? await handle.isVisible().catch(() => false) : false;
|
|
419
|
+
return { exists: connected, visible };
|
|
420
|
+
}
|
|
421
|
+
finally {
|
|
422
|
+
await handle.dispose().catch(() => { });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
const handle = await state.page.$(t.selector);
|
|
426
|
+
if (!handle)
|
|
427
|
+
return { exists: false, visible: false };
|
|
428
|
+
try {
|
|
429
|
+
return { exists: true, visible: await handle.isVisible().catch(() => false) };
|
|
430
|
+
}
|
|
431
|
+
finally {
|
|
432
|
+
await handle.dispose().catch(() => { });
|
|
433
|
+
}
|
|
434
|
+
},
|
|
435
|
+
async eval(ctx, raw) {
|
|
436
|
+
const a = z.object({ code: z.string().min(1) }).parse(raw);
|
|
437
|
+
const page = ctx.current().page;
|
|
438
|
+
// Валидный скрипт (выражение или список инструкций: значение последнего) исполняется как есть;
|
|
439
|
+
// код с return или await на верхнем уровне синтаксически не скрипт, поэтому идёт как тело async-функции.
|
|
440
|
+
const code = isScript(a.code) ? a.code : `(async () => { ${a.code} })()`;
|
|
441
|
+
const result = await page.evaluate(code);
|
|
442
|
+
return { result: result === undefined ? null : result };
|
|
443
|
+
},
|
|
444
|
+
async snapshot(ctx, raw, signal) {
|
|
445
|
+
const a = z.object({ interactive: z.boolean().optional(), root: z.string().optional(), maxChars: z.number().int().positive().optional(), timeout: timeoutField }).parse(raw);
|
|
446
|
+
const state = ctx.current();
|
|
447
|
+
const root = a.root ? await resolveHandle(ctx, a.root, { timeout: a.timeout, visible: false, signal }) : undefined;
|
|
448
|
+
try {
|
|
449
|
+
const tree = await state.page.accessibility.snapshot({ interestingOnly: true, includeIframes: true, ...(root ? { root } : {}) });
|
|
450
|
+
const rendered = renderSnapshot(tree, { interactiveOnly: a.interactive, maxChars: a.maxChars });
|
|
451
|
+
state.snapshot = { refs: rendered.refs, url: state.page.url(), at: new Date().toISOString() };
|
|
452
|
+
return { url: state.page.url(), title: await state.page.title(), count: rendered.count, truncated: rendered.truncated, text: rendered.text };
|
|
453
|
+
}
|
|
454
|
+
finally {
|
|
455
|
+
await root?.dispose().catch(() => { });
|
|
456
|
+
}
|
|
457
|
+
},
|
|
458
|
+
async screenshot(ctx, raw, signal) {
|
|
459
|
+
const a = z.object({ out: z.string().optional(), full: z.boolean().optional(), target: z.string().optional(), timeout: timeoutField }).parse(raw);
|
|
460
|
+
const file = a.out ?? path.join(browserDirs.shots(ctx.env), `${ctx.session}-${timestampSlug()}.png`);
|
|
461
|
+
ensureDir(path.dirname(file));
|
|
462
|
+
const state = ctx.current();
|
|
463
|
+
if (a.target) {
|
|
464
|
+
await withHandle(ctx, a.target, { timeout: a.timeout, signal }, (handle) => handle.screenshot({ path: file }));
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
await state.page.screenshot({ path: file, fullPage: a.full ?? false });
|
|
468
|
+
}
|
|
469
|
+
return { path: file, url: state.page.url() };
|
|
470
|
+
},
|
|
471
|
+
async console(ctx, raw) {
|
|
472
|
+
const a = z.object({ clear: z.boolean().optional(), errors: z.boolean().optional() }).parse(raw);
|
|
473
|
+
const state = ctx.current();
|
|
474
|
+
const entries = a.errors ? state.console.filter((e) => e.kind === 'pageerror' || e.type === 'error' || e.type === 'assert') : [...state.console];
|
|
475
|
+
if (a.clear)
|
|
476
|
+
state.console.length = 0;
|
|
477
|
+
return { entries, url: state.page.url() };
|
|
478
|
+
},
|
|
479
|
+
async requests(ctx, raw) {
|
|
480
|
+
const a = z.object({ clear: z.boolean().optional() }).parse(raw);
|
|
481
|
+
const state = ctx.current();
|
|
482
|
+
const entries = [...state.network];
|
|
483
|
+
if (a.clear)
|
|
484
|
+
state.network.length = 0;
|
|
485
|
+
return { entries, url: state.page.url() };
|
|
486
|
+
},
|
|
487
|
+
async cookies(ctx, raw) {
|
|
488
|
+
const a = z
|
|
489
|
+
.object({ action: z.enum(['list', 'set', 'clear']), cookies: z.array(z.object({ name: z.string(), value: z.string(), domain: z.string().optional(), url: z.string().optional(), path: z.string().optional(), secure: z.boolean().optional(), httpOnly: z.boolean().optional() })).optional() })
|
|
490
|
+
.parse(raw);
|
|
491
|
+
const page = ctx.current().page;
|
|
492
|
+
if (a.action === 'list')
|
|
493
|
+
return { cookies: await ctx.browser.cookies() };
|
|
494
|
+
if (a.action === 'clear') {
|
|
495
|
+
const all = await ctx.browser.cookies();
|
|
496
|
+
if (all.length)
|
|
497
|
+
await ctx.browser.deleteCookie(...all);
|
|
498
|
+
return { cleared: all.length };
|
|
499
|
+
}
|
|
500
|
+
const list = a.cookies ?? [];
|
|
501
|
+
for (const c of list) {
|
|
502
|
+
const url = c.url ?? page.url();
|
|
503
|
+
const domain = c.domain ?? new URL(url).hostname;
|
|
504
|
+
await ctx.browser.setCookie({ name: c.name, value: c.value, domain, path: c.path ?? '/', secure: c.secure, httpOnly: c.httpOnly });
|
|
505
|
+
}
|
|
506
|
+
return { set: list.length };
|
|
507
|
+
},
|
|
508
|
+
async 'state.export'(ctx) {
|
|
509
|
+
const page = ctx.current().page;
|
|
510
|
+
const cookies = await ctx.browser.cookies();
|
|
511
|
+
const origins = [];
|
|
512
|
+
if (/^https?:/.test(page.url())) {
|
|
513
|
+
const storage = await page.evaluate(() => {
|
|
514
|
+
const dump = (s) => Object.fromEntries(Array.from({ length: s.length }, (_, i) => s.key(i)).map((k) => [k, s.getItem(k) ?? '']));
|
|
515
|
+
return { origin: location.origin, localStorage: dump(localStorage), sessionStorage: dump(sessionStorage) };
|
|
516
|
+
});
|
|
517
|
+
origins.push(storage);
|
|
518
|
+
}
|
|
519
|
+
return { state: { version: 1, savedAt: new Date().toISOString(), cookies, origins } };
|
|
520
|
+
},
|
|
521
|
+
async 'state.import'(ctx, raw) {
|
|
522
|
+
const a = z
|
|
523
|
+
.object({
|
|
524
|
+
state: z.object({
|
|
525
|
+
cookies: z.array(z.record(z.string(), z.unknown())).default([]),
|
|
526
|
+
origins: z.array(z.object({ origin: z.string(), localStorage: z.record(z.string(), z.string()).default({}), sessionStorage: z.record(z.string(), z.string()).default({}) })).default([]),
|
|
527
|
+
}),
|
|
528
|
+
})
|
|
529
|
+
.parse(raw);
|
|
530
|
+
const cookies = a.state.cookies.map((c) => {
|
|
531
|
+
const pick = (k) => c[k];
|
|
532
|
+
const data = { name: String(pick('name')), value: String(pick('value')), domain: String(pick('domain')) };
|
|
533
|
+
if (typeof pick('path') === 'string')
|
|
534
|
+
data.path = pick('path');
|
|
535
|
+
if (typeof pick('secure') === 'boolean')
|
|
536
|
+
data.secure = pick('secure');
|
|
537
|
+
if (typeof pick('httpOnly') === 'boolean')
|
|
538
|
+
data.httpOnly = pick('httpOnly');
|
|
539
|
+
if (typeof pick('sameSite') === 'string')
|
|
540
|
+
data.sameSite = pick('sameSite');
|
|
541
|
+
if (typeof pick('expires') === 'number' && pick('expires') > 0)
|
|
542
|
+
data.expires = pick('expires');
|
|
543
|
+
return data;
|
|
544
|
+
});
|
|
545
|
+
if (cookies.length)
|
|
546
|
+
await ctx.browser.setCookie(...cookies);
|
|
547
|
+
const page = ctx.current().page;
|
|
548
|
+
let storages = 0;
|
|
549
|
+
for (const o of a.state.origins) {
|
|
550
|
+
const entries = Object.entries(o.localStorage);
|
|
551
|
+
const sessionEntries = Object.entries(o.sessionStorage);
|
|
552
|
+
if (entries.length === 0 && sessionEntries.length === 0)
|
|
553
|
+
continue;
|
|
554
|
+
let wanted;
|
|
555
|
+
try {
|
|
556
|
+
wanted = new URL(o.origin).origin;
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
throw err('BROWSER_BAD_ARGS', `Некорректный origin в файле состояния: ${o.origin}`);
|
|
560
|
+
}
|
|
561
|
+
// Сравнение origin целиком: префикс строки принял бы localhost:30001 за localhost:3000.
|
|
562
|
+
if (pageOrigin(page) !== wanted)
|
|
563
|
+
await page.goto(wanted, { waitUntil: 'domcontentloaded', timeout: ctx.settings.timeoutMs });
|
|
564
|
+
await page.evaluate((ls, ss) => {
|
|
565
|
+
for (const [k, v] of ls)
|
|
566
|
+
localStorage.setItem(k, v);
|
|
567
|
+
for (const [k, v] of ss)
|
|
568
|
+
sessionStorage.setItem(k, v);
|
|
569
|
+
}, entries, sessionEntries);
|
|
570
|
+
storages += 1;
|
|
571
|
+
}
|
|
572
|
+
return { cookies: cookies.length, origins: storages };
|
|
573
|
+
},
|
|
574
|
+
async viewport(ctx, raw) {
|
|
575
|
+
const a = z.object({ width: z.number().int().positive(), height: z.number().int().positive() }).parse(raw);
|
|
576
|
+
ctx.viewportOverride = { width: a.width, height: a.height };
|
|
577
|
+
ctx.settings.viewport = { ...ctx.viewportOverride };
|
|
578
|
+
await ctx.current().page.setViewport(ctx.viewportOverride);
|
|
579
|
+
return { viewport: ctx.viewportOverride };
|
|
580
|
+
},
|
|
581
|
+
async dialog(ctx, raw) {
|
|
582
|
+
const a = z.object({ action: z.enum(['accept', 'dismiss']).optional(), text: z.string().optional() }).parse(raw);
|
|
583
|
+
if (a.action)
|
|
584
|
+
ctx.dialogPolicy = { action: a.action, ...(a.text !== undefined ? { text: a.text } : {}) };
|
|
585
|
+
return { policy: ctx.dialogPolicy, recent: ctx.current().console.filter((e) => e.kind === 'dialog').slice(-5) };
|
|
586
|
+
},
|
|
587
|
+
async auth(ctx, raw) {
|
|
588
|
+
const a = z.object({ username: z.string(), password: z.string() }).parse(raw);
|
|
589
|
+
await ctx.current().page.authenticate({ username: a.username, password: a.password });
|
|
590
|
+
return { auth: a.username };
|
|
591
|
+
},
|
|
592
|
+
async headers(ctx, raw) {
|
|
593
|
+
const a = z.object({ headers: z.record(z.string(), z.string()) }).parse(raw);
|
|
594
|
+
await ctx.current().page.setExtraHTTPHeaders(a.headers);
|
|
595
|
+
return { headers: Object.keys(a.headers) };
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
//# sourceMappingURL=commands.js.map
|