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
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`);