History

Embed e2e run recordings in PR descriptions

Rhys Sullivan committed 5 months ago

Commit fc64f061 parent c4c306f2 files · +179 −0Browse files

Showing the changes to e2e/scripts (1 of 2 files). Show all

Unified · Split

e2e/scripts/pr-media.ts+164−0
@@ -0,0 +1,164 @@
1+// Turn an e2e run's recording into PR-ready markdown.
2+//
3+// GitHub only renders media inline from URLs it can fetch, and there is no
4+// API for the drag-and-drop user-asset upload — so this converts the run's
5+// recording to a gif (the one format that renders inline from a raw repo
6+// URL), commits it to the orphan `e2e-media` branch through the git database
7+// API (never touching the local worktree), and prints markdown to paste into
8+// the PR description.
9+//
10+// Usage: bun e2e/scripts/pr-media.ts <run-dir-or-artifact> [...more]
11+// run dir e2e/runs/<target>/<scenario-slug> — picks session.mp4 or
12+// terminal.cast and labels the gif from result.json
13+// session.mp4 browser recording (ffmpeg -> gif)
14+// terminal.cast terminal recording (agg -> gif; brew install agg)
15+// *.png run screenshot, uploaded as-is
16+import { execFileSync } from "node:child_process";
17+import { createHash } from "node:crypto";
18+import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
19+import { tmpdir } from "node:os";
20+import { basename, join, resolve } from "node:path";
21+
22+const MEDIA_BRANCH = "e2e-media";
23+// Above this GitHub's image proxy stops rendering the gif inline.
24+const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;
25+
26+const run = (command: string, args: ReadonlyArray<string>): string =>
27+ execFileSync(command, [...args], {
28+ encoding: "utf8",
29+ maxBuffer: 64 * 1024 * 1024,
30+ });
31+
32+const ghJson = (args: ReadonlyArray<string>): unknown => JSON.parse(run("gh", ["api", ...args]));
33+
34+interface Artifact {
35+ readonly path: string;
36+ readonly label: string;
37+ readonly slug: string;
38+}
39+
40+/** A run dir resolves to its recording; a file stands for itself. */
41+const resolveArtifact = (input: string): Artifact => {
42+ const path = resolve(input);
43+ if (!existsSync(path)) throw new Error(`no such path: ${input}`);
44+ if (!statSync(path).isDirectory()) {
45+ return {
46+ path,
47+ label: basename(path),
48+ slug: basename(path).replace(/\.[^.]+$/, ""),
49+ };
50+ }
51+ const recording = ["session.mp4", "terminal.cast"]
52+ .map((name) => join(path, name))
53+ .find(existsSync);
54+ if (!recording) throw new Error(`${input} has no session.mp4 or terminal.cast`);
55+ let label = basename(path);
56+ try {
57+ const result = JSON.parse(readFileSync(join(path, "result.json"), "utf8")) as {
58+ scenario?: string;
59+ target?: string;
60+ };
61+ if (result.scenario) label = `${result.scenario} (${result.target ?? "e2e"})`;
62+ } catch {
63+ // skipped/partial runs still have a recording worth showing
64+ }
65+ return { path: recording, label, slug: basename(path) };
66+};
67+
68+const toGif = (artifact: Artifact): string => {
69+ if (artifact.path.endsWith(".png")) return artifact.path;
70+ const out = join(tmpdir(), `${artifact.slug}.gif`);
71+ if (artifact.path.endsWith(".cast")) {
72+ run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]);
73+ } else {
74+ // Two-pass palette keeps UI text crisp at a size GitHub will render.
75+ const filter =
76+ "fps=8,scale=960:-2:flags=lanczos,split[a][b];" +
77+ "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5";
78+ run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]);
79+ }
80+ return out;
81+};
82+
83+/** Commit a file to the media branch via the git database API; returns its raw URL. */
84+const upload = (repo: string, localPath: string, mediaPath: string): string => {
85+ const bytes = readFileSync(localPath);
86+ const request = join(tmpdir(), `pr-media-blob-${process.pid}.json`);
87+ writeFileSync(request, JSON.stringify({ content: bytes.toString("base64"), encoding: "base64" }));
88+ const blob = ghJson([`repos/${repo}/git/blobs`, "--method", "POST", "--input", request]) as {
89+ sha: string;
90+ };
91+
92+ // Tip of the media branch, if it exists — the branch is orphan on first use.
93+ let parent: { commit: string; tree: string } | undefined;
94+ try {
95+ const ref = ghJson([`repos/${repo}/git/ref/heads/${MEDIA_BRANCH}`]) as {
96+ object: { sha: string };
97+ };
98+ const commit = ghJson([`repos/${repo}/git/commits/${ref.object.sha}`]) as {
99+ tree: { sha: string };
100+ };
101+ parent = { commit: ref.object.sha, tree: commit.tree.sha };
102+ } catch {
103+ parent = undefined;
104+ }
105+
106+ writeFileSync(
107+ request,
108+ JSON.stringify({
109+ ...(parent ? { base_tree: parent.tree } : {}),
110+ tree: [{ path: mediaPath, mode: "100644", type: "blob", sha: blob.sha }],
111+ }),
112+ );
113+ const tree = ghJson([`repos/${repo}/git/trees`, "--method", "POST", "--input", request]) as {
114+ sha: string;
115+ };
116+ writeFileSync(
117+ request,
118+ JSON.stringify({
119+ message: `Add ${mediaPath}`,
120+ tree: tree.sha,
121+ parents: parent ? [parent.commit] : [],
122+ }),
123+ );
124+ const commit = ghJson([`repos/${repo}/git/commits`, "--method", "POST", "--input", request]) as {
125+ sha: string;
126+ };
127+ writeFileSync(
128+ request,
129+ parent
130+ ? JSON.stringify({ sha: commit.sha })
131+ : JSON.stringify({ ref: `refs/heads/${MEDIA_BRANCH}`, sha: commit.sha }),
132+ );
133+ ghJson(
134+ parent
135+ ? [`repos/${repo}/git/refs/heads/${MEDIA_BRANCH}`, "--method", "PATCH", "--input", request]
136+ : [`repos/${repo}/git/refs`, "--method", "POST", "--input", request],
137+ );
138+ return `https://raw.githubusercontent.com/${repo}/${MEDIA_BRANCH}/${mediaPath}`;
139+};
140+
141+const inputs = process.argv.slice(2);
142+if (inputs.length === 0) {
143+ console.error("usage: bun e2e/scripts/pr-media.ts <run-dir-or-artifact> [...more]");
144+ process.exit(1);
145+}
146+
147+const repo = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).trim();
148+const blocks: Array<string> = [];
149+for (const input of inputs) {
150+ const artifact = resolveArtifact(input);
151+ const gif = toGif(artifact);
152+ const size = statSync(gif).size;
153+ if (size > RENDER_LIMIT_BYTES) {
154+ console.error(
155+ `warning: ${basename(gif)} is ${(size / 1024 / 1024).toFixed(1)} MB — GitHub will not render it inline; trim the scenario or lower fps`,
156+ );
157+ }
158+ const hash = createHash("sha256").update(readFileSync(gif)).digest("hex").slice(0, 8);
159+ const extension = gif.endsWith(".png") ? "png" : "gif";
160+ const url = upload(repo, gif, `${artifact.slug}-${hash}.${extension}`);
161+ blocks.push(`![${artifact.label}](${url})`);
162+}
163+
164+console.log(`\n${blocks.join("\n\n")}\n`);