get-handoff 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/handoff.mjs CHANGED
@@ -5,24 +5,45 @@ import { parsePlan } from "../src/plan.js";
5
5
  import { openBrowser } from "../src/browser.js";
6
6
  import { walkPlan } from "../src/walk.js";
7
7
  import { buildPayload } from "../src/payload.js";
8
- import { uploadFrames, uploadClips, createDraft, revokeWalkthrough } from "../src/api.js";
8
+ import { uploadFrames, uploadClips, createDraft, revokeWalkthrough, publishWalkthrough, listWalkthroughs, pruneWalkthroughs } from "../src/api.js";
9
9
  import { readGitContext } from "../src/git.js";
10
10
 
11
- const args = process.argv.slice(2);
12
- const cmd = args[0];
13
-
14
- if (!["walk", "revoke"].includes(cmd) || !args[1]) {
15
- console.error(`handoff walk <план.md> [опции]
11
+ const USAGE = `handoff walk <план.md> [опции]
12
+ handoff publish <wlk_id> [--summary <файл.json>] — опубликовать черновик (и прикрепить сводку)
13
+ handoff list — показать свои прогоны (снятые и черновики)
14
+ handoff prune — удалить свои ЧЕРНОВИКИ (опубликованное не трогается)
16
15
  handoff revoke <слаг|id> — закрыть доступ к уже разосланной ссылке
17
16
 
18
17
  --attach <url> подключиться к запущенному браузеру (по умолчанию — свой профиль)
19
18
  --headless не показывать окно
19
+ --settle <мс> пауза после каждого шага (или HANDOFF_SETTLE; медленным SPA — больше)
20
+ --no-clip не писать клип-запись (страница её не читает — быстрее и легче)
21
+ --fail-fast не создавать черновик, если хоть один шаг провалился
20
22
  --out <файл> записать тело запроса в файл вместо создания черновика
23
+ --summary <файл> (для publish) JSON-массив [{stepIdx, text}, …] — по пункту на шаг
21
24
  --api <url> база edge-функций (или HANDOFF_API)
22
25
  --key <ключ> agent key (или HANDOFF_KEY)
26
+ -h, --help показать эту справку
27
+
28
+ Действия плана: open <путь> · click <селектор> · type <селектор> "текст" · check <селектор> ·
29
+ scroll <селектор> · wait <селектор|мс> (только ждать, кадр НЕ снимать). Цель может содержать
30
+ пробелы без кавычек — подпись отделяется тире: click button:has-text("Оформить") — Оформление.
31
+
32
+ Сводку «что изменилось» CLI не пишет: её дописывает агент, посмотрев снятые кадры, и прикрепляет
33
+ при публикации — MCP-тул publish_walkthrough(walkthroughId, summary).`;
34
+
35
+ const args = process.argv.slice(2);
36
+ const cmd = args[0];
23
37
 
24
- Сводку «что изменилось» CLI не пишет: её дописывает агент, глядя на свой диff
25
- и на снятые кадры, и только он может выполнить правило «пункт ссылается на шаг».`);
38
+ // --help/-h пустой вызов) справка в stdout, код 0. Раньше `walk --help`
39
+ // трактовал --help как путь к файлу плана и падал ENOENT со стектрейсом.
40
+ if (!cmd || cmd === "--help" || cmd === "-h" || args.includes("--help") || args.includes("-h")) {
41
+ console.log(USAGE);
42
+ process.exit(0);
43
+ }
44
+ const NEEDS_ARG = ["walk", "publish", "revoke"]; // list/prune аргумента не требуют
45
+ if (!["walk", "publish", "revoke", "list", "prune"].includes(cmd) || (NEEDS_ARG.includes(cmd) && !args[1])) {
46
+ console.error(USAGE);
26
47
  process.exit(1);
27
48
  }
28
49
 
@@ -50,6 +71,45 @@ if (cmd === "revoke") {
50
71
  process.exit(0);
51
72
  }
52
73
 
74
+ // Публикация из CLI — гомогенно с revoke (браузер не нужен). Сводку читаем из
75
+ // JSON-файла: [{stepIdx, text}, …], по пункту на шаг. Без --summary просто
76
+ // публикуем; повторный вызов со свежей сводкой обновляет её на месте.
77
+ if (cmd === "publish") {
78
+ if (!cfgEarly.agentKey) {
79
+ console.error("Нужен agent key: --key или HANDOFF_KEY.");
80
+ process.exit(2);
81
+ }
82
+ const summaryFile = opt("summary");
83
+ let summary;
84
+ if (summaryFile) {
85
+ try { summary = JSON.parse(readFileSync(summaryFile, "utf8")); }
86
+ catch (e) { console.error(`не прочитать summary ${summaryFile}: ${e.message}`); process.exit(2); }
87
+ }
88
+ const res = await publishWalkthrough(args[1], summary, cfgEarly);
89
+ console.log(res.alreadyPublished
90
+ ? `Обновлено: ${res.url}${summary ? " (сводка обновлена)" : ""}`
91
+ : `Опубликовано: ${res.url}`);
92
+ process.exit(0);
93
+ }
94
+
95
+ if (cmd === "list") {
96
+ if (!cfgEarly.agentKey) { console.error("Нужен agent key: --key или HANDOFF_KEY."); process.exit(2); }
97
+ const items = await listWalkthroughs(cfgEarly);
98
+ if (items.length === 0) { console.log("Прогонов пока нет."); process.exit(0); }
99
+ for (const w of items) {
100
+ const tail = w.url ? ` ${w.url}` : "";
101
+ console.log(` ${w.status.padEnd(10)} ${w.walkthroughId} ${w.planKey ?? "—"} «${w.title}»${tail}`);
102
+ }
103
+ process.exit(0);
104
+ }
105
+
106
+ if (cmd === "prune") {
107
+ if (!cfgEarly.agentKey) { console.error("Нужен agent key: --key или HANDOFF_KEY."); process.exit(2); }
108
+ const n = await pruneWalkthroughs(cfgEarly);
109
+ console.log(n === 0 ? "Черновиков на удаление нет." : `Удалено черновиков: ${n} (опубликованное не тронуто).`);
110
+ process.exit(0);
111
+ }
112
+
53
113
  const plan = parsePlan(readFileSync(args[1], "utf8"));
54
114
  // Файл плана — это и есть личность прогона: `help.md` остаётся `help.md`,
55
115
  // как бы ни звучал заголовок внутри.
@@ -59,11 +119,18 @@ const cfg = {
59
119
  agentKey: opt("key", process.env.HANDOFF_KEY ?? ""),
60
120
  };
61
121
 
122
+ // Пауза после каждого шага. Медленным SPA (данные тянутся XHR-ом, кнопки
123
+ // рендерятся секунды) можно дать больше; невалидное значение → дефолт walkPlan.
124
+ const settleRaw = opt("settle", process.env.HANDOFF_SETTLE);
125
+ const settle = settleRaw != null && settleRaw !== "" && Number.isFinite(Number(settleRaw))
126
+ ? Number(settleRaw) : undefined;
127
+
128
+ const recordClip = !has("no-clip");
62
129
  console.log(`Прогон «${plan.title}» — ${plan.steps.length} шагов на ${plan.origin}`);
63
130
  const { page, close } = await openBrowser({ attach: opt("attach"), headless: has("headless") });
64
131
  let shots;
65
132
  try {
66
- shots = await walkPlan(page, plan);
133
+ shots = await walkPlan(page, plan, { settle, recordClip });
67
134
  } finally {
68
135
  await close();
69
136
  }
@@ -71,6 +138,17 @@ try {
71
138
  const failed = shots.filter((s) => s.status === "failed");
72
139
  for (const s of shots) console.log(` ${s.status === "ok" ? "снят" : "ПРОВАЛ"} ${s.title}${s.failure ? " — " + s.failure : ""}`);
73
140
 
141
+ // Провалы видно ЗАМЕТНО и ДО создания черновика: раньше «снят/ПРОВАЛ» тонули в
142
+ // логе, и брак уезжал в публикацию. --fail-fast вообще не создаёт черновик.
143
+ if (failed.length) {
144
+ console.log(`\n⚠️ ${failed.length} шаг(ов) провалились — проверь перед публикацией:`);
145
+ for (const s of failed) console.log(` ✗ ${s.title}${s.failure ? " — " + s.failure : ""}`);
146
+ if (has("fail-fast")) {
147
+ console.error("\n--fail-fast: черновик не создаю. Почини план и переснимай.");
148
+ process.exit(1);
149
+ }
150
+ }
151
+
74
152
  const outFile = opt("out");
75
153
 
76
154
  // С ключом кадры заливаются всегда — даже когда результат уходит в файл.
@@ -99,6 +177,5 @@ if (!cfg.agentKey) {
99
177
  const draft = await createDraft(payload, cfg);
100
178
 
101
179
  console.log(`\nЧерновик готов: ${draft.walkthroughId} (slug ${draft.slug})`);
102
- console.log("Сводка пуста допиши её и опубликуй через walkthrough-publish.");
103
- if (failed.length) console.log(`Внимание: ${failed.length} шаг(ов) провалились — посмотри перед публикацией.`);
180
+ console.log(`Посмотри кадры, затем опубликуй со сводкой: publish_walkthrough(walkthroughId="${draft.walkthroughId}", summary=[…]) (MCP).`);
104
181
  process.exit(failed.length ? 1 : 0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "get-handoff",
3
- "version": "1.0.0",
3
+ "version": "1.2.0",
4
4
  "type": "module",
5
5
  "bin": { "handoff": "./bin/handoff.mjs", "get-handoff": "./bin/handoff.mjs" },
6
6
  "files": ["bin/", "src/", "!src/**/*.test.js"],
package/src/api.js CHANGED
@@ -67,6 +67,44 @@ export async function createDraft(payload, cfg, fetchImpl = fetch) {
67
67
  return body;
68
68
  }
69
69
 
70
+ // Публикация черновика со сводкой — тот же контракт, что MCP publish_walkthrough
71
+ // (сводку валидирует сервер: по пункту на шаг, покрыты все). summary можно не
72
+ // передавать — тогда просто публикуем; повторный вызов обновляет сводку на месте.
73
+ export async function publishWalkthrough(walkthroughId, summary, cfg, fetchImpl = fetch) {
74
+ const res = await fetchImpl(`${cfg.apiBase}/walkthrough-publish`, {
75
+ method: "POST",
76
+ headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
77
+ body: JSON.stringify(summary === undefined ? { walkthroughId } : { walkthroughId, summary }),
78
+ });
79
+ const data = await res.json().catch(() => ({}));
80
+ if (!res.ok) throw new Error(`не удалось опубликовать: ${data.error ?? res.status}`);
81
+ return data;
82
+ }
83
+
84
+ // Список прогонов этого ключа (что снято, что осталось черновиком).
85
+ export async function listWalkthroughs(cfg, fetchImpl = fetch) {
86
+ const res = await fetchImpl(`${cfg.apiBase}/walkthrough-list`, {
87
+ method: "POST",
88
+ headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
89
+ body: "{}",
90
+ });
91
+ const data = await res.json().catch(() => ({}));
92
+ if (!res.ok) throw new Error(`не удалось получить список: ${data.error ?? res.status}`);
93
+ return data.walkthroughs ?? [];
94
+ }
95
+
96
+ // Подчистить черновики этого ключа (опубликованное не трогается).
97
+ export async function pruneWalkthroughs(cfg, fetchImpl = fetch) {
98
+ const res = await fetchImpl(`${cfg.apiBase}/walkthrough-prune`, {
99
+ method: "POST",
100
+ headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
101
+ body: "{}",
102
+ });
103
+ const data = await res.json().catch(() => ({}));
104
+ if (!res.ok) throw new Error(`не удалось подчистить: ${data.error ?? res.status}`);
105
+ return data.pruned ?? 0;
106
+ }
107
+
70
108
  // Отзыв ссылки: единственная кнопка «стоп» для прогона, который уже разослали.
71
109
  // Принимает слаг из адреса или id черновика — что оказалось под рукой.
72
110
  export async function revokeWalkthrough(target, cfg, fetchImpl = fetch) {
package/src/git.js CHANGED
@@ -32,11 +32,19 @@ export function parseGitContext({ branch, sha, baseSha, files, uncommitted }) {
32
32
  }
33
33
 
34
34
  export function readGitContext(cwd = process.cwd()) {
35
+ // stderr в /dev/null: без него git-usage (десятки строк про --histogram и т.п.)
36
+ // сыпался в вывод прогона и топил полезные строки «ПРОВАЛ …» / «Черновик готов».
35
37
  const run = (args) => {
36
- try { return execFileSync("git", args, { cwd, encoding: "utf8" }); }
38
+ try { return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); }
37
39
  catch { return ""; }
38
40
  };
39
41
 
42
+ // Не git-репозиторий — контекст не собираем и НИЧЕГО не печатаем. Прогон часто
43
+ // снимают вне репо (чужое приложение), и там git-команды только шумят.
44
+ if (run(["rev-parse", "--is-inside-work-tree"]).trim() !== "true") {
45
+ return parseGitContext({ branch: "", sha: "", baseSha: "", files: "", uncommitted: "" });
46
+ }
47
+
40
48
  // База ветки: с какого коммита началась эта работа.
41
49
  let baseSha = "";
42
50
  for (const ref of BASE_CANDIDATES) {
package/src/plan.js CHANGED
@@ -10,11 +10,24 @@
10
10
  // 3. type input.promo "SALE20" — Ввести код
11
11
  // 4. check .cart-total — Сумма пересчиталась
12
12
  // 5. scroll .terms — Условия ниже сгиба
13
+ // 6. wait .deal-card — ждём карточку (кадр НЕ снимается)
14
+ //
15
+ // Цель может содержать ПРОБЕЛЫ без кавычек: подпись отделяется тире, а всё между
16
+ // действием и тире — это цель целиком. Раньше цель бралась одним whitespace-токеном,
17
+ // и `check button:has-text("Создать сделку")` не разбирался — теперь разбирается.
18
+ // Одинарные кавычки вокруг цели по-прежнему допускаются (обратная совместимость) и
19
+ // снимаются при разборе.
13
20
 
14
- export const ACTIONS = ["open", "click", "type", "check", "scroll"];
21
+ export const ACTIONS = ["open", "click", "type", "check", "scroll", "wait"];
15
22
 
16
23
  // Действия, которым без цели делать нечего.
17
- const NEEDS_TARGET = ["open", "click", "type", "check", "scroll"];
24
+ const NEEDS_TARGET = ["open", "click", "type", "check", "scroll", "wait"];
25
+
26
+ // `wait` только ждёт (кадр не снимается) — подпись ему не нужна.
27
+ const NEEDS_CAPTION = ["open", "click", "type", "check", "scroll"];
28
+
29
+ const SYNTAX_HINT =
30
+ 'формат: <действие> <цель> — <подпись>. пример: click button:has-text("Оформить") — Оформление';
18
31
 
19
32
  export function parsePlan(source) {
20
33
  const lines = String(source).split(/\r?\n/);
@@ -36,45 +49,59 @@ export function parsePlan(source) {
36
49
  const raw = lines[i];
37
50
  if (!raw.trim()) continue;
38
51
  const m = raw.match(/^\s*\d+\.\s*(.+)$/);
39
- if (!m) continue; // прозаические строки между шагами игнорируем
40
-
41
- // тело шага: <действие> [цель] ["текст"] <тире> <подпись>
42
- //
43
- // Цель можно взять в ОДИНАРНЫЕ кавычки — тогда в ней разрешены пробелы.
44
- // Без этого на живом приложении не за что зацепиться: у кнопок в Tailwind
45
- // классы вида `button.flex.items-center:nth-of-type(7)`, они едут от любой
46
- // правки вёрстки, а устойчивый селектор — текстовый:
47
- // click 'button:has-text("Продвинуть сделку по этапам")' — Выбрал тур
48
- // Двойные кавычки остаются за текстом для `type`, поэтому путаницы нет.
49
- const [body, caption] = splitCaption(m[1], i + 1);
50
- const parts = body.trim().match(/^(\S+)(?:\s+(?:'([^']*)'|(\S+)))?(?:\s+"([^"]*)")?\s*$/);
51
- if (!parts) throw new Error(`строка ${i + 1}: не разобрать шаг «${raw.trim()}»`);
52
-
53
- const action = parts[1];
54
- if (!ACTIONS.includes(action)) {
55
- throw new Error(`строка ${i + 1}: неизвестное действие «${action}» (можно: ${ACTIONS.join(", ")})`);
56
- }
57
- const target = parts[2] ?? parts[3] ?? null;
58
- if (NEEDS_TARGET.includes(action) && !target) {
59
- throw new Error(`строка ${i + 1}: действию «${action}» нужна цель (путь или селектор)`);
60
- }
61
- if (action === "type" && !parts[4]) {
62
- throw new Error(`строка ${i + 1}: действию «type» нужен текст в кавычках`);
63
- }
64
- steps.push({ action, target, text: parts[4] ?? null, title: caption });
52
+ if (!m) continue; // прозаические строки между шагами игнорируем
53
+ steps.push(parseStep(m[1], i + 1));
65
54
  }
66
55
 
67
56
  if (steps.length === 0) throw new Error("В плане нет ни одного шага");
68
57
  return { title: meta.title ?? "Прогон", origin: meta.origin, steps };
69
58
  }
70
59
 
71
- // Подпись отделяется тире длинным или обычным, как удобно писавшему.
72
- function splitCaption(line, lineNo) {
73
- const idx = findSeparator(line);
74
- if (idx < 0) throw new Error(`строка ${lineNo}: нет подписи (ожидается «… — подпись»)`);
75
- return [line.slice(0, idx), line.slice(idx + 1).trim()];
60
+ // Тело шага: <действие> <цель...> ["текст"] <подпись>.
61
+ // Действие — первый токен; всё до разделителя-тире — цель (пробелы разрешены);
62
+ // подпись после тире. У `type` в хвосте цели стоит "текст" в двойных кавычках.
63
+ function parseStep(body, lineNo) {
64
+ const head = body.match(/^(\S+)\s*([\s\S]*)$/);
65
+ const action = head[1];
66
+ if (!ACTIONS.includes(action)) {
67
+ throw new Error(`строка ${lineNo}: неизвестное действие «${action}» (можно: ${ACTIONS.join(", ")})`);
68
+ }
69
+
70
+ const sep = findSeparator(head[2]);
71
+ const targetPart = (sep >= 0 ? head[2].slice(0, sep) : head[2]).trim();
72
+ const caption = sep >= 0 ? head[2].slice(sep + 1).trim() : null;
73
+
74
+ if (caption === null && NEEDS_CAPTION.includes(action)) {
75
+ throw new Error(`строка ${lineNo}: нет подписи (${SYNTAX_HINT})`);
76
+ }
77
+
78
+ let target = null, text = null;
79
+ if (action === "type") {
80
+ const tm = targetPart.match(/^([\s\S]*?)\s+"([^"]*)"\s*$/);
81
+ if (!tm) {
82
+ throw new Error(`строка ${lineNo}: действию «type» нужен текст в двойных кавычках (${SYNTAX_HINT})`);
83
+ }
84
+ target = stripQuotes(tm[1]);
85
+ text = tm[2];
86
+ } else {
87
+ target = stripQuotes(targetPart) || null;
88
+ }
89
+
90
+ if (NEEDS_TARGET.includes(action) && !target) {
91
+ const kind = action === "wait" ? "селектор или число мс" : "путь или селектор";
92
+ throw new Error(`строка ${lineNo}: действию «${action}» нужна цель (${kind})`);
93
+ }
94
+
95
+ return { action, target, text, title: caption };
76
96
  }
77
97
 
98
+ // Снять одинарные кавычки вокруг цели, если писавший их поставил (обратная совместимость).
99
+ function stripQuotes(s) {
100
+ const t = String(s ?? "").trim();
101
+ return /^'.*'$/.test(t) ? t.slice(1, -1) : t;
102
+ }
103
+
104
+ // Подпись отделяется тире — длинным или обычным, как удобно писавшему.
78
105
  function findSeparator(line) {
79
106
  const em = line.indexOf("—");
80
107
  if (em >= 0) return em;
package/src/walk.js CHANGED
@@ -8,10 +8,26 @@ const SETTLE_MS = 400;
8
8
  // Сколько курсор едет к цели. Меньше — движение не читается, больше — клип
9
9
  // уходит в лимит кадров, ничего не показав.
10
10
  const TRAVEL_MS = 400;
11
+ // Верхняя граница ожидания сетевого простоя после шага. SPA тянет данные XHR-ом
12
+ // ПОСЛЕ первичной загрузки, и `networkidle` в момент goto срабатывает в «окне
13
+ // тишины» до старта отложенного запроса — поэтому ждём простой ещё раз, уже
14
+ // после действия. Никогда не роняем прогон: истёк таймаут — просто идём дальше.
15
+ const NETIDLE_MS = 8000;
16
+ // Стабилизация DOM: XHR долетел, но React ещё перерисовывает. Ждём, пока размер
17
+ // разметки не перестанет меняться два замера подряд, с общим потолком.
18
+ const DOM_SAMPLES = 8;
19
+ const DOM_GAP_MS = 150;
11
20
 
12
- export async function walkPlan(page, plan, { capture = captureStep, settle = SETTLE_MS } = {}) {
21
+ export async function walkPlan(page, plan, { capture = captureStep, settle = SETTLE_MS, recordClip = true } = {}) {
13
22
  const shots = [];
14
23
  for (const step of plan.steps) {
24
+ // `wait` только ждёт — кадр НЕ снимается (медленным страницам нужно дать
25
+ // догрузиться без паразитного кадра со спиннером). В прогон не попадает.
26
+ if (step.action === "wait") {
27
+ await doWait(page, step);
28
+ continue;
29
+ }
30
+
15
31
  let status = "ok", failure = null, clipFrames = [];
16
32
  // Курсор едет только туда, куда человек и правда наводит. У перехода
17
33
  // (`open`) цели на экране нет, а у прокрутки цель ещё ЗА экраном — курсор
@@ -26,7 +42,9 @@ export async function walkPlan(page, plan, { capture = captureStep, settle = SET
26
42
  // шаг: не завелась запись — шаг всё равно снимется стоп-кадром.
27
43
  try {
28
44
  if (withCursor) await page.evaluate(installGhost);
29
- clip = await startClip(await page.context().newCDPSession(page));
45
+ // Клип (screencast) — украшение, которое СТРАНИЦА ДАЖЕ НЕ ЧИТАЕТ (жест
46
+ // синтетический, по координатам). --no-clip его не пишет: быстрее и не жжёт CPU.
47
+ clip = recordClip ? await startClip(await page.context().newCDPSession(page)) : null;
30
48
  if (withCursor) {
31
49
  const box = await page.locator(step.target).first().boundingBox().catch(() => null);
32
50
  if (box) {
@@ -39,14 +57,23 @@ export async function walkPlan(page, plan, { capture = captureStep, settle = SET
39
57
  clip = null;
40
58
  }
41
59
 
60
+ let aborted = false;
42
61
  try {
43
62
  await perform(page, plan, step);
44
- await page.waitForTimeout(settle);
63
+ await settleAfter(page, settle);
45
64
  } catch (e) {
46
65
  // Провал шага не роняет прогон: остальные снимутся, а автор увидит
47
- // причину в черновике и решит сам.
66
+ // причину в черновике и решит сам. Исключение — когда исчезло само окно:
67
+ // остальные шаги всё равно упадут тем же, поэтому прекращаем и говорим
68
+ // человеку понятную причину вместо пяти одинаковых «page has been closed».
48
69
  status = "failed";
49
- failure = e instanceof Error ? e.message : String(e);
70
+ const msg = e instanceof Error ? e.message : String(e);
71
+ if (/has been closed|target (page|closed)|browser has been closed/i.test(msg)) {
72
+ failure = `целевая вкладка/окно закрыто во время прогона — не закрывай окно, к которому подключён recorder (${msg})`;
73
+ aborted = true;
74
+ } else {
75
+ failure = msg;
76
+ }
50
77
  }
51
78
 
52
79
  // Запись и курсор снимаются ВСЕГДА, даже если шаг провалился: иначе призрак
@@ -75,10 +102,47 @@ export async function walkPlan(page, plan, { capture = captureStep, settle = SET
75
102
  status,
76
103
  failure,
77
104
  });
105
+
106
+ if (aborted) break; // окно исчезло — дальше снимать нечего
78
107
  }
79
108
  return shots;
80
109
  }
81
110
 
111
+ // Дождаться, пока страница доедет до устойчивого состояния после действия:
112
+ // сетевой простой (повторно — после отложенного XHR) + стабилизация DOM +
113
+ // базовая пауза. Каждое ожидание best-effort: истекло — идём дальше.
114
+ async function settleAfter(page, settle) {
115
+ if (page.waitForLoadState) {
116
+ await page.waitForLoadState("networkidle", { timeout: NETIDLE_MS }).catch(() => {});
117
+ }
118
+ await domStable(page).catch(() => {});
119
+ await page.waitForTimeout(settle);
120
+ }
121
+
122
+ // DOM «устоялся» = размер разметки не изменился два замера подряд. Дёшево и
123
+ // достаточно: ловит XHR-рендер, который долетает уже после networkidle.
124
+ async function domStable(page) {
125
+ let prev = null;
126
+ for (let i = 0; i < DOM_SAMPLES; i++) {
127
+ const sig = await page.evaluate(() => document.documentElement.outerHTML.length).catch(() => null);
128
+ if (typeof sig === "number" && sig === prev) return; // два одинаковых подряд — устоялся
129
+ prev = sig;
130
+ await page.waitForTimeout(DOM_GAP_MS);
131
+ }
132
+ }
133
+
134
+ // `wait <селектор>` — ждать появления элемента (кадр не снимаем). `wait <мс>` —
135
+ // глухая пауза. Best-effort: не дождались — не роняем прогон, следующий шаг
136
+ // снимется как есть, а автор увидит по кадру, что было рано.
137
+ async function doWait(page, step) {
138
+ const t = String(step.target ?? "").trim();
139
+ if (/^\d+$/.test(t)) {
140
+ await page.waitForTimeout(Number(t));
141
+ return;
142
+ }
143
+ await page.locator(t).first().waitFor({ state: "visible", timeout: 30000 }).catch(() => {});
144
+ }
145
+
82
146
  // Всегда ПЕРВОЕ совпадение. Playwright в строгом режиме падает, если селектор
83
147
  // нашёл больше одного элемента, а на живой странице так и бывает: кнопка
84
148
  // «Далее» есть и в туре, и в скрытом диалоге рядом. Съёмка рамки уже берёт