History

Show URLs in e2e PR media

Rhys Sullivan committed 4 months ago

Commit 95b9e2a1 parent 59a17e11 file · +210 −8Browse files

Unified · Split

e2e/scripts/pr-media.ts+210−8
@@ -16,13 +16,17 @@ · 15 unmodified lines
1616 // *.png run screenshot, uploaded as-is
1717 import { execFileSync } from "node:child_process";
1818 import { createHash } from "node:crypto";
19−import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
19+import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
2020 import { tmpdir } from "node:os";
2121 import { basename, join, resolve } from "node:path";
2222
23+import { chromium } from "playwright";
24+
2325 const MEDIA_BRANCH = "e2e-media";
2426 // Above this GitHub's image proxy stops rendering the gif inline.
2527 const RENDER_LIMIT_BYTES = 10 * 1024 * 1024;
28+const GIF_WIDTH = 960;
29+const CHROME_HEIGHT = 40;
2630
2731 const run = (command: string, args: ReadonlyArray<string>): string =>
2832 execFileSync(command, [...args], {
@@ -36,6 +40,18 @@ interface Artifact { · 7 unmodified lines
3640 readonly path: string;
3741 readonly label: string;
3842 readonly slug: string;
43+ readonly runDir?: string;
44+}
45+
46+interface Timeline {
47+ readonly anchors: { readonly browser?: number };
48+ readonly nav?: ReadonlyArray<{ readonly at: number; readonly url: string }>;
49+}
50+
51+interface UrlSegment {
52+ readonly from: number;
53+ readonly to: number;
54+ readonly url: string;
3955 }
4056
4157 /** A run dir resolves to its recording; a file stands for itself. */
@@ -66,20 +82,206 @@ const resolveArtifact = (input: string): Artifact => { · 24 unmodified lines
6682 } catch {
6783 // skipped/partial runs still have a recording worth showing
6884 }
69− return { path: recording, label, slug: basename(path) };
85+ return { path: recording, label, slug: basename(path), runDir: path };
86+};
87+
88+const videoDuration = (file: string): number =>
89+ Number(
90+ run("ffprobe", [
91+ ...["-v", "quiet", "-show_entries", "format=duration"],
92+ ...["-of", "csv=p=0", file],
93+ ]).trim(),
94+ );
95+
96+const readTimeline = (artifact: Artifact): Timeline | null => {
97+ if (!artifact.runDir) return null;
98+ const timelinePath = join(artifact.runDir, "timeline.json");
99+ if (!existsSync(timelinePath)) return null;
100+ return JSON.parse(readFileSync(timelinePath, "utf8")) as Timeline;
101+};
102+
103+const urlSegments = (artifact: Artifact, duration: number): ReadonlyArray<UrlSegment> => {
104+ if (basename(artifact.path) !== "session.mp4") return [];
105+ const timeline = readTimeline(artifact);
106+ const browserAnchor = timeline?.anchors.browser;
107+ if (!timeline || browserAnchor === undefined) return [];
108+
109+ const nav = [...(timeline.nav ?? [])]
110+ .map((entry) => ({
111+ at: Math.max(0, (entry.at - browserAnchor) / 1000),
112+ url: entry.url,
113+ }))
114+ .filter((entry) => entry.at <= duration)
115+ .sort((a, b) => a.at - b.at);
116+
117+ if (nav.length === 0) return [{ from: 0, to: duration, url: "about:blank" }];
118+ if ((nav[0]?.at ?? 0) <= 0.25) nav[0] = { ...nav[0], at: 0 };
119+
120+ const segments: UrlSegment[] = [];
121+ let cursor = 0;
122+ let currentUrl = "about:blank";
123+ for (const entry of nav) {
124+ if (entry.at > cursor) segments.push({ from: cursor, to: entry.at, url: currentUrl });
125+ currentUrl = entry.url;
126+ cursor = entry.at;
127+ }
128+ segments.push({ from: cursor, to: duration, url: currentUrl });
129+
130+ return segments
131+ .filter((segment) => segment.to - segment.from > 0.01)
132+ .reduce<UrlSegment[]>((merged, segment) => {
133+ const previous = merged.at(-1);
134+ if (previous?.url === segment.url && Math.abs(previous.to - segment.from) < 0.01) {
135+ merged[merged.length - 1] = { ...previous, to: segment.to };
136+ } else {
137+ merged.push(segment);
138+ }
139+ return merged;
140+ }, []);
141+};
142+
143+const htmlEscape = (value: string): string =>
144+ value.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
145+
146+const renderUrlBars = async (
147+ segments: ReadonlyArray<UrlSegment>,
148+ workDir: string,
149+): Promise<ReadonlyArray<{ readonly path: string; readonly segment: UrlSegment }>> => {
150+ const browser = await chromium.launch();
151+ try {
152+ const page = await browser.newPage({
153+ viewport: { width: GIF_WIDTH, height: CHROME_HEIGHT },
154+ deviceScaleFactor: 1,
155+ });
156+ const bars: Array<{ path: string; segment: UrlSegment }> = [];
157+ for (const [index, segment] of segments.entries()) {
158+ const displayUrl = segment.url.replace(/^https?:\/\//, "") || "about:blank";
159+ await page.setContent(`<!doctype html>
160+<html>
161+ <head>
162+ <style>
163+ * { box-sizing: border-box; }
164+ body {
165+ margin: 0;
166+ width: ${GIF_WIDTH}px;
167+ height: ${CHROME_HEIGHT}px;
168+ background: #111318;
169+ color: #d7dde8;
170+ font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
171+ }
172+ .chrome {
173+ height: ${CHROME_HEIGHT}px;
174+ display: flex;
175+ align-items: center;
176+ gap: 10px;
177+ padding: 7px 12px;
178+ border-bottom: 1px solid #272c36;
179+ }
180+ .lights {
181+ display: flex;
182+ gap: 6px;
183+ flex: 0 0 auto;
184+ }
185+ .lights i {
186+ width: 10px;
187+ height: 10px;
188+ border-radius: 50%;
189+ display: block;
190+ }
191+ .lights i:nth-child(1) { background: #ff5f56; }
192+ .lights i:nth-child(2) { background: #ffbd2e; }
193+ .lights i:nth-child(3) { background: #27c93f; }
194+ .urlbar {
195+ min-width: 0;
196+ flex: 1 1 auto;
197+ height: 26px;
198+ display: flex;
199+ align-items: center;
200+ border: 1px solid #313846;
201+ border-radius: 6px;
202+ background: #0b0d12;
203+ padding: 0 10px;
204+ color: #d7dde8;
205+ font-size: 12px;
206+ line-height: 1;
207+ white-space: nowrap;
208+ overflow: hidden;
209+ text-overflow: ellipsis;
210+ }
211+ .lock {
212+ color: #8b95a7;
213+ margin-right: 7px;
214+ flex: 0 0 auto;
215+ }
216+ </style>
217+ </head>
218+ <body>
219+ <div class="chrome">
220+ <span class="lights"><i></i><i></i><i></i></span>
221+ <div class="urlbar"><span class="lock">url</span>${htmlEscape(displayUrl)}</div>
222+ </div>
223+ </body>
224+</html>`);
225+ const path = join(workDir, `urlbar-${index}.png`);
226+ await page.screenshot({ path, animations: "disabled" });
227+ bars.push({ path, segment });
228+ }
229+ return bars;
230+ } finally {
231+ await browser.close();
232+ }
70233 };
71234
72−const toGif = (artifact: Artifact): string => {
235+const toGif = async (artifact: Artifact): Promise<string> => {
73236 if (artifact.path.endsWith(".png")) return artifact.path;
74237 const out = join(tmpdir(), `${artifact.slug}.gif`);
75238 if (artifact.path.endsWith(".cast")) {
76239 run("agg", ["--idle-time-limit", "2", "--font-size", "14", artifact.path, out]);
77240 } else {
78241 // Two-pass palette keeps UI text crisp at a size GitHub will render.
79− const filter =
80− "fps=8,scale=960:-2:flags=lanczos,split[a][b];" +
81− "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5";
82− run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]);
242+ const duration = videoDuration(artifact.path);
243+ const segments = urlSegments(artifact, duration);
244+ if (segments.length > 0) {
245+ const workDir = mkdtempSync(join(tmpdir(), "pr-media-urlbar-"));
246+ try {
247+ const bars = await renderUrlBars(segments, workDir);
248+ const scaled =
249+ `[0:v]fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,` +
250+ `pad=iw:ih+${CHROME_HEIGHT}:0:${CHROME_HEIGHT}:color=0x111318,setsar=1[v0]`;
251+ const overlays = bars.map(({ segment }, index) => {
252+ const input = index + 1;
253+ const previous = `v${index}`;
254+ const next = `v${index + 1}`;
255+ const from = segment.from.toFixed(3);
256+ const to = segment.to.toFixed(3);
257+ return `[${previous}][${input}:v]overlay=0:0:enable='between(t,${from},${to})'[${next}]`;
258+ });
259+ const finalVideo = `v${bars.length}`;
260+ const palette =
261+ `[${finalVideo}]split[a][b];` +
262+ "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5[out]";
263+ run("ffmpeg", [
264+ "-y",
265+ "-loglevel",
266+ "error",
267+ "-i",
268+ artifact.path,
269+ ...bars.flatMap((bar) => ["-i", bar.path]),
270+ "-filter_complex",
271+ [scaled, ...overlays, palette].join(";"),
272+ "-map",
273+ "[out]",
274+ out,
275+ ]);
276+ } finally {
277+ rmSync(workDir, { recursive: true, force: true });
278+ }
279+ } else {
280+ const filter =
281+ `fps=8,scale=${GIF_WIDTH}:-2:flags=lanczos,split[a][b];` +
282+ "[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=5";
283+ run("ffmpeg", ["-y", "-loglevel", "error", "-i", artifact.path, "-vf", filter, out]);
284+ }
83285 }
84286 return out;
85287 };
@@ -152,7 +354,7 @@ const repo = run("gh", ["repo", "view", "--json", "nameWithOwner", "-q", ".nameW · 66 unmodified lines
152354 const blocks: Array<string> = [];
153355 for (const input of inputs) {
154356 const artifact = resolveArtifact(input);
155− const gif = toGif(artifact);
357+ const gif = await toGif(artifact);
156358 const size = statSync(gif).size;
157359 if (size > RENDER_LIMIT_BYTES) {
158360 console.error(