get-handoff 1.0.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 +104 -0
- package/package.json +19 -0
- package/src/api.js +82 -0
- package/src/browser.js +49 -0
- package/src/clip.js +71 -0
- package/src/collect.js +57 -0
- package/src/cursor.js +74 -0
- package/src/git.js +57 -0
- package/src/payload.js +39 -0
- package/src/plan.js +83 -0
- package/src/version.js +1 -0
- package/src/walk.js +108 -0
package/bin/handoff.mjs
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename } from "node:path";
|
|
4
|
+
import { parsePlan } from "../src/plan.js";
|
|
5
|
+
import { openBrowser } from "../src/browser.js";
|
|
6
|
+
import { walkPlan } from "../src/walk.js";
|
|
7
|
+
import { buildPayload } from "../src/payload.js";
|
|
8
|
+
import { uploadFrames, uploadClips, createDraft, revokeWalkthrough } from "../src/api.js";
|
|
9
|
+
import { readGitContext } from "../src/git.js";
|
|
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> [опции]
|
|
16
|
+
handoff revoke <слаг|id> — закрыть доступ к уже разосланной ссылке
|
|
17
|
+
|
|
18
|
+
--attach <url> подключиться к запущенному браузеру (по умолчанию — свой профиль)
|
|
19
|
+
--headless не показывать окно
|
|
20
|
+
--out <файл> записать тело запроса в файл вместо создания черновика
|
|
21
|
+
--api <url> база edge-функций (или HANDOFF_API)
|
|
22
|
+
--key <ключ> agent key (или HANDOFF_KEY)
|
|
23
|
+
|
|
24
|
+
Сводку «что изменилось» CLI не пишет: её дописывает агент, глядя на свой диff
|
|
25
|
+
и на снятые кадры, — и только он может выполнить правило «пункт ссылается на шаг».`);
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const opt = (name, fallback = null) => {
|
|
30
|
+
const i = args.indexOf(`--${name}`);
|
|
31
|
+
return i >= 0 ? args[i + 1] : fallback;
|
|
32
|
+
};
|
|
33
|
+
const has = (name) => args.includes(`--${name}`);
|
|
34
|
+
|
|
35
|
+
const cfgEarly = {
|
|
36
|
+
apiBase: opt("api", process.env.HANDOFF_API ?? "http://127.0.0.1:54321/functions/v1"),
|
|
37
|
+
agentKey: opt("key", process.env.HANDOFF_KEY ?? ""),
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Отзыв — единственная кнопка «стоп» для ссылки, которая уже у людей в чатах.
|
|
41
|
+
if (cmd === "revoke") {
|
|
42
|
+
if (!cfgEarly.agentKey) {
|
|
43
|
+
console.error("Нужен agent key: --key или HANDOFF_KEY.");
|
|
44
|
+
process.exit(2);
|
|
45
|
+
}
|
|
46
|
+
const res = await revokeWalkthrough(args[1], cfgEarly);
|
|
47
|
+
console.log(res.alreadyRevoked
|
|
48
|
+
? `Ссылка ${res.slug} и так была отозвана.`
|
|
49
|
+
: `Ссылка ${res.slug} отозвана — страница больше не открывается.`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const plan = parsePlan(readFileSync(args[1], "utf8"));
|
|
54
|
+
// Файл плана — это и есть личность прогона: `help.md` остаётся `help.md`,
|
|
55
|
+
// как бы ни звучал заголовок внутри.
|
|
56
|
+
plan.planKey = basename(args[1]).replace(/\.md$/i, "");
|
|
57
|
+
const cfg = {
|
|
58
|
+
apiBase: opt("api", process.env.HANDOFF_API ?? "http://127.0.0.1:54321/functions/v1"),
|
|
59
|
+
agentKey: opt("key", process.env.HANDOFF_KEY ?? ""),
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
console.log(`Прогон «${plan.title}» — ${plan.steps.length} шагов на ${plan.origin}`);
|
|
63
|
+
const { page, close } = await openBrowser({ attach: opt("attach"), headless: has("headless") });
|
|
64
|
+
let shots;
|
|
65
|
+
try {
|
|
66
|
+
shots = await walkPlan(page, plan);
|
|
67
|
+
} finally {
|
|
68
|
+
await close();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const failed = shots.filter((s) => s.status === "failed");
|
|
72
|
+
for (const s of shots) console.log(` ${s.status === "ok" ? "снят" : "ПРОВАЛ"} ${s.title}${s.failure ? " — " + s.failure : ""}`);
|
|
73
|
+
|
|
74
|
+
const outFile = opt("out");
|
|
75
|
+
|
|
76
|
+
// С ключом кадры заливаются всегда — даже когда результат уходит в файл.
|
|
77
|
+
// Иначе агент не смог бы сделать то, ради чего труд и разделён: посмотреть на
|
|
78
|
+
// СНЯТЫЕ кадры, дописать summary и только потом создать черновик. Без ключа
|
|
79
|
+
// остаётся сухой режим: посмотреть, что сняли, не трогая облако.
|
|
80
|
+
// Сперва стоп-кадры, потом клипы: клип — украшение, и его заливка уже не может
|
|
81
|
+
// отнять у шага снятый кадр.
|
|
82
|
+
const framed = cfg.agentKey ? await uploadFrames(shots, cfg) : shots;
|
|
83
|
+
const uploaded = cfg.agentKey ? await uploadClips(framed, cfg) : framed;
|
|
84
|
+
const payload = buildPayload(plan, uploaded, readGitContext());
|
|
85
|
+
|
|
86
|
+
if (outFile) {
|
|
87
|
+
writeFileSync(outFile, JSON.stringify(payload, null, 2));
|
|
88
|
+
console.log(`\nТело запроса записано в ${outFile}${cfg.agentKey ? " (кадры залиты)" : " (кадры НЕ залиты — нет ключа)"}.`);
|
|
89
|
+
console.log("Допиши summary — каждый пункт со ссылкой на шаг — и создай черновик:");
|
|
90
|
+
console.log(` curl -X POST $HANDOFF_API/walkthrough-create -H "Authorization: Bearer <ключ>" \\
|
|
91
|
+
-H "content-type: application/json" --data @${outFile}`);
|
|
92
|
+
process.exit(failed.length ? 1 : 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!cfg.agentKey) {
|
|
96
|
+
console.error("\nНужен agent key: --key или HANDOFF_KEY.");
|
|
97
|
+
process.exit(2);
|
|
98
|
+
}
|
|
99
|
+
const draft = await createDraft(payload, cfg);
|
|
100
|
+
|
|
101
|
+
console.log(`\nЧерновик готов: ${draft.walkthroughId} (slug ${draft.slug})`);
|
|
102
|
+
console.log("Сводка пуста — допиши её и опубликуй через walkthrough-publish.");
|
|
103
|
+
if (failed.length) console.log(`Внимание: ${failed.length} шаг(ов) провалились — посмотри перед публикацией.`);
|
|
104
|
+
process.exit(failed.length ? 1 : 0);
|
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "get-handoff",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": { "handoff": "./bin/handoff.mjs", "get-handoff": "./bin/handoff.mjs" },
|
|
6
|
+
"files": ["bin/", "src/", "!src/**/*.test.js"],
|
|
7
|
+
"publishConfig": { "access": "public" },
|
|
8
|
+
"dependencies": {
|
|
9
|
+
"playwright-core": "^1.61.0"
|
|
10
|
+
},
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "vitest run",
|
|
13
|
+
"smoke:capture": "node e2e/capture-smoke.mjs"
|
|
14
|
+
},
|
|
15
|
+
"devDependencies": {
|
|
16
|
+
"jsdom": "^25.0.1",
|
|
17
|
+
"vitest": "^2.1.9"
|
|
18
|
+
}
|
|
19
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Тонкий клиент к edge-функциям части 1. fetch передаётся параметром —
|
|
2
|
+
// так его подменяют тесты, и так же он берётся из окружения в бою.
|
|
3
|
+
|
|
4
|
+
import { timeline } from "./clip.js";
|
|
5
|
+
|
|
6
|
+
export function pngDataUrl(bytes) {
|
|
7
|
+
return `data:image/png;base64,${Buffer.from(bytes).toString("base64")}`;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export async function uploadFrames(shots, cfg, fetchImpl = fetch) {
|
|
11
|
+
const out = [];
|
|
12
|
+
for (const shot of shots) {
|
|
13
|
+
if (!shot.png) { out.push({ ...shot, screenshotPath: null }); continue; }
|
|
14
|
+
const res = await fetchImpl(`${cfg.apiBase}/screenshot-upload`, {
|
|
15
|
+
method: "POST",
|
|
16
|
+
headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
|
|
17
|
+
body: JSON.stringify({ dataUrl: pngDataUrl(shot.png) }),
|
|
18
|
+
});
|
|
19
|
+
const body = await res.json().catch(() => ({}));
|
|
20
|
+
if (!res.ok) throw new Error(`не удалось залить кадр: ${body.error ?? res.status}`);
|
|
21
|
+
out.push({ ...shot, screenshotPath: body.path });
|
|
22
|
+
}
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function jpegDataUrl(base64) {
|
|
27
|
+
return `data:image/jpeg;base64,${base64}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Кадры клипа льются тем же screenshot-upload, что и стоп-кадры (он принимает
|
|
31
|
+
// JPEG). Клип — украшение: если кадр не залился, шаг остаётся со стоп-кадром,
|
|
32
|
+
// а прогон не падает.
|
|
33
|
+
export async function uploadClips(shots, cfg, fetchImpl = fetch) {
|
|
34
|
+
const out = [];
|
|
35
|
+
for (const shot of shots) {
|
|
36
|
+
const frames = shot.clipFrames ?? [];
|
|
37
|
+
if (frames.length === 0) { out.push({ ...shot, clipPaths: [] }); continue; }
|
|
38
|
+
const stamped = [];
|
|
39
|
+
let broken = false;
|
|
40
|
+
for (const frame of frames) {
|
|
41
|
+
const res = await fetchImpl(`${cfg.apiBase}/screenshot-upload`, {
|
|
42
|
+
method: "POST",
|
|
43
|
+
headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
|
|
44
|
+
body: JSON.stringify({ dataUrl: jpegDataUrl(frame.data) }),
|
|
45
|
+
}).catch(() => null);
|
|
46
|
+
const body = res ? await res.json().catch(() => ({})) : {};
|
|
47
|
+
if (!res || !res.ok || !body.path) { broken = true; break; }
|
|
48
|
+
// Время у кадра НАСТОЯЩЕЕ, снятое при записи: страница проигрывает клип
|
|
49
|
+
// по этим паузам, поэтому рваная съёмка не притворится равномерной.
|
|
50
|
+
stamped.push({ path: body.path, t: frame.t });
|
|
51
|
+
}
|
|
52
|
+
// У старых записей времени может не быть — тогда раскладываем по частоте.
|
|
53
|
+
const complete = stamped.every((f) => typeof f.t === "number");
|
|
54
|
+
out.push({ ...shot, clipPaths: broken ? [] : (complete ? stamped : timeline(stamped.map((f) => f.path))) });
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function createDraft(payload, cfg, fetchImpl = fetch) {
|
|
60
|
+
const res = await fetchImpl(`${cfg.apiBase}/walkthrough-create`, {
|
|
61
|
+
method: "POST",
|
|
62
|
+
headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
|
|
63
|
+
body: JSON.stringify(payload),
|
|
64
|
+
});
|
|
65
|
+
const body = await res.json().catch(() => ({}));
|
|
66
|
+
if (!res.ok) throw new Error(`не удалось создать черновик: ${body.error ?? res.status}`);
|
|
67
|
+
return body;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Отзыв ссылки: единственная кнопка «стоп» для прогона, который уже разослали.
|
|
71
|
+
// Принимает слаг из адреса или id черновика — что оказалось под рукой.
|
|
72
|
+
export async function revokeWalkthrough(target, cfg, fetchImpl = fetch) {
|
|
73
|
+
const body = /^wlk_/.test(target) ? { walkthroughId: target } : { slug: target };
|
|
74
|
+
const res = await fetchImpl(`${cfg.apiBase}/walkthrough-revoke`, {
|
|
75
|
+
method: "POST",
|
|
76
|
+
headers: { Authorization: `Bearer ${cfg.agentKey}`, "content-type": "application/json" },
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
const data = await res.json().catch(() => ({}));
|
|
80
|
+
if (!res.ok) throw new Error(`не удалось отозвать: ${data.error ?? res.status}`);
|
|
81
|
+
return data;
|
|
82
|
+
}
|
package/src/browser.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { chromium } from "playwright-core";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { collectElements } from "./collect.js";
|
|
5
|
+
|
|
6
|
+
// По умолчанию — ОТДЕЛЬНЫЙ постоянный профиль: агент не лезет в окно, где
|
|
7
|
+
// человек работает, а сессии (логины на стенде) живут между запусками.
|
|
8
|
+
// --attach подключается к уже запущенному браузеру с отладочным портом.
|
|
9
|
+
export const PROFILE_DIR = join(homedir(), ".handoff", "browser");
|
|
10
|
+
export const VIEWPORT = { width: 1280, height: 800 };
|
|
11
|
+
|
|
12
|
+
export async function openBrowser({ attach = null, headless = false } = {}) {
|
|
13
|
+
if (attach) {
|
|
14
|
+
const browser = await chromium.connectOverCDP(attach);
|
|
15
|
+
const ctx = browser.contexts()[0] ?? (await browser.newContext({ viewport: VIEWPORT }));
|
|
16
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
17
|
+
await page.setViewportSize(VIEWPORT);
|
|
18
|
+
return { page, close: () => browser.close() };
|
|
19
|
+
}
|
|
20
|
+
const ctx = await chromium.launchPersistentContext(PROFILE_DIR, {
|
|
21
|
+
channel: "chrome", // playwright-core не тащит браузер — берём системный Chrome
|
|
22
|
+
headless,
|
|
23
|
+
viewport: VIEWPORT,
|
|
24
|
+
deviceScaleFactor: 1, // rect и пиксели кадра должны совпадать один в один
|
|
25
|
+
});
|
|
26
|
+
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
27
|
+
return { page, close: () => ctx.close() };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Снимок шага: чистый кадр + карта геометрии + прямоугольник действия.
|
|
31
|
+
// Метки НЕ впекаются в картинку — их рисует страница поверх по этим координатам.
|
|
32
|
+
export async function captureStep(page, { actionSelector = null } = {}) {
|
|
33
|
+
const png = await page.screenshot();
|
|
34
|
+
// collectElements самодостаточна, поэтому уезжает в браузер как есть.
|
|
35
|
+
const elementMap = await page.evaluate(collectElements);
|
|
36
|
+
let actionRect = null;
|
|
37
|
+
if (actionSelector) {
|
|
38
|
+
const box = await page.locator(actionSelector).first().boundingBox().catch(() => null);
|
|
39
|
+
if (box) actionRect = { x: Math.round(box.x), y: Math.round(box.y), w: Math.round(box.width), h: Math.round(box.height) };
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
png,
|
|
43
|
+
elementMap,
|
|
44
|
+
actionRect,
|
|
45
|
+
url: page.url(),
|
|
46
|
+
viewport: `${VIEWPORT.width}x${VIEWPORT.height}`,
|
|
47
|
+
devicePixelRatio: 1,
|
|
48
|
+
};
|
|
49
|
+
}
|
package/src/clip.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// Клип вокруг действия — без кодеков: CDP отдаёт поток JPEG-кадров, они лежат
|
|
2
|
+
// в бакете рядом со стоп-кадрами, а страница проигрывает их флипбуком.
|
|
3
|
+
//
|
|
4
|
+
// Числа сознательно ВЧЕТВЕРО легче спековских (3 с / 12 fps / 40 кадров):
|
|
5
|
+
// движение «доехал и нажал» читается и за полторы секунды, а 40 кадров на шаг
|
|
6
|
+
// превращаются в десятки мегабайт на канале коллеги. Поднять обратно —
|
|
7
|
+
// поменять эти четыре цифры.
|
|
8
|
+
export const CLIP = {
|
|
9
|
+
FPS: 8,
|
|
10
|
+
MAX_FRAMES: 12,
|
|
11
|
+
MAX_MS: 1600,
|
|
12
|
+
QUALITY: 55,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// Потолок на всякий случай: основное прореживание делает выборка по времени,
|
|
16
|
+
// но если браузер прислал шквал кадров с одинаковым временем, лимит спасает.
|
|
17
|
+
export function capFrames(frames, max = CLIP.MAX_FRAMES) {
|
|
18
|
+
return frames.length <= max ? frames : frames.slice(0, max);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Решение «брать ли этот кадр»: между соседними кадрами должно пройти не меньше
|
|
22
|
+
// 1/FPS секунды, а весь клип не длиннее MAX_MS.
|
|
23
|
+
//
|
|
24
|
+
// 🔴 ОПЛАЧЕННЫЙ УРОК: сначала кадры ограничивались ТОЛЬКО их числом. Экран
|
|
25
|
+
// отдаёт до 60 кадров в секунду, поэтому лимит в 12 штук кончался за 0.2 с —
|
|
26
|
+
// клип обрывался, не доехав до цели, и нажатия в нём не было вовсе. Считать
|
|
27
|
+
// надо время, а не порядок прихода.
|
|
28
|
+
export function shouldKeep(elapsedMs, lastKeptMs, { fps = CLIP.FPS, maxMs = CLIP.MAX_MS } = {}) {
|
|
29
|
+
if (elapsedMs > maxMs) return false;
|
|
30
|
+
if (lastKeptMs === null) return true;
|
|
31
|
+
return elapsedMs - lastKeptMs >= 1000 / fps - 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function timeline(paths, fps = CLIP.FPS) {
|
|
35
|
+
return paths.map((path, i) => ({ path, t: Math.round((i / fps) * 1000) / 1000 }));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// cdp — сессия Playwright (page.context().newCDPSession(page)) или её двойник.
|
|
39
|
+
// Возвращает кадры как `{ data, t }`: `data` — base64 JPEG, `t` — секунды от
|
|
40
|
+
// начала записи. Время настоящее, а не «индекс делить на fps», поэтому рваная
|
|
41
|
+
// съёмка на странице проиграется такой же рваной, а не соврёт равномерностью.
|
|
42
|
+
export async function startClip(cdp, { viewport = { width: 1280, height: 800 }, now = () => Date.now() } = {}) {
|
|
43
|
+
const frames = [];
|
|
44
|
+
let started = null, lastKept = null;
|
|
45
|
+
cdp.on("Page.screencastFrame", (ev) => {
|
|
46
|
+
const at = now();
|
|
47
|
+
if (started === null) started = at;
|
|
48
|
+
const elapsed = at - started;
|
|
49
|
+
if (frames.length < CLIP.MAX_FRAMES && shouldKeep(elapsed, lastKept)) {
|
|
50
|
+
lastKept = elapsed;
|
|
51
|
+
frames.push({ data: ev.data, t: Math.round(elapsed) / 1000 });
|
|
52
|
+
}
|
|
53
|
+
// Подтверждение обязательно: без него браузер замолкает после первого кадра.
|
|
54
|
+
// Страница могла уже уйти на другой URL — сессия CDP отваливается, но это
|
|
55
|
+
// не должно ронять прогон, поэтому ошибку молча глотаем.
|
|
56
|
+
cdp.send("Page.screencastFrameAck", { sessionId: ev.sessionId }).catch(() => {});
|
|
57
|
+
});
|
|
58
|
+
await cdp.send("Page.startScreencast", {
|
|
59
|
+
format: "jpeg",
|
|
60
|
+
quality: CLIP.QUALITY,
|
|
61
|
+
maxWidth: viewport.width,
|
|
62
|
+
maxHeight: viewport.height,
|
|
63
|
+
everyNthFrame: 1,
|
|
64
|
+
});
|
|
65
|
+
return {
|
|
66
|
+
async stop() {
|
|
67
|
+
await cdp.send("Page.stopScreencast").catch(() => {});
|
|
68
|
+
return capFrames(frames);
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/collect.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Карта геометрии кадра: из неё клик коллеги по картинке превращается в
|
|
2
|
+
// настоящий CSS-селектор.
|
|
3
|
+
//
|
|
4
|
+
// ВАЖНО: функция обязана быть САМОДОСТАТОЧНОЙ — Playwright сериализует её
|
|
5
|
+
// исходник и выполняет в браузере, где нет ни импортов, ни констант модуля,
|
|
6
|
+
// ни соседних функций. Поэтому хелперы и пороги живут внутри, а document и
|
|
7
|
+
// window берутся глобальные (в jsdom-тестах они тоже глобальные).
|
|
8
|
+
|
|
9
|
+
export function collectElements() {
|
|
10
|
+
// Коллега тычет не только в кнопки: в значок, в бейдж, в кусок текста.
|
|
11
|
+
// Каждый непокрытый тег — это «элемент не распознан» вместо селектора,
|
|
12
|
+
// поэтому берём и визуальные атомы (span/svg/code), и всё, что размечено
|
|
13
|
+
// ролью или подписью. Обёртки (div/section) намеренно НЕ берём: правило
|
|
14
|
+
// «самый мелкий элемент под точкой» и так вернёт то, что внутри.
|
|
15
|
+
const INTERESTING = "a, button, input, select, textarea, h1, h2, h3, h4, p, img, li, label," +
|
|
16
|
+
" span, svg, code, [role], [aria-label]";
|
|
17
|
+
const MIN_SIDE = 8; // полоски тоньше этого кликать невозможно
|
|
18
|
+
const TEXT_MAX = 60;
|
|
19
|
+
|
|
20
|
+
// 🔴 ОПЛАЧЕННЫЙ УРОК: классы Tailwind сплошь содержат символы, которые в
|
|
21
|
+
// селекторе значат другое — `group/menu-item`, `hover:bg-x`, `w-1/2`. Без
|
|
22
|
+
// экранирования querySelectorAll БРОСАЕТ, карта кадра не собирается вообще,
|
|
23
|
+
// и шаг помечается провалом. Один такой класс убивал всю съёмку.
|
|
24
|
+
const esc = (s) => (typeof CSS !== "undefined" && CSS.escape
|
|
25
|
+
? CSS.escape(s)
|
|
26
|
+
: String(s).replace(/[^\w-]/g, (c) => "\\" + c));
|
|
27
|
+
|
|
28
|
+
const cssPath = (el) => {
|
|
29
|
+
const tag = el.tagName.toLowerCase();
|
|
30
|
+
if (el.id) return `#${esc(el.id)}`;
|
|
31
|
+
const cls = (el.getAttribute("class") || "")
|
|
32
|
+
.trim().split(/\s+/).filter(Boolean).slice(0, 2).map(esc);
|
|
33
|
+
const base = tag + (cls.length ? "." + cls.join(".") : "");
|
|
34
|
+
try {
|
|
35
|
+
const same = [...document.querySelectorAll(base)];
|
|
36
|
+
return same.length > 1 ? `${base}:nth-of-type(${same.indexOf(el) + 1})` : base;
|
|
37
|
+
} catch {
|
|
38
|
+
// Экранирование не спасло (бывает у совсем экзотической разметки) —
|
|
39
|
+
// отдаём хотя бы тег: неточный селектор лучше пустого кадра.
|
|
40
|
+
return tag;
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const el of document.querySelectorAll(INTERESTING)) {
|
|
46
|
+
const r = el.getBoundingClientRect();
|
|
47
|
+
if (r.width < MIN_SIDE || r.height < MIN_SIDE) continue;
|
|
48
|
+
if (r.bottom < 0 || r.top > window.innerHeight) continue; // вне кадра
|
|
49
|
+
out.push({
|
|
50
|
+
selector: cssPath(el),
|
|
51
|
+
rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
|
|
52
|
+
tag: el.tagName.toLowerCase(),
|
|
53
|
+
text: (el.textContent || "").trim().slice(0, TEXT_MAX),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
package/src/cursor.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Курсор-призрак: его видно в клипе, потому что он живёт на самой снимаемой
|
|
2
|
+
// странице, а не рисуется поверх картинки потом.
|
|
3
|
+
//
|
|
4
|
+
// ВАЖНО: каждая функция обязана быть САМОДОСТАТОЧНОЙ — Playwright сериализует
|
|
5
|
+
// её исходник и выполняет в браузере, где нет ни импортов, ни констант модуля.
|
|
6
|
+
// Поэтому идентификатор хоста продублирован строкой внутри каждой функции, а
|
|
7
|
+
// общее состояние хранится в самом DOM.
|
|
8
|
+
//
|
|
9
|
+
// Правила работы поверх чужой страницы (память overlay-on-hostile-page-rules):
|
|
10
|
+
// свой shadow root, pointer-events:none, анимация только transform/opacity.
|
|
11
|
+
export const GHOST_HOST_ID = "__ft_ghost_cursor__";
|
|
12
|
+
|
|
13
|
+
export function installGhost() {
|
|
14
|
+
const ID = "__ft_ghost_cursor__";
|
|
15
|
+
if (document.getElementById(ID)) return;
|
|
16
|
+
const host = document.createElement("div");
|
|
17
|
+
host.id = ID;
|
|
18
|
+
host.style.cssText = "position:fixed;left:0;top:0;width:0;height:0;z-index:2147483647;pointer-events:none";
|
|
19
|
+
const root = host.attachShadow({ mode: "open" });
|
|
20
|
+
root.innerHTML = `
|
|
21
|
+
<style>
|
|
22
|
+
.dot{position:fixed;left:0;top:0;width:22px;height:22px;margin:-11px 0 0 -11px;
|
|
23
|
+
border-radius:50%;background:rgba(15,27,51,.92);border:2px solid #fff;
|
|
24
|
+
box-shadow:0 2px 8px rgba(15,27,51,.4);opacity:0;
|
|
25
|
+
transition:transform 400ms cubic-bezier(.22,.61,.36,1), opacity 160ms linear}
|
|
26
|
+
.ring{position:fixed;left:0;top:0;width:22px;height:22px;margin:-11px 0 0 -11px;
|
|
27
|
+
border-radius:50%;border:2px solid #F5A524;opacity:0;
|
|
28
|
+
transition:transform 420ms ease-out, opacity 420ms ease-out}
|
|
29
|
+
</style>
|
|
30
|
+
<div class="ring"></div><div class="dot"></div>`;
|
|
31
|
+
document.documentElement.appendChild(host);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Аргументы приезжают ОДНИМ массивом: page.evaluate передаёт в браузер ровно
|
|
35
|
+
// один аргумент, и разворачивать его надо здесь, а не на вызывающей стороне.
|
|
36
|
+
export function moveGhost([x, y, ms]) {
|
|
37
|
+
const host = document.getElementById("__ft_ghost_cursor__");
|
|
38
|
+
if (!host) return;
|
|
39
|
+
for (const el of host.shadowRoot.querySelectorAll(".dot,.ring")) {
|
|
40
|
+
// Задаём переход целиком, а не только длительность: одна `transitionDuration`
|
|
41
|
+
// растянула бы на ту же длину и появление, и курсор половину клипа был бы
|
|
42
|
+
// полупрозрачным пятном. Появление быстрое, поездка — длинная.
|
|
43
|
+
el.style.transition = `transform ${ms}ms cubic-bezier(.22,.61,.36,1), opacity 120ms linear`;
|
|
44
|
+
el.style.transform = `translate3d(${x}px, ${y}px, 0)`;
|
|
45
|
+
}
|
|
46
|
+
host.shadowRoot.querySelector(".dot").style.opacity = "1";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function pulseGhost() {
|
|
50
|
+
const host = document.getElementById("__ft_ghost_cursor__");
|
|
51
|
+
if (!host) return;
|
|
52
|
+
const dot = host.shadowRoot.querySelector(".dot");
|
|
53
|
+
const ring = host.shadowRoot.querySelector(".ring");
|
|
54
|
+
// Кольцо расходится из той же точки, где стоит курсор: берём его же
|
|
55
|
+
// трансформ и добавляем масштаб.
|
|
56
|
+
const at = dot.style.transform;
|
|
57
|
+
ring.style.transitionDuration = "0ms";
|
|
58
|
+
ring.style.transform = at;
|
|
59
|
+
ring.style.opacity = "1";
|
|
60
|
+
// jsdom (тестовое окружение) не всегда даёт requestAnimationFrame —
|
|
61
|
+
// фолбэк на таймаут не меняет смысл: важно, что переход к следующему
|
|
62
|
+
// кадру случится ПОСЛЕ применения стартового состояния кольца.
|
|
63
|
+
const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame : (fn) => setTimeout(fn, 0);
|
|
64
|
+
raf(() => {
|
|
65
|
+
ring.style.transitionDuration = "420ms";
|
|
66
|
+
ring.style.transform = `${at} scale(2.6)`;
|
|
67
|
+
ring.style.opacity = "0";
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function removeGhost() {
|
|
72
|
+
const host = document.getElementById("__ft_ghost_cursor__");
|
|
73
|
+
if (host) host.remove();
|
|
74
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
// Шапка прогона говорит, на чём он снят. Чистая часть отделена от вызовов git,
|
|
4
|
+
// чтобы её можно было проверить без репозитория.
|
|
5
|
+
|
|
6
|
+
// Откуда считать «сколько файлов изменено». Первая ветка, которая существует,
|
|
7
|
+
// и становится точкой отсчёта.
|
|
8
|
+
export const BASE_CANDIDATES = ["origin/HEAD", "origin/master", "origin/main", "master", "main"];
|
|
9
|
+
|
|
10
|
+
// 🔴 ОПЛАЧЕННЫЙ УРОК: считали только НЕЗАКОММИЧЕННЫЕ правки (`diff HEAD`), и у
|
|
11
|
+
// человека, чья работа уже в коммитах, в шапке всегда стоял честный ноль —
|
|
12
|
+
// «0 файлов» читается как «ничего не менялось». Считать надо всю ветку от базы
|
|
13
|
+
// плюс то, что ещё не закоммичено.
|
|
14
|
+
export function countChangedFiles(...lists) {
|
|
15
|
+
const all = new Set();
|
|
16
|
+
for (const list of lists) {
|
|
17
|
+
for (const line of String(list ?? "").split("\n")) {
|
|
18
|
+
const f = line.trim();
|
|
19
|
+
if (f) all.add(f);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return all.size;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function parseGitContext({ branch, sha, baseSha, files, uncommitted }) {
|
|
26
|
+
return {
|
|
27
|
+
branch: String(branch).trim() || null,
|
|
28
|
+
headSha: String(sha).trim().slice(0, 7) || null,
|
|
29
|
+
baseSha: String(baseSha ?? "").trim().slice(0, 7) || null,
|
|
30
|
+
filesChanged: countChangedFiles(files, uncommitted),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readGitContext(cwd = process.cwd()) {
|
|
35
|
+
const run = (args) => {
|
|
36
|
+
try { return execFileSync("git", args, { cwd, encoding: "utf8" }); }
|
|
37
|
+
catch { return ""; }
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// База ветки: с какого коммита началась эта работа.
|
|
41
|
+
let baseSha = "";
|
|
42
|
+
for (const ref of BASE_CANDIDATES) {
|
|
43
|
+
const resolved = run(["rev-parse", "--abbrev-ref", ref]).trim() || ref;
|
|
44
|
+
const merged = run(["merge-base", "HEAD", resolved]).trim();
|
|
45
|
+
if (merged) { baseSha = merged; break; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return parseGitContext({
|
|
49
|
+
branch: run(["rev-parse", "--abbrev-ref", "HEAD"]),
|
|
50
|
+
sha: run(["rev-parse", "HEAD"]),
|
|
51
|
+
baseSha,
|
|
52
|
+
// Что сделано в ветке…
|
|
53
|
+
files: baseSha ? run(["diff", "--name-only", `${baseSha}..HEAD`]) : "",
|
|
54
|
+
// …и что ещё не закоммичено.
|
|
55
|
+
uncommitted: run(["diff", "--name-only", "HEAD"]),
|
|
56
|
+
});
|
|
57
|
+
}
|
package/src/payload.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Тело запроса к walkthrough-create. CLI отвечает за механику, поэтому summary
|
|
2
|
+
// остаётся ПУСТЫМ: его пишет агент, глядя на свой диff и на снятые кадры —
|
|
3
|
+
// и только он может выполнить правило «пункт обязан ссылаться на шаг».
|
|
4
|
+
|
|
5
|
+
export const ACTION_MAP = { open: "navigate", click: "click", type: "type", check: "assert", scroll: "scroll" };
|
|
6
|
+
|
|
7
|
+
export function buildPayload(plan, shots, git = {}) {
|
|
8
|
+
return {
|
|
9
|
+
origin: plan.origin,
|
|
10
|
+
title: plan.title,
|
|
11
|
+
// Личность прогона: имя файла плана. Заголовок можно переписывать сколько
|
|
12
|
+
// угодно — вытеснение предыдущей версии держится на этом.
|
|
13
|
+
planKey: plan.planKey ?? null,
|
|
14
|
+
branch: git.branch ?? null,
|
|
15
|
+
baseSha: git.baseSha ?? null,
|
|
16
|
+
headSha: git.headSha ?? null,
|
|
17
|
+
filesChanged: git.filesChanged ?? null,
|
|
18
|
+
summary: [],
|
|
19
|
+
steps: shots.map((s) => ({
|
|
20
|
+
url: s.url,
|
|
21
|
+
title: s.title,
|
|
22
|
+
note: s.note ?? null,
|
|
23
|
+
screenshotPath: s.screenshotPath ?? null,
|
|
24
|
+
action: {
|
|
25
|
+
type: ACTION_MAP[s.action] ?? "assert",
|
|
26
|
+
// У перехода цель — путь страницы, а не элемент: селектора у него нет.
|
|
27
|
+
selector: s.action === "open" ? undefined : (s.target ?? undefined),
|
|
28
|
+
label: s.label ?? undefined,
|
|
29
|
+
rect: s.actionRect ?? null,
|
|
30
|
+
},
|
|
31
|
+
elementMap: s.elementMap ?? [],
|
|
32
|
+
clipFrames: s.clipPaths ?? [],
|
|
33
|
+
viewport: s.viewport ?? null,
|
|
34
|
+
devicePixelRatio: s.devicePixelRatio ?? null,
|
|
35
|
+
status: s.status ?? "ok",
|
|
36
|
+
failure: s.failure ?? null,
|
|
37
|
+
})),
|
|
38
|
+
};
|
|
39
|
+
}
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// План прогона — markdown, который агент правит ТЕКСТОМ до дорогой съёмки.
|
|
2
|
+
// Формат намеренно скучный: фронтматтер + нумерованные шаги.
|
|
3
|
+
//
|
|
4
|
+
// ---
|
|
5
|
+
// title: Новый чекаут
|
|
6
|
+
// origin: http://localhost:3000
|
|
7
|
+
// ---
|
|
8
|
+
// 1. open /cart — Корзина
|
|
9
|
+
// 2. click button.promo-apply — Применить промокод
|
|
10
|
+
// 3. type input.promo "SALE20" — Ввести код
|
|
11
|
+
// 4. check .cart-total — Сумма пересчиталась
|
|
12
|
+
// 5. scroll .terms — Условия ниже сгиба
|
|
13
|
+
|
|
14
|
+
export const ACTIONS = ["open", "click", "type", "check", "scroll"];
|
|
15
|
+
|
|
16
|
+
// Действия, которым без цели делать нечего.
|
|
17
|
+
const NEEDS_TARGET = ["open", "click", "type", "check", "scroll"];
|
|
18
|
+
|
|
19
|
+
export function parsePlan(source) {
|
|
20
|
+
const lines = String(source).split(/\r?\n/);
|
|
21
|
+
const meta = {};
|
|
22
|
+
let i = 0;
|
|
23
|
+
|
|
24
|
+
if (lines[0]?.trim() === "---") {
|
|
25
|
+
i = 1;
|
|
26
|
+
for (; i < lines.length && lines[i].trim() !== "---"; i++) {
|
|
27
|
+
const m = lines[i].match(/^\s*([a-zA-Z_]+)\s*:\s*(.+?)\s*$/);
|
|
28
|
+
if (m) meta[m[1]] = m[2];
|
|
29
|
+
}
|
|
30
|
+
i++; // закрывающий ---
|
|
31
|
+
}
|
|
32
|
+
if (!meta.origin) throw new Error("В плане не указан origin (фронтматтер `origin:`)");
|
|
33
|
+
|
|
34
|
+
const steps = [];
|
|
35
|
+
for (; i < lines.length; i++) {
|
|
36
|
+
const raw = lines[i];
|
|
37
|
+
if (!raw.trim()) continue;
|
|
38
|
+
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 });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (steps.length === 0) throw new Error("В плане нет ни одного шага");
|
|
68
|
+
return { title: meta.title ?? "Прогон", origin: meta.origin, steps };
|
|
69
|
+
}
|
|
70
|
+
|
|
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()];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function findSeparator(line) {
|
|
79
|
+
const em = line.indexOf("—");
|
|
80
|
+
if (em >= 0) return em;
|
|
81
|
+
const hyphen = line.search(/\s-\s/);
|
|
82
|
+
return hyphen >= 0 ? hyphen + 1 : -1;
|
|
83
|
+
}
|
package/src/version.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const CLI_NAME = "handoff";
|
package/src/walk.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { captureStep } from "./browser.js";
|
|
2
|
+
import { installGhost, moveGhost, pulseGhost, removeGhost } from "./cursor.js";
|
|
3
|
+
import { startClip } from "./clip.js";
|
|
4
|
+
|
|
5
|
+
// Пауза после действия: даём странице доехать до нового состояния, иначе
|
|
6
|
+
// снимем кадр «между» — с полупримененной анимацией.
|
|
7
|
+
const SETTLE_MS = 400;
|
|
8
|
+
// Сколько курсор едет к цели. Меньше — движение не читается, больше — клип
|
|
9
|
+
// уходит в лимит кадров, ничего не показав.
|
|
10
|
+
const TRAVEL_MS = 400;
|
|
11
|
+
|
|
12
|
+
export async function walkPlan(page, plan, { capture = captureStep, settle = SETTLE_MS } = {}) {
|
|
13
|
+
const shots = [];
|
|
14
|
+
for (const step of plan.steps) {
|
|
15
|
+
let status = "ok", failure = null, clipFrames = [];
|
|
16
|
+
// Курсор едет только туда, куда человек и правда наводит. У перехода
|
|
17
|
+
// (`open`) цели на экране нет, а у прокрутки цель ещё ЗА экраном — курсор
|
|
18
|
+
// улетел бы в пустоту; движение там показывает сама поехавшая страница.
|
|
19
|
+
const withCursor = ["click", "type", "check"].includes(step.action) && Boolean(step.target);
|
|
20
|
+
// Пульс — это язык нажатия. На проверке (`check`) никто ничего не нажимает,
|
|
21
|
+
// и кольцо там соврало бы: курсор доезжает показать, куда смотреть, и всё.
|
|
22
|
+
const withPress = withCursor && step.action !== "check";
|
|
23
|
+
let clip = null;
|
|
24
|
+
|
|
25
|
+
// Клип и курсор — УКРАШЕНИЕ, и своим падением они не имеют права портить
|
|
26
|
+
// шаг: не завелась запись — шаг всё равно снимется стоп-кадром.
|
|
27
|
+
try {
|
|
28
|
+
if (withCursor) await page.evaluate(installGhost);
|
|
29
|
+
clip = await startClip(await page.context().newCDPSession(page));
|
|
30
|
+
if (withCursor) {
|
|
31
|
+
const box = await page.locator(step.target).first().boundingBox().catch(() => null);
|
|
32
|
+
if (box) {
|
|
33
|
+
await page.evaluate(moveGhost, [Math.round(box.x + box.width / 2), Math.round(box.y + box.height / 2), TRAVEL_MS]);
|
|
34
|
+
await page.waitForTimeout(TRAVEL_MS);
|
|
35
|
+
if (withPress) await page.evaluate(pulseGhost);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
clip = null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
await perform(page, plan, step);
|
|
44
|
+
await page.waitForTimeout(settle);
|
|
45
|
+
} catch (e) {
|
|
46
|
+
// Провал шага не роняет прогон: остальные снимутся, а автор увидит
|
|
47
|
+
// причину в черновике и решит сам.
|
|
48
|
+
status = "failed";
|
|
49
|
+
failure = e instanceof Error ? e.message : String(e);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Запись и курсор снимаются ВСЕГДА, даже если шаг провалился: иначе призрак
|
|
53
|
+
// останется в кадре следующего шага, а screencast продолжит жечь CPU.
|
|
54
|
+
// Уборка обёрнута целиком: на закрытой странице вызов может бросить сразу,
|
|
55
|
+
// до возврата промиса, и тогда .catch() бесполезен, а прогон падает из-за
|
|
56
|
+
// украшения — ровно того, что не имеет права ничего ронять.
|
|
57
|
+
try {
|
|
58
|
+
if (clip) clipFrames = await clip.stop();
|
|
59
|
+
await page.evaluate(removeGhost);
|
|
60
|
+
} catch {
|
|
61
|
+
// Уже снятые кадры оставляем: если упало на снятии курсора, клип цел.
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const shot = status === "ok"
|
|
65
|
+
? await capture(page, { actionSelector: step.action === "open" ? null : step.target })
|
|
66
|
+
: { png: null, elementMap: [], actionRect: null, url: page.url(), viewport: null, devicePixelRatio: null };
|
|
67
|
+
|
|
68
|
+
shots.push({
|
|
69
|
+
...shot,
|
|
70
|
+
clipFrames,
|
|
71
|
+
title: step.title,
|
|
72
|
+
action: step.action,
|
|
73
|
+
target: step.target,
|
|
74
|
+
label: step.text ?? step.title,
|
|
75
|
+
status,
|
|
76
|
+
failure,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
return shots;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Всегда ПЕРВОЕ совпадение. Playwright в строгом режиме падает, если селектор
|
|
83
|
+
// нашёл больше одного элемента, а на живой странице так и бывает: кнопка
|
|
84
|
+
// «Далее» есть и в туре, и в скрытом диалоге рядом. Съёмка рамки уже берёт
|
|
85
|
+
// first() — здесь то же самое, иначе шаг падал бы на многозначности.
|
|
86
|
+
async function perform(page, plan, step) {
|
|
87
|
+
switch (step.action) {
|
|
88
|
+
case "open":
|
|
89
|
+
return page.goto(absolute(plan.origin, step.target), { waitUntil: "networkidle" });
|
|
90
|
+
case "click":
|
|
91
|
+
return page.locator(step.target).first().click();
|
|
92
|
+
case "type":
|
|
93
|
+
return page.locator(step.target).first().fill(step.text ?? "");
|
|
94
|
+
case "check":
|
|
95
|
+
return page.locator(step.target).first().waitFor({ state: "visible" });
|
|
96
|
+
// Кадр — это вьюпорт, а не вся страница: содержимое ниже сгиба в него не
|
|
97
|
+
// попадает. Прокрутка к элементу — единственный способ показать длинный
|
|
98
|
+
// справочник или форму целиком, не разрезая их на куски.
|
|
99
|
+
case "scroll":
|
|
100
|
+
return page.locator(step.target).first().scrollIntoViewIfNeeded();
|
|
101
|
+
default:
|
|
102
|
+
throw new Error(`неизвестное действие «${step.action}»`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function absolute(origin, target) {
|
|
107
|
+
return /^https?:\/\//.test(target) ? target : origin.replace(/\/$/, "") + target;
|
|
108
|
+
}
|