mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-20 00:17:44 +00:00
feat(ci): add pull request UI preview pipeline
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# UI preview
|
||||
|
||||
`.github/workflows/ui-preview.yml` shows reviewers what a pull request does to the UI, the way
|
||||
[yetone/magpie](https://github.com/yetone/magpie) does for its own — this is a port of that
|
||||
pipeline, adapted to dbx's Tauri/frontend split. When a PR touches `apps/desktop/src/` (or
|
||||
carries the `ui-preview` label), the workflow does this:
|
||||
|
||||
1. **publish.mjs detect** decides whether the PR is a UI change, and which lane it is:
|
||||
a diff that reaches `crates/`, `plugins/` or `src-tauri/` is a *rust* lane, anything else
|
||||
is a *web* lane. It puts a "recording…" note in a fixed block at the foot of the PR
|
||||
description, between `<!-- dbx-ui-preview:begin -->` and `<!-- dbx-ui-preview:end -->`.
|
||||
2. **The record job** builds the PR's frontend (`pnpm build`) and runs it against a backend:
|
||||
the released dbx-web static binary (web lane — no Rust is built; the PR's `dist/` replaces
|
||||
the embedded frontend via `DBX_STATIC_DIR`), or dbx-web built from the PR (rust lane).
|
||||
**run.sh** starts it headless (`DBX_DISABLE_PASSWORD=1`, an isolated home), then
|
||||
**seed.mjs** fills it with made-up data, so a change that only shows with data can be seen:
|
||||
- two SQLite databases with fixed, deterministic content (orders/products/customers with
|
||||
a view, a trigger and foreign keys; an events table that pages), saved as two
|
||||
connections and really connected and queried through the app;
|
||||
- a saved-SQL folder with three files, and three days of query history (real executions
|
||||
plus seeded entries, a few failures among them).
|
||||
3. **record.mjs**, which:
|
||||
- gives DeepSeek the diff, an outline of the running app, and the changed frontend files;
|
||||
- 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.
|
||||
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
|
||||
shown inline, and the plan's summary / mismatch (what the description claims but the diff
|
||||
doesn't do) / unseen (what this sandbox can't show) notes go on top.
|
||||
|
||||
Security: the recording job runs the PR's code without asking anyone — its frontend build
|
||||
scripts, and in the rust lane the PR's backend itself. Any PR's code can therefore read
|
||||
`DEEPSEEK_API_KEY` (environment `ui-preview`), so the key there should have a low spending
|
||||
limit. That job has no write access, and the job that writes never runs the PR's code.
|
||||
|
||||
Known limits: the recording is of the web build, so desktop-shell behaviour (detached tabs,
|
||||
OS dialogs) is not covered; in the web lane the backend is the released dbx-web, so a PR
|
||||
whose frontend calls an endpoint newer than the release records that call failing — the
|
||||
honest-notes fields are the place that shows up.
|
||||
|
||||
To run it locally against a dbx-web that already runs somewhere (all of record.mjs, none of
|
||||
the starting):
|
||||
|
||||
```sh
|
||||
cd .github/ui-preview && npm install && npx playwright install chromium
|
||||
DBX_URL=http://127.0.0.1:4280 DEEPSEEK_API_KEY=… DIFF_FILE=pr.diff PR_TITLE=… \
|
||||
OUT_DIR=/tmp/out SRC_DIR=../.. node record.mjs # a plan from DeepSeek
|
||||
PLAN_FILE=plan.json DBX_URL=… … node record.mjs # or a hand-written plan
|
||||
```
|
||||
|
||||
`seed.mjs dbs <dir>` writes the databases; `seed.mjs api <url> <dir> [--as <server path>]`
|
||||
seeds a running instance (pass `--as` when the databases live where dbx-web runs, not where
|
||||
you run the script — testing over an SSH tunnel).
|
||||
|
||||
To re-run it for a PR, use `gh workflow run ui-preview.yml -f pr=<n>`.
|
||||
@@ -0,0 +1,107 @@
|
||||
// Drawn into every page the recorder opens, so the video shows where the
|
||||
// mouse is, the path it took and each click: an arrow on top of everything,
|
||||
// its trail fading behind it, a ring where a button went down. The recorder
|
||||
// sets captions with window.__uiPreviewCaption(text). None of it takes
|
||||
// pointer events, so the app under it behaves as it would.
|
||||
(() => {
|
||||
if (window.__uiPreviewCursor) return;
|
||||
window.__uiPreviewCursor = true;
|
||||
const TRAIL_MS = 1400;
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
let root, canvas, ctx, arrow, caption;
|
||||
const points = []; // {x, y, t}
|
||||
const rings = []; // {x, y, t}
|
||||
let x = -100, y = -100, down = false;
|
||||
|
||||
function mount() {
|
||||
if (root?.isConnected) return;
|
||||
root = document.createElement("div");
|
||||
root.setAttribute("data-ui-preview", "");
|
||||
root.style.cssText = "position:fixed;inset:0;pointer-events:none;z-index:2147483647;contain:strict;";
|
||||
canvas = document.createElement("canvas");
|
||||
canvas.style.cssText = "position:absolute;inset:0;width:100%;height:100%;";
|
||||
ctx = canvas.getContext("2d");
|
||||
arrow = document.createElementNS(ns, "svg");
|
||||
arrow.setAttribute("width", "26");
|
||||
arrow.setAttribute("height", "26");
|
||||
arrow.setAttribute("viewBox", "0 0 26 26");
|
||||
arrow.style.cssText = "position:absolute;left:0;top:0;transform-origin:3px 3px;filter:drop-shadow(0 1px 2px rgba(0,0,0,.35));transition:scale .08s;";
|
||||
arrow.innerHTML = '<path d="M3 2.5v18.2l4.6-4.4 3.1 7.2 3.3-1.4-3.1-7.1h6.4z" fill="#111" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/>';
|
||||
caption = document.createElement("div");
|
||||
caption.style.cssText = "position:absolute;left:50%;bottom:18px;translate:-50% 0;max-width:80%;padding:7px 14px;border-radius:9px;background:rgba(17,17,17,.82);color:#fff;font:500 14px/1.4 -apple-system,'Segoe UI','Noto Sans CJK SC','PingFang SC',sans-serif;opacity:0;transition:opacity .2s;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;";
|
||||
root.append(canvas, arrow, caption);
|
||||
document.documentElement.append(root);
|
||||
size();
|
||||
}
|
||||
function size() {
|
||||
const r = devicePixelRatio || 1;
|
||||
canvas.width = innerWidth * r;
|
||||
canvas.height = innerHeight * r;
|
||||
ctx.setTransform(r, 0, 0, r, 0, 0);
|
||||
}
|
||||
|
||||
function frame() {
|
||||
mount();
|
||||
const now = performance.now();
|
||||
while (points.length && now - points[0].t > TRAIL_MS) points.shift();
|
||||
while (rings.length && now - rings[0].t > 900) rings.shift();
|
||||
ctx.clearRect(0, 0, innerWidth, innerHeight);
|
||||
// the path: each segment as faint as it is old
|
||||
ctx.lineCap = ctx.lineJoin = "round";
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const a = points[i - 1], b = points[i];
|
||||
const age = (now - b.t) / TRAIL_MS;
|
||||
ctx.strokeStyle = `rgba(255, 59, 48, ${0.75 * (1 - age)})`;
|
||||
ctx.lineWidth = 3.5 * (1 - age) + 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x, b.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
// a click: a ring that opens and fades, a dot where it landed
|
||||
for (const r of rings) {
|
||||
const k = (now - r.t) / 900;
|
||||
ctx.strokeStyle = `rgba(255, 59, 48, ${1 - k})`;
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, 8 + 26 * Math.sqrt(k), 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = `rgba(255, 59, 48, ${0.9 * (1 - k)})`;
|
||||
ctx.beginPath();
|
||||
ctx.arc(r.x, r.y, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
if (down) {
|
||||
ctx.fillStyle = "rgba(255, 59, 48, .35)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, y, 14, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
arrow.style.translate = `${x - 3}px ${y - 3}px`;
|
||||
arrow.style.scale = down ? "0.85" : "1";
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
const opts = { capture: true, passive: true };
|
||||
addEventListener("mousemove", (e) => {
|
||||
x = e.clientX; y = e.clientY;
|
||||
points.push({ x, y, t: performance.now() });
|
||||
}, opts);
|
||||
addEventListener("mousedown", (e) => {
|
||||
x = e.clientX; y = e.clientY; down = true;
|
||||
rings.push({ x, y, t: performance.now() });
|
||||
}, opts);
|
||||
addEventListener("mouseup", () => { down = false; }, opts);
|
||||
addEventListener("resize", () => canvas && size());
|
||||
|
||||
window.__uiPreviewCaption = (text) => {
|
||||
mount();
|
||||
caption.textContent = text || "";
|
||||
caption.style.opacity = text ? "1" : "0";
|
||||
};
|
||||
// the recorder's last known spot, carried across a page load
|
||||
window.__uiPreviewAt = (px, py) => { x = px; y = py; };
|
||||
|
||||
if (document.readyState === "loading") addEventListener("DOMContentLoaded", () => requestAnimationFrame(frame));
|
||||
else requestAnimationFrame(frame);
|
||||
})();
|
||||
Generated
+57
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"name": "dbx-ui-preview",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "dbx-ui-preview",
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.49.1.tgz",
|
||||
"integrity": "sha512-VYL8zLoNTBxVOrJBbDuRgDWa3i+mfQgDTrL8Ah9QXZ7ax4Dsj0MSq5bYgytRnDVVe+njoKnfsYkH3HzqVj5UZA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.49.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.49.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.49.1.tgz",
|
||||
"integrity": "sha512-BzmpVcs4kE2CH15rWfzpjzVGhWERJfmnXmniSyKeRZUs9Ws65m+RGIi7mjJK/euCegfn3i7jvqWeWyHe9y3Vgg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"name": "dbx-ui-preview",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"seed": "node seed.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"playwright": "1.49.1"
|
||||
}
|
||||
}
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
// The trusted half of the UI preview: never runs the PR's code.
|
||||
//
|
||||
// node publish.mjs detect — does the PR touch the UI? (GITHUB_OUTPUT: ui,
|
||||
// lane, sha, number) and, when it does, puts
|
||||
// the "recording" note in the PR's description
|
||||
// node publish.mjs publish — the recording (OUT_DIR, from record.mjs) onto
|
||||
// the ui-previews branch, served by Pages, and
|
||||
// the preview into the PR's description
|
||||
//
|
||||
// The preview lives in one fixed block at the foot of the description,
|
||||
// between the markers below; each run replaces that block and leaves the
|
||||
// rest of the description as the author wrote it.
|
||||
//
|
||||
// lane: "rust" when the diff reaches the backend (crates/, plugins/,
|
||||
// src-tauri/) — then CI builds dbx-web from the PR; anything else builds
|
||||
// only the frontend and runs it against the released dbx-web.
|
||||
//
|
||||
// env: GITHUB_TOKEN, GITHUB_REPOSITORY, PR, OUT_DIR, RECORD_RESULT, RUN_URL
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
|
||||
const BEGIN = "<!-- dbx-ui-preview:begin -->";
|
||||
const END = "<!-- dbx-ui-preview:end -->";
|
||||
const BRANCH = "ui-previews";
|
||||
const repo = process.env.GITHUB_REPOSITORY;
|
||||
const pr = Number(process.env.PR);
|
||||
const token = process.env.GITHUB_TOKEN;
|
||||
const runURL = process.env.RUN_URL || "";
|
||||
// what counts as the UI: the app's own sources, not their tests
|
||||
const NOT_TEST = (f) => !/(__tests__|\.spec\.|\.test\.|\.bench\.)/.test(f);
|
||||
const UI = (f) => /^apps\/desktop\/src\//.test(f) && NOT_TEST(f);
|
||||
const RUST = (f) => /^(crates|plugins|src-tauri)\//.test(f);
|
||||
|
||||
async function gh(p, init = {}) {
|
||||
const res = await fetch(`https://api.github.com${p}`, {
|
||||
...init,
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28", ...init.headers },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${(await res.text()).slice(0, 300)}`);
|
||||
return res.status === 204 ? null : res.json();
|
||||
}
|
||||
|
||||
async function files() {
|
||||
const out = [];
|
||||
for (let page = 1; page <= 30; page++) {
|
||||
const batch = await gh(`/repos/${repo}/pulls/${pr}/files?per_page=100&page=${page}`);
|
||||
out.push(...batch.map((f) => f.filename));
|
||||
if (batch.length < 100) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// the description with the block in place of the old one, or at its foot
|
||||
function withBlock(body, block) {
|
||||
body = body || "";
|
||||
const a = body.indexOf(BEGIN), b = body.indexOf(END);
|
||||
if (a >= 0 && b > a) return body.slice(0, a) + block + body.slice(b + END.length);
|
||||
return body.replace(/\s*$/, "") + "\n\n" + block;
|
||||
}
|
||||
async function setBlock(inner) {
|
||||
// read just before writing, so an edit the author just made is kept
|
||||
const cur = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const next = withBlock(cur.body, `${BEGIN}\n${inner.trim()}\n${END}`);
|
||||
if (next !== cur.body) await gh(`/repos/${repo}/pulls/${pr}`, { method: "PATCH", body: JSON.stringify({ body: next }) });
|
||||
}
|
||||
|
||||
const short = (sha) => sha.slice(0, 7);
|
||||
const head = (sha) => `---\n### 🎬 界面预览\n<sub>CI 用这个 PR 的前端构建并真实运行 dbx(沙盒环境,dbx-web 后端 + 本地 SQLite 种子数据),按改动自动操作、截图和录屏 · <code>${short(sha)}</code>${runURL ? ` · [运行记录](${runURL})` : ""}</sub>\n`;
|
||||
|
||||
async function detect() {
|
||||
const p = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const changed = await files();
|
||||
const forced = p.labels.some((l) => l.name === "ui-preview");
|
||||
const ui = forced || changed.some(UI);
|
||||
const lane = changed.some(RUST) ? "rust" : "web";
|
||||
const out = process.env.GITHUB_OUTPUT;
|
||||
const lines = [`ui=${ui}`, `lane=${lane}`, `sha=${p.head.sha}`, `number=${pr}`];
|
||||
if (out) await fs.appendFile(out, lines.join("\n") + "\n");
|
||||
console.log(lines.join(" "), "·", changed.filter(UI).join(", ") || "no UI files");
|
||||
if (ui && p.state === "open") await setBlock(`${head(p.head.sha)}\n⏳ 正在录制这次提交的界面改动…`);
|
||||
}
|
||||
|
||||
function git(args, cwd) {
|
||||
return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim();
|
||||
}
|
||||
|
||||
// the recording onto the ui-previews branch: one commit, replaced each time
|
||||
// so the branch never piles up old videos; another PR's run pushing in
|
||||
// between makes this one start over from theirs
|
||||
async function pushMedia(sha, dir) {
|
||||
const work = await fs.mkdtemp(path.join(os.tmpdir(), "ui-previews-"));
|
||||
const remote = `https://x-access-token:${token}@github.com/${repo}.git`;
|
||||
git(["init", "-q"], work);
|
||||
git(["config", "user.name", "github-actions[bot]"], work);
|
||||
git(["config", "user.email", "41898282+github-actions[bot]@users.noreply.github.com"], work);
|
||||
const open = new Set((await gh(`/repos/${repo}/pulls?state=open&per_page=100`)).map((p) => p.number));
|
||||
for (let attempt = 0; attempt < 6; attempt++) {
|
||||
let base = "";
|
||||
try {
|
||||
git(["fetch", "-q", "--depth=1", remote, BRANCH], work);
|
||||
base = git(["rev-parse", "FETCH_HEAD"], work);
|
||||
git(["checkout", "-q", "-f", "--detach", "FETCH_HEAD"], work);
|
||||
} catch { /* the first one */ }
|
||||
// this PR's latest run only, and no PR that's been closed
|
||||
for (const d of await fs.readdir(work).catch(() => [])) {
|
||||
const m = /^pr-(\d+)$/.exec(d);
|
||||
if (m && (Number(m[1]) === pr || !open.has(Number(m[1])))) await fs.rm(path.join(work, d), { recursive: true, force: true });
|
||||
}
|
||||
const to = path.join(work, `pr-${pr}`, short(sha));
|
||||
await fs.mkdir(to, { recursive: true });
|
||||
for (const f of await fs.readdir(dir)) await fs.copyFile(path.join(dir, f), path.join(to, f));
|
||||
await fs.writeFile(path.join(to, "index.html"), player(sha, JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8"))));
|
||||
await fs.writeFile(path.join(work, ".nojekyll"), "");
|
||||
await fs.writeFile(path.join(work, "index.html"), `<!doctype html><meta charset="utf-8"><title>dbx UI previews</title><p>Screenshots and recordings of <a href="https://github.com/${repo}/pulls">${repo}</a> pull requests, made by .github/workflows/ui-preview.yml.</p>`);
|
||||
git(["checkout", "-q", "--orphan", `tmp-${attempt}`], work);
|
||||
git(["add", "-A"], work);
|
||||
git(["commit", "-q", "-m", `ui preview for #${pr} at ${short(sha)}`], work);
|
||||
try {
|
||||
git(["push", "-q", `--force-with-lease=${BRANCH}:${base}`, remote, `HEAD:refs/heads/${BRANCH}`], work);
|
||||
await fs.rm(work, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (e) {
|
||||
console.log(`push lost a race (${e.message.split("\n")[0]}), again`);
|
||||
await new Promise((r) => setTimeout(r, 2000 + Math.random() * 4000));
|
||||
}
|
||||
}
|
||||
throw new Error("couldn't push the preview");
|
||||
}
|
||||
|
||||
const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||
|
||||
// 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 })));
|
||||
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}
|
||||
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>
|
||||
${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")}
|
||||
</main></html>`;
|
||||
}
|
||||
|
||||
async function pagesURL() {
|
||||
try { return (await gh(`/repos/${repo}/pages`)).html_url.replace(/\/?$/, "/"); }
|
||||
catch { const [o, n] = repo.split("/"); return `https://${o.toLowerCase()}.github.io/${n}/`; }
|
||||
}
|
||||
|
||||
async function publish() {
|
||||
const p = await gh(`/repos/${repo}/pulls/${pr}`);
|
||||
const sha = process.env.SHA || p.head.sha;
|
||||
const dir = process.env.OUT_DIR;
|
||||
const result = process.env.RECORD_RESULT || "success";
|
||||
let m = null;
|
||||
try { m = JSON.parse(await fs.readFile(path.join(dir, "manifest.json"), "utf8")); } catch {}
|
||||
if (result === "cancelled" && !m) return; // a newer commit took over
|
||||
if (!m || m.leak || (!m.scenes.some((s) => s.shots.length) && !m.video)) {
|
||||
const why = m?.leak ? "录制时屏幕上出现了密钥,已全部丢弃,没有发布任何截图"
|
||||
: m?.skipped === "no visible UI change found in the diff" ? "看了改动,没有找到用户能看到的界面变化,所以没有录制"
|
||||
: `这次没录成(${result})`;
|
||||
const errs = m?.errors?.length ? `\n\n<details><summary>细节</summary>\n\n${m.errors.map((e) => "- " + e).join("\n")}\n</details>` : "";
|
||||
await setBlock(`${head(sha)}\n${m?.summary ? `**改动**(按代码):${m.summary}\n\n` : ""}${m?.mismatch ? `> [!WARNING]\n> **描述与代码不符**:${m.mismatch}\n\n` : ""}${why}。${runURL ? `详见[运行记录](${runURL})。` : ""}${errs}`);
|
||||
return;
|
||||
}
|
||||
await pushMedia(sha, dir);
|
||||
const raw = `https://raw.githubusercontent.com/${repo}/${BRANCH}/pr-${pr}/${short(sha)}/`;
|
||||
const site = `${await pagesURL()}pr-${pr}/${short(sha)}/`;
|
||||
let md = `${head(sha)}\n`;
|
||||
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.video) {
|
||||
md += `[](${site})\n\n`;
|
||||
md += `<sub>▶️ 点图打开录屏(可暂停、拖动)· [直接下载 mp4](${raw}${m.video}) · 红线是鼠标轨迹,红色圆环是点击</sub>\n\n`;
|
||||
}
|
||||
for (const s of m.scenes) {
|
||||
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`;
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
const cmd = process.argv[2];
|
||||
(cmd === "detect" ? detect() : cmd === "publish" ? publish() : Promise.reject(new Error("detect | publish")))
|
||||
.catch((e) => { console.error(e); process.exit(1); });
|
||||
Executable
+595
@@ -0,0 +1,595 @@
|
||||
// Records a PR's UI changes in the real app: dbx-web is already running
|
||||
// (run.sh), this reads the PR's diff, has DeepSeek plan what to show from it
|
||||
// and the page as it really is, then walks the plan in Chromium with the
|
||||
// mouse drawn in (cursor.js) — screenshots always, a video when the change
|
||||
// needs clicking to see. Everything lands in OUT_DIR with a manifest.json
|
||||
// that publish.mjs turns into the PR's preview.
|
||||
//
|
||||
// env: DBX_URL (the app's link), DIFF_FILE, PR_TITLE, PR_BODY_FILE, OUT_DIR,
|
||||
// SRC_DIR (the PR's source, for code context), DEEPSEEK_API_KEY, SECRETS
|
||||
// (words that must never be on screen, one per line), UI_LOCALE (zh-CN),
|
||||
// PLAN_MODEL, PLAN_FILE (a plan to walk instead of asking for one).
|
||||
import { chromium } from "playwright";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const env = (k, d) => process.env[k] ?? d;
|
||||
const OUT = path.resolve(process.env.OUT_DIR || "ui-preview-out");
|
||||
// a leak empties OUT, so it must be a folder of its own: not the working
|
||||
// folder, the home, the root or one holding them
|
||||
for (const keep of [process.cwd(), os.homedir(), path.parse(OUT).root]) {
|
||||
const rel = path.relative(OUT, keep);
|
||||
if (rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel))) {
|
||||
console.error(`record: OUT_DIR ${OUT} holds ${keep}; give it a folder of its own`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
const BASE = env("DBX_URL");
|
||||
const LOCALE = env("UI_LOCALE", "zh-CN");
|
||||
const MODEL = env("PLAN_MODEL", "deepseek-flash");
|
||||
const VIEW = { width: 1440, height: 900 };
|
||||
// never pressed: they delete the sandbox's own data or its setup
|
||||
const FORBIDDEN = /删除|清空|移除|卸载|重置|格式化|delete|remove|clear all|drop\b|truncate\b/i;
|
||||
const secrets = env("SECRETS", "").split("\n").map((s) => s.trim()).filter((s) => s.length >= 8);
|
||||
|
||||
const log = (...a) => console.log("[ui-preview]", ...a);
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const manifest = { summary: "", scenes: [], video: null, poster: null, errors: [], leak: false, skipped: null };
|
||||
|
||||
// The page as a compact outline the planner can pick real selectors from:
|
||||
// visible elements, their tag, id, classes, a few data- attributes and
|
||||
// their own text, indented; svg insides and invisible parts left out.
|
||||
function outline() {
|
||||
const lines = [];
|
||||
const MAX = 420;
|
||||
const own = (e) => [...e.childNodes].filter((n) => n.nodeType === 3).map((n) => n.textContent.trim()).join(" ").replace(/\s+/g, " ").slice(0, 70);
|
||||
const shown = (e) => {
|
||||
const r = e.getBoundingClientRect();
|
||||
if (!r.width || !r.height) return false;
|
||||
const s = getComputedStyle(e);
|
||||
return s.visibility !== "hidden" && s.display !== "none" && +s.opacity !== 0;
|
||||
};
|
||||
// fold: where the view it is in ends on screen
|
||||
const walk = (e, depth, fold) => {
|
||||
if (lines.length >= MAX || e.hasAttribute?.("data-ui-preview")) return;
|
||||
const tag = e.tagName.toLowerCase();
|
||||
if (["script", "style", "svg", "link", "meta", "noscript", "template"].includes(tag) || !shown(e)) return;
|
||||
const cls = [...e.classList].slice(0, 4).map((c) => "." + c).join("");
|
||||
const data = [...e.attributes].filter((a) => a.name.startsWith("data-") && a.value.length < 40).slice(0, 3).map((a) => `[${a.name}="${a.value}"]`).join("");
|
||||
const aria = ["role", "aria-label", "title", "placeholder", "type"].map((k) => e.getAttribute(k) ? `${k}="${e.getAttribute(k).slice(0, 40)}"` : "").filter(Boolean).join(" ");
|
||||
const text = own(e);
|
||||
const r = e.getBoundingClientRect();
|
||||
const away = r.top >= fold ? " (below the fold)" : "";
|
||||
const interesting = cls || data || e.id || text || aria || /^(button|a|input|select|textarea|label|h\d)$/.test(tag);
|
||||
if (interesting) lines.push(`${" ".repeat(Math.min(depth, 12))}${tag}${e.id ? "#" + e.id : ""}${cls}${data}${aria ? " " + aria : ""}${text ? ` "${text}"` : ""}${away}`);
|
||||
const scrolls = /auto|scroll|overlay/.test(getComputedStyle(e).overflowY) && e.scrollHeight > e.clientHeight + 1;
|
||||
for (const c of e.children) walk(c, interesting ? depth + 1 : depth, scrolls ? Math.min(fold, r.bottom) : fold);
|
||||
};
|
||||
walk(document.body, 0, innerHeight);
|
||||
if (lines.length >= MAX) lines.push("… (cut)");
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function screenText(page) {
|
||||
return page.evaluate(() => document.documentElement.innerText + "\n" +
|
||||
[...document.querySelectorAll("input, textarea")].map((e) => e.value).join("\n")).catch(() => "");
|
||||
}
|
||||
async function checkLeak(page, where) {
|
||||
if (!secrets.length) return;
|
||||
const text = await screenText(page);
|
||||
for (const s of secrets) {
|
||||
// the whole key, or a long enough run of its middle
|
||||
const mid = s.slice(4, -4);
|
||||
if (text.includes(s) || (mid.length >= 12 && text.includes(mid))) {
|
||||
manifest.leak = true;
|
||||
throw new Error(`a secret is on screen (${where}); nothing will be published`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deepseek(messages) {
|
||||
// a reply cut short or not JSON is asked for again
|
||||
let last;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await fetch("https://api.deepseek.com/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `Bearer ${env("DEEPSEEK_API_KEY")}` },
|
||||
body: JSON.stringify({ model: MODEL, messages, response_format: { type: "json_object" }, max_tokens: 16000 }),
|
||||
signal: AbortSignal.timeout(240e3),
|
||||
});
|
||||
if (!res.ok) throw new Error(`DeepSeek ${res.status}: ${(await res.text()).slice(0, 300)}`);
|
||||
const j = await res.json();
|
||||
const text = j.choices?.[0]?.message?.content ?? "";
|
||||
try { return JSON.parse(text.replace(/^```(?:json)?\s*|\s+```$/g, "")); }
|
||||
catch (e) { last = new Error(`reply not JSON (${j.choices?.[0]?.finish_reason}): ${e.message}`); log(last.message, "— asking again"); }
|
||||
}
|
||||
throw last;
|
||||
}
|
||||
|
||||
// Two things for the planner, from the PR's source:
|
||||
//
|
||||
// 1. the changed frontend files themselves — a Vue component's template is
|
||||
// the surest guide to the selectors it renders;
|
||||
// 2. the entry chains to them: a dialog or panel the diff touches is often
|
||||
// rendered by some other component that isn't in the diff, opened by a
|
||||
// condition the diff never shows. For each changed .vue component we
|
||||
// grep the frontend for who imports it and under what v-if/v-show it is
|
||||
// rendered, then for who flips those conditions — the magpie-style
|
||||
// two-hop caller walk, adapted to Vue.
|
||||
const SKIP = /(__tests__|\.spec\.|\.test\.|\.bench\.|^apps\/desktop\/src\/i18n\/locales\/)/;
|
||||
async function readTree(root) {
|
||||
const out = [];
|
||||
const walk = async (dir) => {
|
||||
for (const e of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
||||
const p = path.join(dir, e.name);
|
||||
if (e.isDirectory()) await walk(p);
|
||||
else if (/\.(vue|ts)$/.test(e.name)) out.push(p);
|
||||
}
|
||||
};
|
||||
await walk(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
async function codeContext(diff, src) {
|
||||
if (!src) return "";
|
||||
const changed = [...new Set([...diff.matchAll(/^diff --git a\/(\S+) b\/\1$/gm)].map((m) => m[1]))]
|
||||
.filter((f) => /^apps\/desktop\/src\//.test(f) && !SKIP.test(f.slice("apps/desktop/src/".length - 1)) && !SKIP.test(f));
|
||||
|
||||
// 1. the changed files
|
||||
let out = "";
|
||||
for (const f of changed.slice(0, 12)) {
|
||||
let body;
|
||||
try { body = await fs.readFile(path.join(src, f), "utf8"); } catch { continue; }
|
||||
const chunk = `--- ${f}\n${body.length > 8000 ? body.slice(0, 8000) + "\n… (cut)" : body}\n`;
|
||||
if (out.length + chunk.length > 20e3) { out += "… (cut)\n"; break; }
|
||||
out += chunk;
|
||||
}
|
||||
|
||||
// 2. entry chains: who renders the changed components, and who opens that
|
||||
const components = changed.filter((f) => f.endsWith(".vue")).map((f) => path.basename(f, ".vue"));
|
||||
if (!components.length) return out;
|
||||
const rel = (p) => path.relative(src, p);
|
||||
const files = (await readTree(path.join(src, "apps/desktop/src"))).filter((p) => !SKIP.test(rel(p)));
|
||||
const texts = new Map();
|
||||
const read = async (p) => {
|
||||
if (!texts.has(p)) {
|
||||
const b = await fs.readFile(p, "utf8").catch(() => "");
|
||||
texts.set(p, b.length > 400e3 ? "" : b);
|
||||
}
|
||||
return texts.get(p);
|
||||
};
|
||||
let entries = "";
|
||||
for (const name of components.slice(0, 6)) {
|
||||
const kebab = name.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase();
|
||||
const re = new RegExp(
|
||||
"import\\s+" + name + "\\b" +
|
||||
"|from\\s+[\"'][^\"']*/" + name + "(\\.vue)?[\"']" +
|
||||
"|import\\(\\s*[\"'][^\"']*/" + name + "(\\.vue)?[\"']\\s*\\)" +
|
||||
"|<" + kebab + "[\\s>/]|<" + name + "[\\s>/]");
|
||||
const flags = new Map(); // caller file -> Set of line numbers
|
||||
for (const p of files) {
|
||||
const t = await read(p);
|
||||
if (!t || rel(p).endsWith(`/${name}.vue`)) continue;
|
||||
t.split("\n").forEach((l, i) => { if (re.test(l)) (flags.get(p) ?? flags.set(p, new Set()).get(p)).add(i); });
|
||||
}
|
||||
for (const [p, lines] of flags) {
|
||||
// a dynamic import renames the component (const X = defineAsyncComponent):
|
||||
// resolve the local aliases, then mark where they are rendered — that
|
||||
// line, not the import, carries the v-if that shows it
|
||||
const ls = await read(p);
|
||||
const linesArr = ls.split("\n");
|
||||
const aliases = new Set([name]);
|
||||
for (const i of lines) {
|
||||
const m = /(?:import\s+(\w+)\s+from|const\s+(\w+)\s*=\s*(?:defineAsyncComponent|shallowRef|markRaw))/.exec(linesArr[i]);
|
||||
if (m && re.test(linesArr[i])) aliases.add(m[1] || m[2]);
|
||||
}
|
||||
if (aliases.size > 1) {
|
||||
const tagRe = new RegExp("<(?:" + [...aliases].join("|") + "|" + [...aliases].map((a) => a.replace(/([a-z])([A-Z])/g, "$1-$2").toLowerCase()).join("|") + ")[\\s>/]");
|
||||
linesArr.forEach((l, i) => { if (tagRe.test(l)) lines.add(i); });
|
||||
}
|
||||
// the usage with its v-if/v-show/:open condition, and identifiers in it
|
||||
const span = [];
|
||||
for (const i of lines) span.push(linesArr.slice(Math.max(0, i - 6), i + 14).join("\n"));
|
||||
const cond = span.join("\n");
|
||||
const chunk = `--- ${rel(p)} renders ${name} (how it is shown):\n${cond.slice(0, 2500)}\n`;
|
||||
if (entries.length + chunk.length > 9e3) { entries += "… (cut)\n"; break; }
|
||||
entries += chunk;
|
||||
// who flips those conditions: identifiers in v-if / :open / v-model near the usage
|
||||
const ids = [...new Set([...cond.matchAll(/(?:v-if|v-show|:open|v-model:open|:model-value)="\s*([A-Za-z_$][\w$.]*)/g)].map((m) => m[1]))].slice(0, 4);
|
||||
for (const id of ids) {
|
||||
const base = id.split(".").pop();
|
||||
const flip = new RegExp("\\b" + base + "\\s*=\\s*(true|false|!)|(?:function|const)\\s+" + base + "\\b|\\b" + base + "\\s*\\(");
|
||||
let shown = 0;
|
||||
for (const q of files) {
|
||||
if (shown >= 2) break;
|
||||
const qt = await read(q);
|
||||
if (!qt) continue;
|
||||
const qArr = qt.split("\n");
|
||||
for (let i = 0; i < qArr.length && shown < 2; i++) {
|
||||
if (!flip.test(qArr[i]) || re.test(qArr[i])) continue;
|
||||
const c = `--- ${rel(q)} flips ${id}:\n${qArr.slice(Math.max(0, i - 8), i + 8).join("\n")}\n`;
|
||||
if (entries.length + c.length > 12e3) break;
|
||||
entries += c;
|
||||
shown++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out + (entries ? `\n=== how the changes are reached (other components rendering/opening them — the entry points to plan from) ===\n${entries}` : "");
|
||||
}
|
||||
|
||||
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 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.
|
||||
|
||||
The diff is what the PR does. The title and description are the author's words and may be out of date or wrong: plan and summarise from the diff alone, and when the description claims something in the UI the diff doesn't do (a setting, a page, a button), don't look for it — say so in "mismatch".
|
||||
|
||||
You get the PR's title, description, diff, an outline of the app as it renders now, the changed frontend files themselves, and the entry chains: the other components that render the changed ones, under what v-if/v-show condition they are shown, and the code that flips those conditions. Use only selectors you can build from what the outline shows, or, for what only appears after an interaction (a dialog, a context menu, a dropdown, a tab), from the changed files and entry chains: the outline shows the app at rest, so an element the diff styles or builds being missing from it means you must open it first, not that it isn't there. The entry chains are the map — when a selector from the diff is nowhere in the outline, plan the steps that flip its condition (the toolbar button, the context menu, the tab the entry chain names), and don't search random other pages for it.
|
||||
|
||||
Write a plan that shows a reviewer exactly what this PR changes in the UI and whether it works as intended: go where the change is, do what a user would do to see it (open the connection, switch the tab, type the SQL, open the dialog…), and take a screenshot at each state that matters, before and after an interaction when that is the point. Captions say what is being done or what to look at ("打开订单表后的数据网格"), never what the result is or should be — the reviewer judges that from the picture, and the sandbox may differ from what you expect; and they never name a thing the diff doesn't add. When the change only shows with data this sandbox doesn't have (a server database, a plugin), say so in "unseen" and still show the place it would be. Keep it short: usually 1–3 scenes, under 15 steps each. Don't show unrelated pages. Never press anything that deletes, removes, clears or resets (删除/清空/移除/重置), and never run SQL that drops or truncates. Say ui_change false (and no scenes) only when the diff plainly changes nothing a user can see — only tests, comments, docs, or code that never reaches the screen; any change to the page's CSS, markup, text or behaviour is a UI change.
|
||||
|
||||
Reply with JSON only:
|
||||
{
|
||||
"ui_change": true,
|
||||
"summary": "one or two sentences, in Chinese, on what the diff changes in the UI",
|
||||
"mismatch": "Chinese, or empty: what the description says the UI gets that the diff doesn't do",
|
||||
"unseen": "Chinese, or empty: what of the change this sandbox can't show, and why",
|
||||
"scenes": [
|
||||
{ "title": "short Chinese title", "start": "app",
|
||||
"steps": [
|
||||
{ "do": "click", "target": "css selector", "text": "optional: only elements containing this text", "dblclick": false, "caption": "Chinese caption shown in the video" },
|
||||
{ "do": "hover", "target": "…", "caption": "…" },
|
||||
{ "do": "type", "target": "…", "value": "text to type", "caption": "…" },
|
||||
{ "do": "press", "key": "Escape" },
|
||||
{ "do": "scroll", "to": "css selector of what to bring into view", "text": "optional" },
|
||||
{ "do": "scroll", "target": "optional scroll container", "dy": 400 },
|
||||
{ "do": "wait", "ms": 800 },
|
||||
{ "do": "shot", "name": "kebab-case-name", "caption": "Chinese: what this screenshot shows", "target": "optional: capture just this element (with some room around it)" }
|
||||
] }
|
||||
]
|
||||
}`;
|
||||
|
||||
// the mouse glides like a hand would, so its trail reads in the video
|
||||
let mouse = { x: VIEW.width / 2, y: VIEW.height / 2 };
|
||||
async function glide(page, x, y) {
|
||||
const d = Math.hypot(x - mouse.x, y - mouse.y);
|
||||
const ms = Math.min(900, Math.max(250, d * 1.1));
|
||||
const n = Math.max(8, Math.round(ms / 16));
|
||||
const from = { ...mouse };
|
||||
for (let i = 1; i <= n; i++) {
|
||||
const t = i / n, e = t < .5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
|
||||
// a slight arc, not a ruler line
|
||||
const bend = Math.sin(Math.PI * t) * Math.min(40, d * .08);
|
||||
const nx = from.x + (x - from.x) * e - ((y - from.y) / (d || 1)) * bend;
|
||||
const ny = from.y + (y - from.y) * e + ((x - from.x) / (d || 1)) * bend;
|
||||
await page.mouse.move(nx, ny);
|
||||
await sleep(16);
|
||||
}
|
||||
mouse = { x, y };
|
||||
}
|
||||
|
||||
async function locate(page, step) {
|
||||
if (!step.target) throw new Error(`${step.do} needs a target`);
|
||||
let loc = page.locator(step.target);
|
||||
if (step.text) loc = loc.filter({ hasText: step.text });
|
||||
const visible = async (l) => {
|
||||
const n = await l.count();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const one = l.nth(i);
|
||||
if (await one.isVisible()) return one;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const hit = await visible(loc);
|
||||
if (hit) return hit;
|
||||
// the selector guessed a role or structure this app doesn't use (a tab, a
|
||||
// menuitem…): fall back to the one visible clickable thing carrying the
|
||||
// text — only when exactly one is on screen, so a wrong guess fails loud
|
||||
if (step.text) {
|
||||
const loose = page.locator('button, a, li, [role="menuitem"], [role="tab"], [role="button"], [class*="categor"], [class*="nav"] button, [class*="sidebar"] *').filter({ hasText: step.text });
|
||||
const seen = [];
|
||||
const n = await loose.count();
|
||||
for (let i = 0; i < n; i++) {
|
||||
const one = loose.nth(i);
|
||||
if (await one.isVisible()) seen.push(one);
|
||||
}
|
||||
if (seen.length === 1) return seen[0];
|
||||
}
|
||||
throw new Error(`nothing visible matches ${step.target}${step.text ? ` with "${step.text}"` : ""} (${await loc.count()} in the page)`);
|
||||
}
|
||||
|
||||
// Out of sight is scrolled to as a reader would, with the wheel over what
|
||||
// scrolls. Each view that hides it, the innermost first, is wheeled in small
|
||||
// steps until the element is in the middle of it, or at its top when it's
|
||||
// taller.
|
||||
async function bringIntoView(page, loc) {
|
||||
const PAD = 24;
|
||||
for (let pass = 0; pass < 6; pass++) {
|
||||
const views = await loc.evaluate((e, PAD) => {
|
||||
const out = [];
|
||||
const clip = { top: 0, bottom: innerHeight, left: 0, right: innerWidth };
|
||||
const scrollers = [];
|
||||
for (let s = e.parentElement; s; s = s.parentElement) {
|
||||
const root = s === document.scrollingElement;
|
||||
const o = getComputedStyle(s).overflowY;
|
||||
if ((root || /auto|scroll|overlay/.test(o)) && s.scrollHeight > s.clientHeight + 1) scrollers.push(s);
|
||||
}
|
||||
const r = e.getBoundingClientRect();
|
||||
for (const s of scrollers) {
|
||||
const root = s === document.scrollingElement;
|
||||
const b = root ? clip : s.getBoundingClientRect();
|
||||
// what of it shows: inside the window and every view around it
|
||||
let top = Math.max(b.top, 0), bottom = Math.min(b.bottom, innerHeight);
|
||||
let left = Math.max(b.left, 0), right = Math.min(b.right, innerWidth);
|
||||
for (let p = s.parentElement; p && !root; p = p.parentElement) {
|
||||
if (!scrollers.includes(p) || p === document.scrollingElement) continue;
|
||||
const pb = p.getBoundingClientRect();
|
||||
top = Math.max(top, pb.top); bottom = Math.min(bottom, pb.bottom);
|
||||
left = Math.max(left, pb.left); right = Math.min(right, pb.right);
|
||||
}
|
||||
const room = bottom - top - 2 * PAD;
|
||||
const want = r.height <= room ? (r.top + r.bottom) / 2 - (top + bottom) / 2 : r.top - (top + PAD);
|
||||
const shown = r.top >= top + Math.min(PAD, Math.max(0, room - r.height) / 2) && (r.height <= room ? r.bottom <= bottom - PAD : r.top <= top + PAD * 2);
|
||||
out.push({ shown, dy: Math.max(-s.scrollTop, Math.min(s.scrollHeight - s.clientHeight - s.scrollTop, want)),
|
||||
x: (left + right) / 2, y: (top + bottom) / 2, seen: bottom - top > 20 && right - left > 20 });
|
||||
}
|
||||
return out;
|
||||
}, PAD);
|
||||
const v = views.find((v) => !v.shown && Math.abs(v.dy) >= 4 && v.seen);
|
||||
if (!v) return;
|
||||
await glide(page, v.x, v.y);
|
||||
await wheel(page, v.dy);
|
||||
await sleep(350);
|
||||
}
|
||||
}
|
||||
|
||||
// a hand's flick: a few notches at a time, not one jump
|
||||
async function wheel(page, dy) {
|
||||
for (let left = dy; Math.abs(left) >= 1;) {
|
||||
const n = Math.sign(left) * Math.min(Math.abs(left), 90);
|
||||
await page.mouse.wheel(0, n);
|
||||
left -= n;
|
||||
await sleep(28);
|
||||
}
|
||||
}
|
||||
|
||||
async function pointAt(page, loc) {
|
||||
await bringIntoView(page, loc);
|
||||
const b = await loc.boundingBox();
|
||||
if (!b) throw new Error("the element has no box");
|
||||
const x = b.x + Math.min(b.width / 2, 60), y = b.y + b.height / 2;
|
||||
await glide(page, x, y);
|
||||
return b;
|
||||
}
|
||||
|
||||
// The app polls, so the network never goes quiet: loaded, and its first
|
||||
// paint in, is ready
|
||||
async function settle(page) {
|
||||
await page.waitForLoadState("load").catch(() => {});
|
||||
await page.waitForFunction(() => document.querySelectorAll("button").length >= 2, null, { timeout: 15000 }).catch(() => {});
|
||||
await sleep(1200);
|
||||
}
|
||||
|
||||
const caption = (page, text) => page.evaluate((t) => window.__uiPreviewCaption?.(t), text || "").catch(() => {});
|
||||
let shotN = 0;
|
||||
|
||||
async function runStep(page, step, scene) {
|
||||
if (step.caption) await caption(page, step.caption);
|
||||
switch (step.do) {
|
||||
case "click": {
|
||||
const loc = await locate(page, step);
|
||||
const label = (await loc.innerText().catch(() => "")) + " " + (await loc.getAttribute("title").catch(() => "") || "");
|
||||
if (FORBIDDEN.test(label)) throw new Error(`won't press "${label.trim().slice(0, 40)}"`);
|
||||
await pointAt(page, loc);
|
||||
await sleep(180);
|
||||
if (step.dblclick) {
|
||||
await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
await sleep(120); await page.mouse.down(); await sleep(60); await page.mouse.up();
|
||||
} else {
|
||||
await page.mouse.down();
|
||||
await sleep(90);
|
||||
await page.mouse.up();
|
||||
}
|
||||
await sleep(700);
|
||||
break;
|
||||
}
|
||||
case "hover": {
|
||||
await pointAt(page, await locate(page, step));
|
||||
await sleep(900);
|
||||
break;
|
||||
}
|
||||
case "type": {
|
||||
const loc = await locate(page, step);
|
||||
await pointAt(page, loc);
|
||||
await page.mouse.down(); await sleep(80); await page.mouse.up();
|
||||
await loc.fill("");
|
||||
await page.keyboard.type(String(step.value ?? ""), { delay: 70 });
|
||||
await sleep(600);
|
||||
break;
|
||||
}
|
||||
case "press":
|
||||
await page.keyboard.press(step.key || "Escape");
|
||||
await sleep(500);
|
||||
break;
|
||||
case "scroll": {
|
||||
if (step.to) {
|
||||
await bringIntoView(page, await locate(page, { ...step, target: step.to }));
|
||||
await sleep(400);
|
||||
break;
|
||||
}
|
||||
// over the view to scroll, or the page's middle: the wheel turns what's under the mouse
|
||||
if (step.target) await pointAt(page, await locate(page, step));
|
||||
else if (mouse.y < 60) await glide(page, VIEW.width / 2, VIEW.height / 2);
|
||||
await wheel(page, step.dy ?? 400);
|
||||
await sleep(700);
|
||||
break;
|
||||
}
|
||||
case "wait":
|
||||
await sleep(Math.min(step.ms ?? 800, 4000));
|
||||
break;
|
||||
case "shot": {
|
||||
// in sight first, scrolled to on camera: the clip is of the screen
|
||||
const loc = step.target ? await locate(page, step) : null;
|
||||
if (loc) await bringIntoView(page, loc);
|
||||
await sleep(250);
|
||||
await checkLeak(page, `before ${step.name}`);
|
||||
const file = `${String(++shotN).padStart(2, "0")}-${(step.name || "shot").replace(/[^a-z0-9-]/gi, "-").slice(0, 40)}.png`;
|
||||
// the screenshot without the drawn mouse and caption
|
||||
await page.evaluate(() => document.querySelector("[data-ui-preview]")?.style.setProperty("visibility", "hidden"));
|
||||
let clip;
|
||||
try {
|
||||
if (loc) {
|
||||
const b = await loc.boundingBox();
|
||||
if (b) {
|
||||
// the target with room around it, never so small that a button
|
||||
// is shown without where it is
|
||||
const w = Math.min(VIEW.width, Math.max(560, b.width + 48)), h = Math.min(VIEW.height, Math.max(340, b.height + 48));
|
||||
const x = Math.min(VIEW.width - w, Math.max(0, b.x + b.width / 2 - w / 2));
|
||||
const y = Math.min(VIEW.height - h, Math.max(0, b.y + b.height / 2 - h / 2));
|
||||
clip = { x, y, width: w, height: h };
|
||||
}
|
||||
}
|
||||
await page.screenshot({ path: path.join(OUT, file), clip });
|
||||
} 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) });
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unknown step ${step.do}`);
|
||||
}
|
||||
if (step.do !== "shot") await checkLeak(page, `after ${step.do}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await fs.mkdir(OUT, { recursive: true });
|
||||
const diff = await fs.readFile(env("DIFF_FILE"), "utf8");
|
||||
const body = await fs.readFile(env("PR_BODY_FILE", "/dev/null"), "utf8").catch(() => "");
|
||||
const browser = await chromium.launch();
|
||||
const ctxOpts = { viewport: VIEW, deviceScaleFactor: 2, locale: LOCALE, colorScheme: "light" };
|
||||
|
||||
// what the app looks like now, for the plan
|
||||
const look = await browser.newContext(ctxOpts);
|
||||
const lp = await look.newPage();
|
||||
const outlines = {};
|
||||
try {
|
||||
await lp.goto(BASE);
|
||||
await settle(lp);
|
||||
outlines.app = await lp.evaluate(outline);
|
||||
} catch (e) { outlines.app = `(failed to load: ${e.message})`; }
|
||||
await look.close();
|
||||
|
||||
const MAXDIFF = 150e3;
|
||||
const code = await codeContext(diff, env("SRC_DIR")).catch((e) => (log("code context:", e.message), ""));
|
||||
log(`code context: ${code.length} characters`);
|
||||
const ask = [
|
||||
{ role: "system", content: GUIDE },
|
||||
{ role: "user", content: `PR title: ${env("PR_TITLE", "")}\n\nPR description:\n${body.slice(0, 6000)}\n\nDiff${diff.length > MAXDIFF ? " (cut)" : ""}:\n${diff.slice(0, MAXDIFF)}\n\n` +
|
||||
`=== outline: app ===\n${outlines.app}` +
|
||||
(code ? `\n\n=== the changed frontend files (the PR's version) ===\n${code}` : "") },
|
||||
];
|
||||
let plan;
|
||||
try { plan = env("PLAN_FILE") ? JSON.parse(await fs.readFile(env("PLAN_FILE"), "utf8")) : await deepseek(ask); }
|
||||
catch (e) { plan = null; manifest.errors.push(`plan: ${e.message}`); }
|
||||
if (!plan || plan.ui_change === false || !plan.scenes?.length) {
|
||||
manifest.summary = plan?.summary || "";
|
||||
manifest.skipped = plan ? "no visible UI change found in the diff" : "no plan";
|
||||
// still something to look at: the app as it opens
|
||||
plan = { summary: manifest.summary, scenes: plan ? [] : [{ title: "应用主界面", start: "app", steps: [{ do: "shot", name: "app", caption: "应用主界面" }] }] };
|
||||
}
|
||||
manifest.summary = plan.summary || "";
|
||||
manifest.mismatch = plan.mismatch || "";
|
||||
manifest.unseen = plan.unseen || "";
|
||||
log("plan:", JSON.stringify(plan, null, 1));
|
||||
|
||||
if (plan.scenes.length) {
|
||||
const rec = await browser.newContext({ ...ctxOpts, recordVideo: { dir: path.join(OUT, "raw"), size: VIEW } });
|
||||
await rec.addInitScript({ path: path.join(here, "cursor.js") });
|
||||
const page = await rec.newPage();
|
||||
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: [] };
|
||||
manifest.scenes.push(scene);
|
||||
try {
|
||||
await caption(page, scene.title);
|
||||
await page.goto(BASE);
|
||||
await settle(page);
|
||||
if (!lead) lead = Math.max(0, (Date.now() - began) / 1000 - 0.4);
|
||||
await page.evaluate(([x, y]) => window.__uiPreviewAt?.(x, y), [mouse.x, mouse.y]);
|
||||
} catch (e) { manifest.errors.push(`${scene.title}: open: ${e.message}`); if (manifest.leak) break; continue; }
|
||||
let steps = (s.steps || []).slice(0, 25);
|
||||
for (let i = 0; i < steps.length; i++) {
|
||||
const step = steps[i];
|
||||
if (["click", "hover", "type", "press", "scroll"].includes(step.do)) interactive = true;
|
||||
try { await runStep(page, step, scene); }
|
||||
catch (e) {
|
||||
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);
|
||||
if (repairs >= 3) continue;
|
||||
repairs++;
|
||||
// the page as it is now, and what went wrong: the rest of the scene again
|
||||
try {
|
||||
const fix = await deepseek([...ask, { role: "assistant", content: JSON.stringify(plan) },
|
||||
{ role: "user", content: `Step ${i + 1} of scene "${scene.title}" failed: ${e.message.split("\n")[0]}\nThe page now:\n${await page.evaluate(outline)}\n\nIf the failed selector came from the diff, remember the changed-file paths above say which dialog, panel or component holds it — a different one may have to be opened (or closed) first, not just a different selector for this page. Reply with JSON {"steps": [...]}: the steps to do instead of that one and the ones after it in this scene.` }]);
|
||||
if (Array.isArray(fix.steps)) { steps = [...steps.slice(0, i + 1), ...fix.steps.slice(0, 20)]; log("repaired:", JSON.stringify(fix.steps)); }
|
||||
} catch (e2) { manifest.errors.push(`repair: ${e2.message}`); }
|
||||
}
|
||||
}
|
||||
if (manifest.leak) break;
|
||||
await caption(page, "");
|
||||
await sleep(600);
|
||||
}
|
||||
const video = await page.video().path();
|
||||
await rec.close();
|
||||
manifest.interactive = interactive;
|
||||
// a video when there's something to watch: an interaction
|
||||
if (!manifest.leak && interactive) {
|
||||
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-ss", lead.toFixed(2), "-i", video, "-c:v", "libx264", "-preset", "medium", "-crf", "20",
|
||||
"-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", path.join(OUT, "preview.mp4")]);
|
||||
manifest.video = "preview.mp4";
|
||||
manifest.poster = await poster(browser);
|
||||
}
|
||||
await fs.rm(path.join(OUT, "raw"), { recursive: true, force: true });
|
||||
}
|
||||
await browser.close();
|
||||
if (manifest.leak) {
|
||||
for (const f of await fs.readdir(OUT)) await fs.rm(path.join(OUT, f), { recursive: true, force: true });
|
||||
manifest.scenes = []; manifest.video = manifest.poster = null;
|
||||
}
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2));
|
||||
log("done:", JSON.stringify({ shots: manifest.scenes.reduce((n, s) => n + s.shots.length, 0), video: manifest.video, errors: manifest.errors.length, leak: manifest.leak }));
|
||||
}
|
||||
|
||||
// the video's still in the PR: a frame from the middle with a play button
|
||||
async function poster(browser) {
|
||||
const frame = path.join(OUT, "frame.png");
|
||||
const dur = parseFloat(execFileSync("ffprobe", ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path.join(OUT, "preview.mp4")]).toString()) || 2;
|
||||
execFileSync("ffmpeg", ["-y", "-loglevel", "error", "-ss", String(Math.min(dur / 3, 6)), "-i", path.join(OUT, "preview.mp4"), "-frames:v", "1", frame]);
|
||||
const img = (await fs.readFile(frame)).toString("base64");
|
||||
const p = await browser.newPage({ viewport: VIEW, deviceScaleFactor: 1 });
|
||||
await p.setContent(`<body style="margin:0;background:url(data:image/png;base64,${img}) center/cover;height:100vh;display:grid;place-items:center">
|
||||
<div style="position:absolute;inset:0;background:rgba(0,0,0,.28)"></div>
|
||||
<div style="position:relative;display:flex;align-items:center;gap:18px;padding:22px 34px 22px 26px;border-radius:999px;background:rgba(17,17,19,.78);color:#fff;font:600 30px -apple-system,'Segoe UI','Noto Sans CJK SC',sans-serif">
|
||||
<svg width="46" height="46" viewBox="0 0 24 24"><circle cx="12" cy="12" r="12" fill="#fff"/><path d="M9.5 7.5v9l7-4.5z" fill="#111"/></svg>
|
||||
播放录屏 · ${Math.round(dur)} 秒</div></body>`);
|
||||
await p.screenshot({ path: path.join(OUT, "poster.png") });
|
||||
await p.close();
|
||||
await fs.rm(frame);
|
||||
return "poster.png";
|
||||
}
|
||||
|
||||
main().catch(async (e) => {
|
||||
console.error(e);
|
||||
manifest.errors.push(`recorder: ${e.message}`);
|
||||
await fs.mkdir(OUT, { recursive: true }).catch(() => {});
|
||||
await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2)).catch(() => {});
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runs the PR's frontend against a dbx-web backend in a home of its own, with
|
||||
# made-up data beside it (seed.mjs: two SQLite databases, connections, a
|
||||
# saved-SQL library, query history), and records it (record.mjs).
|
||||
#
|
||||
# run.sh <out dir>
|
||||
#
|
||||
# env: DBX_WEB_BIN the dbx-web binary (or the static package's launcher)
|
||||
# — required
|
||||
# DIST_DIR the PR's frontend build; when set it replaces the
|
||||
# binary's embedded frontend (DBX_STATIC_DIR)
|
||||
# DBX_PORT listen port (default 4280)
|
||||
# DEEPSEEK_API_KEY, DIFF_FILE, PR_TITLE, PR_BODY_FILE, SRC_DIR (the
|
||||
# PR's source, for code context) — read by record.mjs;
|
||||
# PLAYWRIGHT_BROWSERS_PATH must already point at the installed
|
||||
# Chromium, since HOME moves.
|
||||
#
|
||||
# For local testing against a dbx-web that already runs somewhere: set
|
||||
# DBX_URL and nothing here is started or seeded.
|
||||
set -euo pipefail
|
||||
[ "$(uname -s)" = Linux ] || { echo "run.sh: Linux only" >&2; exit 1; }
|
||||
out_arg=$1
|
||||
mkdir -p "$out_arg"
|
||||
out=$(cd "$out_arg" && pwd)
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
work=$(mktemp -d)
|
||||
web=""
|
||||
trap 'kill "${web:-}" 2>/dev/null || true' EXIT
|
||||
|
||||
url=${DBX_URL:-}
|
||||
if [ -z "$url" ]; then
|
||||
: "${DBX_WEB_BIN:?DBX_WEB_BIN or DBX_URL is required}"
|
||||
port=${DBX_PORT:-4280}
|
||||
# nothing of the runner's own: the app sees only this home
|
||||
export HOME="$work/home" XDG_CONFIG_HOME="$work/home/.config" XDG_CACHE_HOME="$work/home/.cache" \
|
||||
XDG_DATA_HOME="$work/home/.local/share" XDG_STATE_HOME="$work/home/.local/state"
|
||||
mkdir -p "$HOME" "$work/data" "$work/dbs"
|
||||
|
||||
static=()
|
||||
[ -n "${DIST_DIR:-}" ] && static=(DBX_STATIC_DIR="$(cd "$DIST_DIR" && pwd)")
|
||||
env DBX_PORT="$port" DBX_BIND_ADDR=127.0.0.1 DBX_DISABLE_PASSWORD=1 \
|
||||
DBX_DATA_DIR="$work/data" "${static[@]}" \
|
||||
"$DBX_WEB_BIN" >"$work/web.log" 2>&1 &
|
||||
web=$!
|
||||
url="http://127.0.0.1:$port"
|
||||
for _ in $(seq 60); do
|
||||
curl -fsS -o /dev/null "$url/api/auth/check" 2>/dev/null && break
|
||||
kill -0 "$web" 2>/dev/null || { cat "$work/web.log"; exit 1; }
|
||||
sleep 1
|
||||
done
|
||||
curl -fsS -o /dev/null "$url/api/auth/check" || { echo "run.sh: dbx-web never became ready" >&2; cat "$work/web.log"; exit 1; }
|
||||
|
||||
node "$here/seed.mjs" dbs "$work/dbs"
|
||||
node "$here/seed.mjs" api "$url" "$work/dbs"
|
||||
fi
|
||||
|
||||
status=0
|
||||
DBX_URL="$url" OUT_DIR="$out" node "$here/record.mjs" || status=$?
|
||||
exit $status
|
||||
Executable
+212
@@ -0,0 +1,212 @@
|
||||
// Fills the UI preview's sandbox (run.sh) with data to show: a fresh dbx-web
|
||||
// has no connections, so a change to the sidebar, the grid, query history or
|
||||
// the ER diagram has nothing to draw. Made up, every bit of it, and never
|
||||
// real.
|
||||
//
|
||||
// node seed.mjs dbs <dir> [--as <path on the server>] — two SQLite
|
||||
// databases with fixed, deterministic content (python3 writes them, so
|
||||
// the files are identical byte-for-byte on every run);
|
||||
// node seed.mjs api <base-url> <dir> [--as <path on the server>]
|
||||
// — saves the connections (SQLite: the file path is the host), connects,
|
||||
// runs real queries through the gateway so history and result caches
|
||||
// are genuine, then adds a saved-SQL library and a few more history
|
||||
// entries the UI can list.
|
||||
//
|
||||
// The connections point at files as the dbx-web process sees them: pass
|
||||
// --as when the databases are generated on another machine than the one
|
||||
// dbx-web runs on (local testing over an SSH tunnel).
|
||||
import { execFileSync } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const arg = (n) => process.argv[n];
|
||||
const opt = (name, d) => {
|
||||
const i = process.argv.indexOf(name);
|
||||
return i > 0 && i + 1 < process.argv.length ? process.argv[i + 1] : d;
|
||||
};
|
||||
|
||||
const DBS = [
|
||||
{
|
||||
file: "dbx-shop.sqlite",
|
||||
name: "商店 Shop (SQLite)",
|
||||
note: "订单、商品与客户示例库",
|
||||
tables: "customers · products · orders · audit_log(含视图 v_daily_revenue、外键与触发器)",
|
||||
warm: ["SELECT * FROM orders ORDER BY created_at DESC LIMIT 50", "SELECT c.city, COUNT(*) AS orders, SUM(o.amount) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC", "SELECT category, COUNT(*) AS n, ROUND(AVG(price), 2) AS avg_price\nFROM products WHERE listed = 1 GROUP BY category ORDER BY n DESC"],
|
||||
},
|
||||
{
|
||||
file: "dbx-analytics.sqlite",
|
||||
name: "分析 Analytics (SQLite)",
|
||||
note: "事件流与每日指标",
|
||||
tables: "events(5000 行,看分页)· daily_stats(90 天)",
|
||||
warm: ["SELECT event_type, COUNT(*) AS n, ROUND(AVG(duration_ms)) AS avg_ms\nFROM events GROUP BY event_type ORDER BY n DESC", "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14", "SELECT * FROM daily_stats ORDER BY stat_date DESC LIMIT 30"],
|
||||
},
|
||||
];
|
||||
|
||||
// Deterministic content: the same LCG magpie's seed uses, so two previews of
|
||||
// one PR differ only by the PR.
|
||||
function python(dir) {
|
||||
const py = `
|
||||
import os, sqlite3, random, datetime
|
||||
rng = random.Random(20261008)
|
||||
def d(days, hours=0):
|
||||
return (datetime.datetime(2026, 10, 8, 12, 0, 0) - datetime.timedelta(days=days, hours=hours)).strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
shop = sqlite3.connect(os.path.join(${JSON.stringify(dir)}, "dbx-shop.sqlite"))
|
||||
shop.executescript("""
|
||||
CREATE TABLE customers (id INTEGER PRIMARY KEY, name TEXT NOT NULL, email TEXT, city TEXT, level TEXT, created_at TEXT);
|
||||
CREATE TABLE products (id INTEGER PRIMARY KEY, name TEXT NOT NULL, category TEXT, price REAL, stock INTEGER, listed INTEGER DEFAULT 1);
|
||||
CREATE TABLE orders (id INTEGER PRIMARY KEY, customer_id INTEGER REFERENCES customers(id), product_id INTEGER REFERENCES products(id),
|
||||
quantity INTEGER, amount REAL, status TEXT, created_at TEXT);
|
||||
CREATE TABLE audit_log (id INTEGER PRIMARY KEY, ts TEXT, what TEXT);
|
||||
CREATE INDEX idx_orders_created ON orders(created_at);
|
||||
CREATE INDEX idx_orders_status ON orders(status);
|
||||
CREATE VIEW v_daily_revenue AS SELECT date(created_at) AS day, COUNT(*) AS orders, ROUND(SUM(amount), 2) AS revenue FROM orders GROUP BY date(created_at);
|
||||
CREATE TRIGGER trg_orders_audit AFTER INSERT ON orders BEGIN INSERT INTO audit_log(ts, what) VALUES (datetime('now'), 'order ' || NEW.id); END;
|
||||
""")
|
||||
cities = ["上海", "北京", "深圳", "杭州", "成都", "广州", "武汉", "南京"]
|
||||
surnames = ["王", "李", "张", "刘", "陈", "杨", "赵", "周", "吴", "徐"]
|
||||
givens = ["晓东", "思远", "雨桐", "子墨", "一鸣", "嘉怡", "浩然", "若彤", "俊杰", "梦琪"]
|
||||
levels = ["bronze", "silver", "gold"]
|
||||
for i in range(1, 201):
|
||||
shop.execute("INSERT INTO customers VALUES (?,?,?,?,?,?)",
|
||||
(i, rng.choice(surnames) + rng.choice(givens), f"user{i}@example.com", rng.choice(cities), rng.choice(levels), d(rng.randint(30, 400), rng.randint(0, 23))))
|
||||
cats = ["键盘", "显示器", "鼠标", "硬盘", "内存", "耳机", "摄像头", "扩展坞"]
|
||||
for i in range(1, 121):
|
||||
shop.execute("INSERT INTO products VALUES (?,?,?,?,?,?)",
|
||||
(i, f"{rng.choice(cats)} {chr(65 + i % 26)}{i:03d}", rng.choice(cats), round(rng.uniform(49, 4999), 2), rng.randint(0, 500), 1 if rng.random() > 0.1 else 0))
|
||||
statuses = ["paid", "shipped", "done", "refund"]
|
||||
for i in range(1, 501):
|
||||
q = rng.randint(1, 3)
|
||||
price = rng.uniform(49, 4999)
|
||||
shop.execute("INSERT INTO orders VALUES (?,?,?,?,?,?,?)",
|
||||
(i, rng.randint(1, 200), rng.randint(1, 120), q, round(q * price, 2), rng.choice(statuses) if rng.random() > 0.15 else "pending", d(rng.randint(0, 90), rng.randint(0, 23))))
|
||||
shop.commit()
|
||||
|
||||
an = sqlite3.connect(os.path.join(${JSON.stringify(dir)}, "dbx-analytics.sqlite"))
|
||||
an.executescript("""
|
||||
CREATE TABLE events (id INTEGER PRIMARY KEY, ts TEXT, user_id INTEGER, event_type TEXT, page TEXT, duration_ms INTEGER);
|
||||
CREATE TABLE daily_stats (stat_date TEXT PRIMARY KEY, dau INTEGER, revenue REAL, new_users INTEGER);
|
||||
CREATE INDEX idx_events_ts ON events(ts);
|
||||
CREATE INDEX idx_events_type ON events(event_type);
|
||||
""")
|
||||
types = ["page_view", "click", "scroll", "search", "submit", "error"]
|
||||
pages = ["/", "/grid", "/editor", "/history", "/settings", "/diagram"]
|
||||
day = datetime.date(2026, 10, 8)
|
||||
for i in range(1, 5001):
|
||||
back = rng.randint(0, 29)
|
||||
ts = datetime.datetime(2026, 10, 8, rng.randint(0, 23), rng.randint(0, 59), rng.randint(0, 59)) - datetime.timedelta(days=back)
|
||||
an.execute("INSERT INTO events VALUES (?,?,?,?,?,?)", (i, ts.strftime("%Y-%m-%d %H:%M:%S"), rng.randint(1, 400), rng.choice(types) if rng.random() > 0.02 else "error", rng.choice(pages), rng.randint(50, 180000) if rng.random() > 0.1 else None))
|
||||
for k in range(90):
|
||||
date = (day - datetime.timedelta(days=k)).strftime("%Y-%m-%d")
|
||||
an.execute("INSERT INTO daily_stats VALUES (?,?,?,?)", (date, rng.randint(180, 420), round(rng.uniform(800, 5200), 2), rng.randint(3, 26)))
|
||||
an.commit()
|
||||
print("ok")
|
||||
`;
|
||||
execFileSync("python3", ["-c", py], { stdio: ["ignore", "inherit", "inherit"] });
|
||||
}
|
||||
|
||||
async function api(base, p, init = {}) {
|
||||
const res = await fetch(base.replace(/\/$/, "") + p, {
|
||||
...init,
|
||||
headers: { "Content-Type": "application/json", ...(init.headers || {}) },
|
||||
});
|
||||
const text = await res.text();
|
||||
if (!res.ok) throw new Error(`${init.method || "GET"} ${p}: ${res.status} ${text.slice(0, 300)}`);
|
||||
try { return JSON.parse(text); } catch { return text; }
|
||||
}
|
||||
|
||||
async function seedApi() {
|
||||
const base = arg(3);
|
||||
const dir = path.resolve(arg(4));
|
||||
const as = path.resolve(opt("--as", dir));
|
||||
const now = Date.now();
|
||||
const iso = (msAgo) => new Date(now - msAgo).toISOString();
|
||||
|
||||
const conns = DBS.map((db, i) => ({
|
||||
id: `seed-${db.file.replace(".sqlite", "")}`,
|
||||
name: db.name,
|
||||
note: db.note,
|
||||
db_type: "sqlite",
|
||||
driver_label: "SQLite",
|
||||
host: path.join(as, db.file),
|
||||
port: 0,
|
||||
username: "",
|
||||
password: "",
|
||||
database: "main",
|
||||
color: i === 0 ? "#2f6fed" : "#8f4bd6",
|
||||
}));
|
||||
await api(base, "/api/connection/save", { method: "POST", body: JSON.stringify({ configs: conns, removed_ids: [] }) });
|
||||
console.log("[ui-preview] saved", conns.length, "connections");
|
||||
|
||||
for (const c of conns) {
|
||||
await api(base, "/api/connection/connect", { method: "POST", body: JSON.stringify({ config: c, client_attempt: 1 }) });
|
||||
const info = await api(base, `/api/connection/info?connection_id=${encodeURIComponent(c.id)}`).catch((e) => (console.log("[ui-preview] connection info:", e.message), null));
|
||||
const database = info?.current_database || info?.databases?.[0] || "main";
|
||||
const db = DBS.find((x) => c.host.endsWith(x.file));
|
||||
for (const sql of db.warm) {
|
||||
const r = await api(base, "/api/query/execute", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ connectionId: c.id, database, sql, maxRows: 200 }),
|
||||
}).catch((e) => (console.log("[ui-preview] warm query failed:", e.message), null));
|
||||
if (r) console.log("[ui-preview] ran", sql.split("\n")[0].slice(0, 60));
|
||||
}
|
||||
}
|
||||
|
||||
// A saved-SQL library, so the SQL library panel has folders and files.
|
||||
const folder = await api(base, "/api/saved-sql/folders", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ id: "seed-folder-daily", name: "常用查询", createdAt: iso(36e5 * 72), updatedAt: iso(36e5 * 5) }),
|
||||
}).catch(() => null);
|
||||
const saved = [
|
||||
{ id: "seed-sql-1", name: "各城市营收 Top", sql: "SELECT c.city, COUNT(*) AS orders, ROUND(SUM(o.amount), 2) AS revenue\nFROM orders o JOIN customers c ON c.id = o.customer_id\nGROUP BY c.city ORDER BY revenue DESC" },
|
||||
{ id: "seed-sql-2", name: "近 14 天 DAU", sql: "SELECT date(ts) AS day, COUNT(DISTINCT user_id) AS dau\nFROM events GROUP BY day ORDER BY day DESC LIMIT 14" },
|
||||
{ id: "seed-sql-3", name: "低库存商品", sql: "SELECT name, category, stock FROM products WHERE stock < 20 AND listed = 1 ORDER BY stock" },
|
||||
];
|
||||
for (const [i, s] of saved.entries()) {
|
||||
await api(base, "/api/saved-sql", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...s, connectionId: conns[i % conns.length].id, folderId: folder?.id || "seed-folder-daily",
|
||||
database: "main", orderIndex: i, openCount: 3 + i, createdAt: iso(36e5 * (70 - i)), updatedAt: iso(36e5 * (24 - i * 6)),
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] saved-sql:", e.message));
|
||||
}
|
||||
console.log("[ui-preview] saved-sql library seeded");
|
||||
|
||||
// A few more history entries besides the real ones above: spread over the
|
||||
// last three days, one failure included, so the history list pages.
|
||||
const extra = [
|
||||
[conns[0], "SELECT COUNT(*) FROM orders WHERE status = 'pending'", 12, true],
|
||||
[conns[0], "SELECT * FROM v_daily_revenue ORDER BY day DESC LIMIT 7", 18, true],
|
||||
[conns[0], "UPDATE products SET stock = stock - 1 WHERE id = 42", 6, false],
|
||||
[conns[1], "SELECT stat_date, dau, revenue FROM daily_stats ORDER BY stat_date DESC LIMIT 30", 15, true],
|
||||
[conns[1], "SELECT page, COUNT(*) FROM events WHERE event_type = 'error' GROUP BY page", 22, true],
|
||||
[conns[1], "DELETE FROM events WHERE id < 0", 4, false],
|
||||
];
|
||||
for (const [c, sql, ms, ok] of extra) {
|
||||
await api(base, "/api/history/save", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
entry: {
|
||||
id: `seed-h-${Math.random().toString(36).slice(2, 10)}`, connection_id: c.id, connection_name: c.name,
|
||||
database: "main", sql, executed_at: iso(36e5 * (2 + Math.random() * 60)), execution_time_ms: ms, success: ok,
|
||||
error: ok ? null : "demo entry: would have modified rows (not really run)", activity_kind: "query", source: "editor",
|
||||
},
|
||||
}),
|
||||
}).catch((e) => console.log("[ui-preview] history:", e.message));
|
||||
}
|
||||
console.log("[ui-preview] history seeded");
|
||||
}
|
||||
|
||||
const cmd = process.argv[2];
|
||||
if (cmd === "dbs") {
|
||||
const dir = path.resolve(arg(3));
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
python(dir);
|
||||
console.log("[ui-preview] databases written to", dir);
|
||||
} else if (cmd === "api") {
|
||||
seedApi().catch((e) => { console.error(e); process.exit(1); });
|
||||
} else {
|
||||
console.error("usage: seed.mjs dbs <dir> | seed.mjs api <base-url> <dir> [--as <server path>]");
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
name: UI preview
|
||||
|
||||
# A pull request that changes the UI gets screenshots — and a screen
|
||||
# recording, the mouse drawn in, when the change needs clicking to see — of
|
||||
# the real app built from it, in a fixed block at the foot of its
|
||||
# description. See .github/ui-preview/.
|
||||
#
|
||||
# Pull requests come from forks, so this runs on pull_request_target with
|
||||
# the scripts from main. The PR's code is only built and run in the record
|
||||
# job, which holds the DeepSeek key (environment ui-preview, no approval
|
||||
# asked: any PR's code can read it, so the key there is one with a low
|
||||
# limit) and nothing that can write to the repo. The publish job, which can
|
||||
# write, never runs the PR's code.
|
||||
#
|
||||
# The backend the recording runs against comes in two lanes: a diff that
|
||||
# only touches the frontend (the usual case) runs against the released
|
||||
# dbx-web static binary — no Rust is built; a diff that reaches crates/,
|
||||
# plugins/ or src-tauri/ builds dbx-web from the PR itself. Either way the
|
||||
# PR's own frontend build is what's on screen (DBX_STATIC_DIR).
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr:
|
||||
description: Pull request number
|
||||
required: true
|
||||
|
||||
concurrency:
|
||||
group: ui-preview-${{ github.event.pull_request.number || inputs.pr }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
detect:
|
||||
if: github.event.action != 'labeled' || github.event.label.name == 'ui-preview'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
outputs:
|
||||
ui: ${{ steps.detect.outputs.ui }}
|
||||
lane: ${{ steps.detect.outputs.lane }}
|
||||
sha: ${{ steps.detect.outputs.sha }}
|
||||
number: ${{ steps.detect.outputs.number }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- id: detect
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR: ${{ github.event.pull_request.number || inputs.pr }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/ui-preview/publish.mjs detect
|
||||
# what the recorder reads of the PR, handed over so its job needs no token
|
||||
- if: steps.detect.outputs.ui == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PR: ${{ steps.detect.outputs.number }}
|
||||
run: |
|
||||
mkdir pr-info
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" -H "Accept: application/vnd.github.diff" > pr-info/pr.diff
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq .title > pr-info/title.txt
|
||||
gh api "repos/$GITHUB_REPOSITORY/pulls/$PR" --jq '.body // ""' > pr-info/body.txt
|
||||
- if: steps.detect.outputs.ui == 'true'
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: pr-info
|
||||
path: pr-info
|
||||
retention-days: 3
|
||||
|
||||
record:
|
||||
needs: detect
|
||||
if: needs.detect.outputs.ui == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: ${{ needs.detect.outputs.lane == 'rust' && 60 || 30 }}
|
||||
environment: ui-preview
|
||||
permissions:
|
||||
contents: read
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.pw
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
path: ci
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
# the fork's code, on purpose: this job is the one meant to run it,
|
||||
# with a read-only token and no credentials left in the checkout
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ needs.detect.outputs.sha }}
|
||||
path: pr
|
||||
persist-credentials: false
|
||||
allow-unsafe-pr-checkout: true
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: pr-info
|
||||
path: pr-info
|
||||
|
||||
# the frontend, from the PR, for both lanes
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: pnpm
|
||||
cache-dependency-path: pr/pnpm-lock.yaml
|
||||
- name: Build the PR's frontend
|
||||
working-directory: pr
|
||||
run: |
|
||||
pnpm install --frozen-lockfile || pnpm install
|
||||
pnpm build
|
||||
|
||||
# the backend: released static binary, or built from the PR
|
||||
- name: The released dbx-web (frontend-only diff)
|
||||
if: needs.detect.outputs.lane != 'rust'
|
||||
working-directory: ${{ github.workspace }}
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
mkdir -p backend
|
||||
gh release download --repo "$GITHUB_REPOSITORY" --pattern '*x64-browser-static.tar.gz' --dir backend --clobber
|
||||
tar xzf backend/*x64-browser-static.tar.gz -C backend
|
||||
echo "DBX_WEB_BIN=$(echo backend/*/dbx-web)" >> "$GITHUB_ENV"
|
||||
- name: dbx-web built from the PR (backend diff)
|
||||
if: needs.detect.outputs.lane == 'rust'
|
||||
working-directory: pr
|
||||
run: |
|
||||
cargo build --release -p dbx-web --no-default-features --features dbx-core/sqlite-bundled
|
||||
echo "DBX_WEB_BIN=pr/target/release/dbx-web" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Chromium, ffmpeg and fonts
|
||||
working-directory: ci/.github/ui-preview
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y ffmpeg fonts-noto-cjk fonts-noto-color-emoji
|
||||
npm install --no-audit --no-fund
|
||||
npx playwright install --with-deps chromium
|
||||
- name: Record
|
||||
env:
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
DIFF_FILE: ${{ github.workspace }}/pr-info/pr.diff
|
||||
PR_BODY_FILE: ${{ github.workspace }}/pr-info/body.txt
|
||||
DIST_DIR: ${{ github.workspace }}/pr/dist
|
||||
run: PR_TITLE="$(cat pr-info/title.txt)" ci/.github/ui-preview/run.sh out
|
||||
- if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ui-preview
|
||||
path: out
|
||||
retention-days: 7
|
||||
if-no-files-found: ignore
|
||||
|
||||
publish:
|
||||
needs: [detect, record]
|
||||
if: always() && needs.detect.outputs.ui == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/ui-preview
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
- uses: actions/download-artifact@v4
|
||||
continue-on-error: true
|
||||
with:
|
||||
name: ui-preview
|
||||
path: out
|
||||
- env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
PR: ${{ needs.detect.outputs.number }}
|
||||
SHA: ${{ needs.detect.outputs.sha }}
|
||||
OUT_DIR: out
|
||||
RECORD_RESULT: ${{ needs.record.result }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: node .github/ui-preview/publish.mjs publish
|
||||
Reference in New Issue
Block a user