fix(ci): repair ui-preview recording navigation and mark failed shots

This commit is contained in:
t8y2
2026-10-09 12:41:08 +08:00
parent 94f8970efd
commit ae1804daba
4 changed files with 42 additions and 15 deletions
+4 -2
View File
@@ -24,8 +24,10 @@ carries the `ui-preview` label), the workflow does this:
- has it plan scenes: what to click, double-click, hover, type and capture;
- walks the plan in Chromium with `cursor.js` drawing the pointer, its trail and each
click. It always takes screenshots. When the plan interacts, it also records an mp4,
never a GIF. A failed step is re-planned from the page as it is (up to three times);
what still fails is listed honestly in the PR block.
never a GIF. A failed step is re-planned from the page as it is (up to three times),
after an Escape clears anything the failure left open (a menu, a dialog); what still
fails is listed honestly in the PR block, and shots taken after a failure in their
scene are marked ⚠️ both there and on the Pages player.
4. **publish.mjs publish** puts the files under `pr-<n>/<sha>/` on the `ui-previews` branch,
which GitHub Pages serves, and rewrites the block in the description: the video's poster
links to the player page (GitHub won't embed a video it didn't host), the screenshots are
+7 -3
View File
@@ -134,17 +134,21 @@ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&l
// the page the video plays on: GitHub shows no video it didn't host itself
function player(sha, m) {
const shots = m.scenes.flatMap((s) => s.shots.map((x) => ({ ...x, scene: s.title })));
const warned = m.errors?.length || shots.some((x) => x.warn);
return `<!doctype html><html lang="zh"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>#${pr} 界面预览 · ${short(sha)}</title>
<style>:root{color-scheme:light dark;--bg:#f6f6f7;--fg:#1d1d1f;--muted:#6e6e73;--card:#fff;--line:#e5e5ea}
@media (prefers-color-scheme:dark){:root{--bg:#111113;--fg:#f2f2f4;--muted:#9a9aa0;--card:#1c1c1f;--line:#2c2c30}}
body{margin:0;background:var(--bg);color:var(--fg);font:15px/1.6 -apple-system,"Segoe UI","PingFang SC","Noto Sans CJK SC",sans-serif}
main{max-width:1200px;margin:0 auto;padding:28px 16px 60px}h1{font-size:20px;margin:0 0 4px}.m{color:var(--muted);font-size:13px;margin:0 0 20px}
.warn{margin:0 0 20px;padding:10px 14px;border:1px solid #c0392b;border-radius:10px;background:rgba(192,57,43,.07);color:#c0392b;font-size:14px}
figure.shaky img{border-color:#c0392b}
video,img{display:block;width:100%;border-radius:10px;border:1px solid var(--line);background:var(--card)}figure{margin:0 0 28px}figcaption{color:var(--muted);font-size:13px;margin-top:8px}
a{color:inherit}</style>
<main><h1>#${pr} 界面预览</h1><p class="m">commit ${short(sha)} · <a href="https://github.com/${repo}/pull/${pr}">回到 PR</a>${m.summary ? " · " + esc(m.summary) : ""}</p>
${warned ? `<p class="warn">⚠️ 这次录制有 ${m.errors?.length || 0} 步没能按计划执行${shots.some((x) => x.warn) ? ";带 ⚠️ 的截图拍摄于步骤失败之后,画面未必是说明文字所描述的状态" : ""}。</p>` : ""}
${m.video ? `<figure><video src="${m.video}" controls autoplay muted playsinline poster="${m.poster || ""}"></video><figcaption>红线是鼠标走过的轨迹,红色圆环是一次点击;底部文字是这一步在做什么。空格暂停,← → 逐段查看。</figcaption></figure>` : ""}
${shots.map((x) => `<figure><img src="${esc(x.file)}" alt="${esc(x.caption)}"><figcaption>${esc(x.scene)} · ${esc(x.caption)}</figcaption></figure>`).join("\n")}
${shots.map((x) => `<figure${x.warn ? ' class="shaky"' : ""}><img src="${esc(x.file)}" alt="${esc(x.caption)}"><figcaption>${x.warn ? "⚠️ " : ""}${esc(x.scene)} · ${esc(x.caption)}</figcaption></figure>`).join("\n")}
</main></html>`;
}
@@ -176,6 +180,7 @@ async function publish() {
if (m.summary) md += `**改动**(按代码):${m.summary}\n\n`;
if (m.mismatch) md += `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n`;
if (m.unseen) md += `> [!NOTE]\n> **沙盒里看不到**:${m.unseen}\n\n`;
if (m.errors?.length) md += `<details><summary>⚠️ 有 ${m.errors.length} 步没能照计划执行</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>\n\n`;
if (m.video) {
md += `[![播放录屏](${raw}${m.poster})](${site})\n\n`;
md += `<sub>▶️ 点图打开录屏(可暂停、拖动)· [直接下载 mp4](${raw}${m.video}) · 红线是鼠标轨迹,红色圆环是点击</sub>\n\n`;
@@ -184,9 +189,8 @@ async function publish() {
if (!s.shots.length) continue;
md += `#### ${s.title}\n\n`;
// shown at its own size (they're taken at 2x), a full page no wider than 760
for (const x of s.shots) md += `<img src="${raw}${x.file}" width="${Math.min(760, Math.round((x.width || 1520) / 2))}" alt="${esc(x.caption)}">\n\n${x.caption}\n\n`;
for (const x of s.shots) md += `<img src="${raw}${x.file}" width="${Math.min(760, Math.round((x.width || 1520) / 2))}" alt="${esc(x.caption)}">\n\n${x.warn ? "⚠️ " : ""}${x.caption}\n\n`;
}
if (m.errors?.length) md += `<details><summary>⚠️ 有 ${m.errors.length} 步没能照计划执行</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>\n`;
await setBlock(md);
}
+30 -10
View File
@@ -225,7 +225,7 @@ async function codeContext(diff, src) {
const GUIDE = `You plan a short screen recording of dbx, a database GUI (shown here in a browser at ${VIEW.width}x${VIEW.height}). It runs for real on a sandbox machine: the web build of the app against a dbx-web backend with password protection off, and made-up data beside it (seed.mjs) so the pages that need data have some. That data: two saved SQLite connections, "商店 Shop (SQLite)" (tables customers, products, orders, audit_log, a view v_daily_revenue, foreign keys and a trigger) and "分析 Analytics (SQLite)" (events with 5000 rows — the grid pages — and daily_stats with 90 days); a small sample.csv (name,category,price,stock) for import dialogs; a saved-SQL folder "常用查询" with three files; and query history over the last three days across both connections, real executions mixed with seeded entries, a few failures among them. The UI language is ${LOCALE}. What it doesn't have: any server database (MySQL, PostgreSQL, Redis, MongoDB… none are connected — connection dialogs can be opened but not completed), no AI provider configured, no plugins or JDBC drivers installed, no SSH tunnels.
dbx is a single-page app with no URL routing: every recording starts from the page as it opens. The left sidebar lists the connections; opening one (double-click, or right-click → its menu) opens its tables and its tabs — the query editor, table data, structure, ER diagram; the toolbar above the editor runs the SQL; other panels (SQL 库, 查询历史, 全局搜索, 设置) open from the interface itself. Find your way from the outline you are given. Two ways of working that reach further than clicks:
dbx is a single-page app with no URL routing: every recording starts from the page as it opens. The left sidebar is a tree that opens one level at a time: a click on a connection lists its databases (SQLite shows one, "main 默认库"), a click on that database node lists its tables, and a click on a table opens its data grid — the query editor, table data, structure, ER diagram are that connection's tabs. A double-click on a connection instead opens a database-browser tab, so working down the tree is how a table is reached; the outline you are given shows the app at rest, connections collapsed, so a node the plan needs that isn't in it has to be expanded into view first, not looked for on other pages. The search box above the tree (placeholder 搜索...) is the shortcut: typing a table's name there reveals its node without the level-by-level clicking. The toolbar above the editor runs the SQL; other panels (SQL 库, 查询历史, 全局搜索, 设置) open from the interface itself. Two ways of working that reach further than clicks:
- The query editor runs any SQL against the sandbox's SQLite databases (they are throwaway — writes are fine): when a state needs data (a result with no columns, an empty table, a table of a shape), open a connection, type the SQL that produces it and run it, and show the state it makes.
- A file picker can't be clicked through, but a step can set it: { "do": "upload", "target": "input[type=file]", "value": "sample.csv" } — seed.mjs puts sample.csv (a small product list: name,category,price,stock) beside the databases, for import and mapping dialogs.
@@ -278,15 +278,11 @@ async function glide(page, x, y) {
async function locate(page, step) {
if (!step.target) throw new Error(`${step.do} needs a target`);
let loc = page.locator(step.target);
const all = page.locator(step.target);
// a wish for text tucked into the selector ([aria-label="设置"], [title="Run"]):
// take it out and treat it as the text filter, where the fallback can use it
const attrText = /\[(?:aria-label|title|placeholder|data-slot|data-view)[*^$]?=~?"([^"]+)"/.exec(step.target)?.[1];
if (!step.text && attrText) {
step = { ...step, text: attrText };
loc = loc.filter({ hasText: attrText });
}
if (step.text) loc = loc.filter({ hasText: step.text });
const text = step.text || attrText || "";
const visible = async (l) => {
const n = await l.count();
for (let i = 0; i < n; i++) {
@@ -295,7 +291,23 @@ async function locate(page, step) {
}
return null;
};
const hit = await visible(loc);
const countVisible = async (l) => {
const n = await l.count();
let c = 0;
for (let i = 0; i < n; i++) if (await l.nth(i).isVisible()) c++;
return c;
};
let loc = text ? all.filter({ hasText: text }) : all;
let hit = await visible(loc);
// hasText matches an element's content, and a form field's content is its
// value — empty until typed into — so input[placeholder="搜索..."] filters
// itself out. The bare selector was right all along: trust it when exactly
// one visible element answers to it.
if (!hit && text && (await countVisible(all)) === 1) {
loc = all;
step = { ...step, text: "" };
hit = await visible(loc);
}
if (hit) return hit;
// the selector guessed a role or structure this app doesn't use (a tab, a
// menuitem, a tree node…): fall back to the one visible thing carrying the
@@ -486,7 +498,9 @@ async function runStep(page, step, scene) {
} finally {
await page.evaluate(() => document.querySelector("[data-ui-preview]")?.style.removeProperty("visibility"));
}
scene.shots.push({ file, caption: step.caption || step.name || "", width: 2 * (clip?.width ?? VIEW.width) });
// a shot taken after a failed step in its scene shows the state the
// plan actually reached, not the one the caption claims
scene.shots.push({ file, caption: step.caption || step.name || "", width: 2 * (clip?.width ?? VIEW.width), warn: scene.derailed || undefined });
break;
}
default:
@@ -543,7 +557,7 @@ async function main() {
const began = Date.now();
let lead = 0, repairs = 0, interactive = false;
for (const s of plan.scenes.slice(0, 5)) {
const scene = { title: s.title || "", shots: [] };
const scene = { title: s.title || "", shots: [], derailed: false };
manifest.scenes.push(scene);
try {
await caption(page, scene.title);
@@ -561,6 +575,12 @@ async function main() {
if (manifest.leak) break;
manifest.errors.push(`${scene.title} · ${step.do} ${step.target || step.name || ""}: ${e.message.split("\n")[0]}`);
log("step failed:", e.message);
scene.derailed = true;
// whatever the failed step (or an earlier stray one) left open — a
// menu, a dropdown, a confirm dialog — swallows every click and
// shows up in the shots that follow: clear it before going on
await page.keyboard.press("Escape").catch(() => {});
await sleep(400);
if (repairs >= 3) continue;
repairs++;
// the page as it is now, and what went wrong: the rest of the scene again
+1
View File
@@ -153,6 +153,7 @@ jobs:
DIFF_FILE: ${{ github.workspace }}/pr-info/pr.diff
PR_BODY_FILE: ${{ github.workspace }}/pr-info/body.txt
DIST_DIR: ${{ github.workspace }}/pr/dist
SRC_DIR: ${{ github.workspace }}/pr
run: PR_TITLE="$(cat pr-info/title.txt)" ci/.github/ui-preview/run.sh out
- if: always()
uses: actions/upload-artifact@v4