History

Embed e2e run recordings in PR descriptions

Rhys Sullivan committed 5 months ago

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

Unified · Split

2 files
  1. AGENTS.md +15 −0
  2. e2e/scripts/pr-media.ts +164 −0
AGENTS.md+15−0
@@ -19,6 +19,21 @@ attribution/trailers to commits, commit messages, PRs, or generated files. · 18 unmodified lines
19Pull request titles and descriptions are going to a public GitHub repo, so19Pull request titles and descriptions are going to a public GitHub repo, so
20avoid using specific names or internal info unless explicitly stated to.20avoid using specific names or internal info unless explicitly stated to.
21 21
22## Show Changes in PR Descriptions
23
24When a change is user-visible and an e2e scenario covers it, embed the run's
25recording in the PR description — reviewers should see the change, not just
26read about it.
27
28```
29bun e2e/scripts/pr-media.ts e2e/runs/<target>/<scenario-slug>
30```
31
32converts the run's recording (browser `session.mp4` or `terminal.cast`) to a
33gif, uploads it to the `e2e-media` branch, and prints PR-ready markdown to
34paste into the body. Run screenshots (`*.png`) can be passed directly too. If
35no scenario covers the change yet, that is usually the cue to write one.
36
22## Collaboration Notes37## Collaboration Notes
23 38
24The user uses speech to text occasionally, so if sentences are weird or words39The user uses speech to text occasionally, so if sentences are weird or words
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
16import { execFileSync } from "node:child_process";
17import { createHash } from "node:crypto";
18import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
19import { tmpdir } from "node:os";
20import { basename, join, resolve } from "node:path";
21
22const MEDIA_BRANCH = "e2e-media";
23// Above this GitHub's image proxy stops rendering the gif inline.
24const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;
25
26const run = (command: string, args: ReadonlyArray<string>): string =>
27 execFileSync(command, [...args], {
28 encoding: "utf8",
29 maxBuffer: 64 * 1024 * 1024,
30 });
31
32const ghJson = (args: ReadonlyArray<string>): unknown => JSON.parse(run("gh", ["api", ...args]));
33
34interface 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. */
41const 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
68const 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. */
84const 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
141const inputs = process.argv.slice(2);
142if (inputs.length === 0) {
143 console.error("usage: bun e2e/scripts/pr-media.ts <run-dir-or-artifact> [...more]");
144 process.exit(1);
145}
146
147const repo = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).trim();
148const blocks: Array<string> = [];
149for (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
164console.log(`\n${blocks.join("\n\n")}\n`);