mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-08-10 17:27:10 +00:00
* feat(web-shell): auto-post visual previews (screenshots + flow GIFs) on PRs PRs that touch the web-shell UI now get an auto-updated comment with light/dark screenshots of key views (transcript, slash menu, model/theme dialogs, permission panel) and short GIF recordings of common flows, rendered against the existing mock daemon — no real backend, no secrets. Split into two workflows for security, since capture runs untrusted PR code: - web-shell-visuals.yml (pull_request): checks out the PR head, builds and renders it with Playwright, captures PNGs + webm, converts webm->GIF with ffmpeg, and uploads an artifact. `contents: read` only, references no secrets — fork PRs run with a read-only token and no secrets. - web-shell-visuals-publish.yml (workflow_run): downloads the artifact, binds it to its real PR by requiring the PR head SHA to equal the run's authenticated head SHA, hosts the images on a per-PR `pr-assets/*` branch (referenced by immutable commit SHA), and posts/updates one inline comment. Never checks out or runs PR code; the write token lives only here. Capture infra is self-contained in packages/web-shell (playwright.visuals.config.ts + client/e2e/visuals/*), reusing the mock daemon harness. Run locally with: `npm run test:e2e:visuals --workspace=packages/web-shell`. * fix(web-shell): guard empty gh api response in visuals publish Addresses review feedback on #6880: if `gh api` returns empty (network error / rate limit), jq on empty stdin errors and `set -e` kills the publish job. Skip gracefully instead. * fix(web-shell): address review nits on visuals capture - harness recordFlow: wrap video saveAs/delete in try/catch so a video I/O error (e.g. drive failed before navigation) can't mask the real driveError. - capture workflow: drop the unused head_sha.txt artifact field; the publish job binds to the authenticated workflow_run.head_sha, and an artifact-sourced SHA would be untrusted. * fix(web-shell): address second review round on visuals capture - context.close() in recordFlow's finally is now best-effort (try/catch) so a close/crash error can't mask the real driveError. - add a flows spec that asserts a throwing drive propagates its own error. - trigger the capture workflow on playwright.visuals.config.ts changes too. * fix(web-shell): address third review round on visuals capture - harness: log (don't silently swallow) a video save/null when drive succeeded; keep masking-suppression only when driveError is set. - publish: HTML-escape interpolated values in the comment builder (defense in depth, independent of the upstream filename sanitization); fix the stale 'single pr-assets branch' comment and key concurrency on source repo+branch so different PRs (incl. same-named fork branches) parallelize. - capture: bump checkout to v6.0.3 (repo standard); surface ffmpeg's stderr on GIF-conversion failure instead of discarding it. * fix(web-shell): harden visuals publish/capture (review round 4) Publish (privileged workflow_run): - CRITICAL: capture basename before `tr` so its trailing newline isn't turned into `_` (which broke the .png/.gif filter -> empty preview). - dedup only against the bot's OWN comment (author + marker), not any marker-bearing comment a participant can post. - bound the pr-assets branch: force-push a single orphan snapshot per run (previous snapshot GC'd) instead of appending unbounded untrusted content; this also removes the rebase/retry path. - cap EXAMINED candidates (not just accepted) before validation; tighten per-file (3MiB) and accepted-image (14) caps. - re-validate PR open + head-SHA immediately before the comment write (TOCTOU); retry the comment listing and abort rather than POST a duplicate when listing fails. - esc() the runUrl for consistency with the self-defending HTML. Capture (pull_request): - upload raw recordings as a SEPARATE artifact the publisher never downloads, so an untrusted multi-GB video can't exhaust the privileged job. - also trigger on packages/webui/src and packages/sdk-typescript/src (the visuals dev server aliases them). - create screenshots/gifs dirs before the metadata counts (defensive). Harness recordFlow: - track drive failure with an explicit boolean (handles `throw undefined`); discard the recording on failure so a failed flow leaves no bogus webm. * refactor(web-shell): extract + unit-test the visuals publish staging/comment Addresses the review's testability gap (the class of bug that let the filename sanitizer break the whole preview slip through green CI). The image validation (magic bytes, filename sanitization, examined/accepted/size caps) and the comment builder (light/dark pairing, flow labels, HTML escaping) move from inline workflow bash/node into .github/scripts/web-shell-visuals-publish .mjs, covered by web-shell-visuals-publish.test.mjs (run in ci.yml's node --test line). The publish workflow sparse-checks-out and calls the script instead. Behaviour is unchanged; it just gained a test surface. * fix(web-shell): retry the visuals asset force-push; drop stale comment Round-4 switched hosting to a force-push but left a comment referencing a 'push-retry loop' that no longer existed, and the force-push was a single call that set -e would abort on a transient failure. Add a bounded retry and correct the comment. * fix(web-shell): harden visuals publish/capture (review round 6) Script (unit-tested): - flow labels: own-property lookup so `toString.gif`/`constructor.gif` can't leak Object.prototype members into the comment. - per-kind image caps (screenshots vs gifs) so a large screenshot set can't silently starve the flow GIFs from the preview. - tests for both, plus the per-kind cap. Publish: - bind the artifact PR number to the run's authenticated head repo+branch (not just head SHA), rejecting a sibling PR that shares the same commit. - re-validate before the force-push and again right before the comment write (close the download/stage/lookup TOCTOU windows). Capture: - bound artifact contents before upload (drop oversized / excess files) so an untrusted spec can't bloat the published or video artifact. - trigger on the capture workflow file itself. - new close-trigger cleanup workflow deletes a PR's asset branch on close, so pr-assets/* refs don't accumulate without bound. - single-source the capture viewport (constants.ts) shared by config + harness. - model-switch flow asserts the daemon model request actually fired. * fix(web-shell): stricter visuals error handling (review round 7) Harness recordFlow: - when the drive SUCCEEDS, a failed context.close() or video.saveAs() (or a missing recording) now FAILS the flow instead of a swallowed console.warn — a silent pass with no .webm makes the downstream GIF step fail confusingly. A drive FAILURE still discards the partial video and rethrows the original error (unchanged). Publish: - validate_pr distinguishes a transient API failure (empty after retries -> exit 1, re-triggerable) from a genuine invalid state (closed / head mismatch -> skip), via a `gate` wrapper used at all three checkpoints. - add a 2s backoff between comment-listing retries (matching the push retry). --------- Co-authored-by: wenshao <wenshao@example.com>
282 lines
8.9 KiB
JavaScript
282 lines
8.9 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Staging + comment generation for the web-shell visuals publish workflow.
|
|
*
|
|
* Extracted from the inline workflow so the image validation and comment
|
|
* construction — the parts that consume UNTRUSTED PR output and were
|
|
* previously untested — have unit coverage. (A shell sanitizer bug once
|
|
* appended `_` to every filename and silently produced an empty preview; the
|
|
* pure functions here are covered by web-shell-visuals-publish.test.mjs.)
|
|
*
|
|
* The pure helpers (`sanitizeName`, `classifyMagic`, `selectImages`,
|
|
* `buildComment`) are exported and tested. The file also runs as a CLI for the
|
|
* workflow:
|
|
* node web-shell-visuals-publish.mjs stage <screenshotsDir> <gifsDir> <stageDir>
|
|
* node web-shell-visuals-publish.mjs comment <stageDir> <rawBase> <shortSha> <runUrl> <bodyFile>
|
|
*/
|
|
|
|
import {
|
|
closeSync,
|
|
copyFileSync,
|
|
mkdirSync,
|
|
openSync,
|
|
readdirSync,
|
|
readSync,
|
|
statSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { basename, join } from 'node:path';
|
|
import { pathToFileURL } from 'node:url';
|
|
|
|
// Bounds on UNTRUSTED artifact content: cap files EXAMINED (so a flood of junk
|
|
// can't burn the budget before valid files), files ACCEPTED, and per-file size.
|
|
export const MAX_CANDIDATES = 200;
|
|
export const MAX_SCREENSHOTS = 20;
|
|
export const MAX_GIFS = 6;
|
|
export const MAX_BYTES = 3 * 1024 * 1024;
|
|
|
|
const PNG_MAGIC = '89504e470d0a1a0a';
|
|
const GIF_MAGICS = new Set(['474946383961', '474946383761']); // GIF89a / GIF87a
|
|
|
|
const FLOW_LABELS = {
|
|
'model-switch': 'Open the slash menu and switch model',
|
|
'prompt-stream': 'Submit a prompt and watch the reply stream in',
|
|
};
|
|
|
|
/**
|
|
* Sanitize to the hosted-filename charset WITHOUT corrupting the extension.
|
|
* (The shell version captured `basename` through a pipe, turning its trailing
|
|
* newline into `_` and breaking the `.png`/`.gif` filter — this cannot.)
|
|
*/
|
|
export function sanitizeName(name) {
|
|
return String(name).replace(/[^A-Za-z0-9._-]/g, '_');
|
|
}
|
|
|
|
/** Classify by first-bytes magic hex → 'png' | 'gif' | null. */
|
|
export function classifyMagic(ext, magicHex) {
|
|
const hex = String(magicHex).toLowerCase();
|
|
if (ext === 'png') return hex.slice(0, 16) === PNG_MAGIC ? 'png' : null;
|
|
if (ext === 'gif') return GIF_MAGICS.has(hex.slice(0, 12)) ? 'gif' : null;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Pure selection over candidates `[{ name, ext, size, magic }]` (in order):
|
|
* apply the examined/accepted/size caps and magic validation. Returns
|
|
* `{ accepted: [{ name, safeName, kind }], warnings: string[] }`.
|
|
*/
|
|
export function selectImages(candidates, opts = {}) {
|
|
const maxCandidates = opts.maxCandidates ?? MAX_CANDIDATES;
|
|
const maxBytes = opts.maxBytes ?? MAX_BYTES;
|
|
// Per-kind caps so a large screenshot set can't starve the flow GIFs: a
|
|
// shared total cap over PNG-first candidates would let >=N screenshots
|
|
// silently drop every GIF from the preview.
|
|
const maxPerKind = {
|
|
png: opts.maxScreenshots ?? MAX_SCREENSHOTS,
|
|
gif: opts.maxGifs ?? MAX_GIFS,
|
|
};
|
|
const kindCount = { png: 0, gif: 0 };
|
|
const accepted = [];
|
|
const warnings = [];
|
|
let examined = 0;
|
|
for (const c of candidates) {
|
|
examined += 1;
|
|
if (examined > maxCandidates) {
|
|
warnings.push(`examined ${maxCandidates} candidate files; stopping`);
|
|
break;
|
|
}
|
|
if (c.size > maxBytes) {
|
|
warnings.push(`${c.name} exceeds ${maxBytes} bytes; skipping`);
|
|
continue;
|
|
}
|
|
const kind = classifyMagic(c.ext, c.magic);
|
|
if (!kind) {
|
|
warnings.push(`${c.name} is not a valid ${c.ext}; skipping`);
|
|
continue;
|
|
}
|
|
if (kindCount[kind] >= maxPerKind[kind]) {
|
|
warnings.push(
|
|
`reached the ${kind} cap (${maxPerKind[kind]}); skipping ${c.name}`,
|
|
);
|
|
continue;
|
|
}
|
|
kindCount[kind] += 1;
|
|
accepted.push({
|
|
name: c.name,
|
|
safeName: sanitizeName(basename(c.name)),
|
|
kind,
|
|
});
|
|
}
|
|
return { accepted, warnings };
|
|
}
|
|
|
|
/** Self-defending HTML escaping for interpolated values. */
|
|
export const esc = (s) =>
|
|
String(s)
|
|
.replace(/&/g, '&')
|
|
.replace(/"/g, '"')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>');
|
|
|
|
export const pretty = (s) =>
|
|
s.replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
/**
|
|
* Pure comment builder. `files` is the list of staged filenames (png + gif).
|
|
* `ctx` is `{ rawBase, shortSha, runUrl }`. Returns the markdown body.
|
|
*/
|
|
export function buildComment(files, ctx = {}) {
|
|
const rawBase = ctx.rawBase ?? '';
|
|
const shortSha = ctx.shortSha ?? '';
|
|
const runUrl = ctx.runUrl ?? '';
|
|
const url = (name) => `${rawBase}/${encodeURIComponent(name)}`;
|
|
|
|
const shots = files.filter((f) => /\.png$/i.test(f));
|
|
const views = new Map();
|
|
for (const f of shots) {
|
|
const m = f.match(/^(.*)-(light|dark)\.png$/i);
|
|
if (!m) continue;
|
|
const [, view, theme] = m;
|
|
const entry = views.get(view) || {};
|
|
entry[theme.toLowerCase()] = f;
|
|
views.set(view, entry);
|
|
}
|
|
const gifs = files.filter((f) => /\.gif$/i.test(f)).sort();
|
|
|
|
const out = [];
|
|
out.push('<!-- qwen:web-shell-visuals -->');
|
|
out.push('### 🖼️ web-shell visual preview');
|
|
out.push(
|
|
`Auto-rendered from this PR head \`${esc(shortSha)}\` against a mock daemon (no real backend). Refreshes on every push.`,
|
|
);
|
|
out.push('');
|
|
|
|
if (views.size > 0) {
|
|
out.push('#### Screenshots · light / dark');
|
|
out.push('');
|
|
out.push('<table>');
|
|
out.push('<tr><th align="left">view</th><th>light</th><th>dark</th></tr>');
|
|
for (const [view, pair] of [...views.entries()].sort()) {
|
|
const light = pair.light
|
|
? `<img src="${url(pair.light)}" width="360" alt="${esc(view)} light">`
|
|
: '—';
|
|
const dark = pair.dark
|
|
? `<img src="${url(pair.dark)}" width="360" alt="${esc(view)} dark">`
|
|
: '—';
|
|
out.push(
|
|
`<tr><td valign="top"><sub>${esc(pretty(view))}</sub></td><td>${light}</td><td>${dark}</td></tr>`,
|
|
);
|
|
}
|
|
out.push('</table>');
|
|
out.push('');
|
|
}
|
|
|
|
if (gifs.length > 0) {
|
|
out.push('#### Flows');
|
|
out.push('');
|
|
for (const g of gifs) {
|
|
const key = g.replace(/\.gif$/i, '');
|
|
// Own-property only: `FLOW_LABELS[key]` would otherwise inherit
|
|
// Object.prototype members, so a `toString.gif` would render the function
|
|
// source as the label.
|
|
const label = Object.hasOwn(FLOW_LABELS, key)
|
|
? FLOW_LABELS[key]
|
|
: pretty(key);
|
|
out.push(`**${esc(label)}**`);
|
|
out.push('');
|
|
out.push(`<img src="${url(g)}" width="640" alt="${esc(key)} flow">`);
|
|
out.push('');
|
|
}
|
|
}
|
|
|
|
if (runUrl) {
|
|
out.push(
|
|
`<sub>Full-resolution recordings (.webm) are attached to the <a href="${esc(runUrl)}">workflow run</a>.</sub>`,
|
|
);
|
|
}
|
|
out.push('');
|
|
out.push('— _Qwen Code · web-shell visuals_');
|
|
return out.join('\n') + '\n';
|
|
}
|
|
|
|
// --- I/O layer (exercised by the CLI; not part of the unit-tested surface) ---
|
|
|
|
function readMagicHex(path, n = 8) {
|
|
const fd = openSync(path, 'r');
|
|
try {
|
|
const buf = Buffer.alloc(n);
|
|
const read = readSync(fd, buf, 0, n, 0);
|
|
return buf.subarray(0, read).toString('hex');
|
|
} finally {
|
|
closeSync(fd);
|
|
}
|
|
}
|
|
|
|
function gatherCandidates(dir, ext) {
|
|
let names;
|
|
try {
|
|
names = readdirSync(dir);
|
|
} catch {
|
|
return [];
|
|
}
|
|
return names
|
|
.filter((n) => n.toLowerCase().endsWith(`.${ext}`))
|
|
.sort()
|
|
.map((n) => {
|
|
const path = join(dir, n);
|
|
let size = Infinity;
|
|
let magic = '';
|
|
try {
|
|
size = statSync(path).size;
|
|
magic = readMagicHex(path);
|
|
} catch {
|
|
// Unreadable entry: leave size=Infinity/magic='' so it is skipped.
|
|
}
|
|
return { name: n, ext, size, magic, path };
|
|
});
|
|
}
|
|
|
|
function stageCli(screenshotsDir, gifsDir, stageDir) {
|
|
const candidates = [
|
|
...gatherCandidates(screenshotsDir, 'png'),
|
|
...gatherCandidates(gifsDir, 'gif'),
|
|
];
|
|
const { accepted, warnings } = selectImages(candidates);
|
|
for (const w of warnings) process.stderr.write(`::warning::${w}\n`);
|
|
mkdirSync(stageDir, { recursive: true });
|
|
const byName = new Map(candidates.map((c) => [c.name, c.path]));
|
|
for (const a of accepted) {
|
|
copyFileSync(byName.get(a.name), join(stageDir, a.safeName));
|
|
}
|
|
// stdout = accepted count (the workflow reads it to decide whether to post).
|
|
process.stdout.write(`${accepted.length}\n`);
|
|
}
|
|
|
|
function commentCli(stageDir, rawBase, shortSha, runUrl, bodyFile) {
|
|
let files = [];
|
|
try {
|
|
files = readdirSync(stageDir);
|
|
} catch {
|
|
// Missing stage dir → empty preview body.
|
|
}
|
|
const body = buildComment(files, { rawBase, shortSha, runUrl });
|
|
writeFileSync(bodyFile, body);
|
|
process.stderr.write(`Comment body: ${body.split('\n').length} lines.\n`);
|
|
}
|
|
|
|
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
|
|
const [cmd, ...rest] = process.argv.slice(2);
|
|
if (cmd === 'stage') {
|
|
stageCli(rest[0], rest[1], rest[2]);
|
|
} else if (cmd === 'comment') {
|
|
commentCli(rest[0], rest[1], rest[2], rest[3], rest[4]);
|
|
} else {
|
|
process.stderr.write(`unknown command: ${cmd ?? '(none)'}\n`);
|
|
process.exit(2);
|
|
}
|
|
}
|