Skip to content

Commit 9bd0d5f

Browse files
improve ci action renders
1 parent 90d331e commit 9bd0d5f

7 files changed

Lines changed: 159 additions & 33 deletions

File tree

scripts/ci/combine-render-images

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
#!/usr/bin/env bun
2+
// @ts-nocheck
23
const fs = require('fs');
34
const path = require('path');
45
const { chromium } = require('playwright');
@@ -17,7 +18,7 @@ const screenshotName = requiredEnv('SCREENSHOT_NAME');
1718
const fpsSamplesName = requiredEnv('FPS_SAMPLES_NAME');
1819
const sceneMetricsName = requiredEnv('SCENE_METRICS_NAME');
1920
const feCoverageName = requiredEnv('FE_COVERAGE_NAME');
20-
const renderImageScale = Number(process.env.COMBINED_RENDER_IMAGE_SCALE || 2);
21+
const renderImageScale = Number(process.env.COMBINED_RENDER_IMAGE_SCALE || 3);
2122
if (!Number.isFinite(renderImageScale) || renderImageScale <= 0) {
2223
console.error('COMBINED_RENDER_IMAGE_SCALE must be a positive number');
2324
process.exit(1);
@@ -34,10 +35,27 @@ const metricRow = name => [
3435
[`${name} metrics`, imagePath(`${sceneMetricsName}_${name}`)],
3536
[],
3637
];
37-
const screenshotCell = name => [
38-
name.replace(/_/g, ' '),
39-
imagePath(`${screenshotName}_${name}`),
40-
];
38+
const actionScreenshotPaths = name => {
39+
const prefix = `${screenshotName}_${name}_action_`;
40+
if (!fs.existsSync('/tmp')) { return []; }
41+
return fs.readdirSync('/tmp')
42+
.filter(file => file.startsWith(prefix) && file.endsWith('.png'))
43+
.sort((a, b) => {
44+
const aIndex = Number(a.slice(prefix.length, -4));
45+
const bIndex = Number(b.slice(prefix.length, -4));
46+
return aIndex - bIndex;
47+
})
48+
.map(file => path.join('/tmp', file));
49+
};
50+
const screenshotCell = name => {
51+
const actionPaths = actionScreenshotPaths(name);
52+
return [
53+
name.replace(/_/g, ' '),
54+
actionPaths.length > 0
55+
? actionPaths
56+
: imagePath(`${screenshotName}_${name}`),
57+
];
58+
};
4159
const rows = [
4260
metricRow('promo_xl'),
4361
metricRow('app'),
@@ -62,21 +80,26 @@ const escapeHtml = value => String(value)
6280
.replace(/</g, '&lt;')
6381
.replace(/>/g, '&gt;')
6482
.replace(/"/g, '&quot;');
65-
const cell = ([title, filePath]) => {
66-
if (!title && !filePath) {
83+
const imageTag = filePath => `<img src="${imageData(filePath)}" />`;
84+
const cell = ([title, filePaths]) => {
85+
if (!title && !filePaths) {
6786
return '<div class="empty"></div>';
6887
}
69-
if (!fs.existsSync(filePath)) {
88+
const paths = Array.isArray(filePaths) ? filePaths : [filePaths];
89+
const existingPaths = paths.filter(filePath => filePath && fs.existsSync(filePath));
90+
if (existingPaths.length === 0) {
91+
const missingPath = paths.find(Boolean);
7092
return `
7193
<figure class="missing">
7294
<figcaption>${escapeHtml(title)}</figcaption>
73-
<div>Missing ${escapeHtml(path.basename(filePath))}</div>
95+
<div>Missing ${escapeHtml(path.basename(missingPath))}</div>
7496
</figure>`;
7597
}
98+
const imageClass = existingPaths.length > 1 ? 'images multi-images' : 'images';
7699
return `
77100
<figure>
78101
<figcaption>${escapeHtml(title)}</figcaption>
79-
<img src="${imageData(filePath)}" />
102+
<div class="${imageClass}">${existingPaths.map(imageTag).join('')}</div>
80103
</figure>`;
81104
};
82105
const rowHtml = rows
@@ -105,6 +128,8 @@ const html = `<!doctype html>
105128
figure {
106129
margin: 0;
107130
padding: 12px;
131+
min-width: 0;
132+
overflow: auto;
108133
background: #ffffff;
109134
border: 1px solid #d0d7de;
110135
border-radius: 6px;
@@ -122,6 +147,17 @@ const html = `<!doctype html>
122147
height: auto;
123148
background: #ffffff;
124149
}
150+
.images {
151+
display: grid;
152+
gap: 8px;
153+
}
154+
.multi-images {
155+
grid-auto-flow: column;
156+
grid-auto-columns: minmax(0, 1fr);
157+
}
158+
.multi-images img {
159+
object-fit: contain;
160+
}
125161
.missing div {
126162
min-height: 160px;
127163
display: grid;

scripts/ci/render-env

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
: "${SCENE_METRICS_NAME:=scene_metrics}"
77
: "${CHOSEN_METRICS:=${SCENE_METRICS_NAME}_${CHOSEN_NAME}}"
88
: "${COMBINED_RENDER_IMAGE:=render_summary}"
9-
: "${COMBINED_RENDER_IMAGE_SCALE:=2}"
9+
: "${COMBINED_RENDER_IMAGE_SCALE:=3}"
1010
: "${FE_COVERAGE_NAME:=fe_coverage}"
1111
: "${SCENE_METRICS_HEADER:=epoch, FPS, Calls, Triangles, Points, Lines, Geometries, Textures, Objects, Meshes, Instanced meshes, commit sha}"
1212
: "${ARTIFACT_BRANCH:=ci-artifacts}"

scripts/ci/render-url-records

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ with open(sys.argv[1], encoding="utf-8") as f:
77

88
for entry in entries:
99
actions = entry.get("actions", [])
10+
roi = entry.get("roi", {})
1011
print("\034".join([
1112
entry["name"],
1213
entry["mode"],
1314
entry["url"],
1415
json.dumps(actions, separators=(",", ":")) if actions else "",
1516
entry.get("state", ""),
17+
json.dumps(roi, separators=(",", ":")) if roi else "",
1618
]))

scripts/ci/render-urls.json

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,28 @@
3232
{
3333
"fill": {
3434
"placeholder": "Filter...",
35-
"value": "Genesis"
35+
"value": "v1.8"
36+
}
37+
},
38+
{
39+
"fill": {
40+
"placeholder": "Filter...",
41+
"value": "v0"
3642
}
3743
},
3844
{
3945
"hover": {
4046
"class": "bp6-icon-cross"
4147
}
4248
}
43-
]
49+
],
50+
"roi": {
51+
"x": 575,
52+
"y": 430,
53+
"width": 275,
54+
"height": 400,
55+
"scale": 2
56+
}
4457
},
4558
{
4659
"name": "password_reset",

scripts/ci/run-playwright-render

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ valid_scene_metrics() {
9292
[ -n "$row" ] && [ "$row" != "undefined" ] && [ "$row" != "null" ]
9393
}
9494

95-
while IFS=$'\034' read -r -u 3 name mode url actions state; do
95+
while IFS=$'\034' read -r -u 3 name mode url actions state roi; do
9696
screenshot_path="/tmp/${SCREENSHOT_NAME}_${name}.png"
9797
fps_samples_path="/tmp/${FPS_SAMPLES_NAME}_${name}.csv"
9898
scene_metrics_path="/tmp/${SCENE_METRICS_NAME}_${name}.csv"
@@ -106,6 +106,7 @@ while IFS=$'\034' read -r -u 3 name mode url actions state; do
106106
)
107107
[ -n "$actions" ] && fps_args+=(--actions "$actions")
108108
[ -n "$state" ] && fps_args+=(--state "$state")
109+
[ -n "$roi" ] && fps_args+=(--roi "$roi")
109110

110111
echo -e "\n\n\n"
111112
if [ "$mode" = "screenshot" ]; then

scripts/ci/test_python_scripts.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,29 @@ def test_render_url_records_outputs_field_separated_records(self):
119119
"promo\034fps\034http://localhost:3000/promo\034"
120120
"[{\"click\":{\"title\":\"Run\"}},"
121121
"{\"fill\":{\"placeholder\":\"Filter...\",\"value\":\"Genesis\"}},"
122-
"{\"hover\":{\"classname\":\"item\"}}]\034app\n")
122+
"{\"hover\":{\"classname\":\"item\"}}]\034app\034\n")
123+
self.assertEqual(stderr, "")
124+
125+
def test_render_url_records_outputs_roi_when_present(self):
126+
with tempfile.NamedTemporaryFile("w", delete=False) as file:
127+
json.dump([{
128+
"name": "dropdown",
129+
"mode": "screenshot",
130+
"url": "http://localhost:3000/demo",
131+
"roi": {"x": 1, "y": 2, "width": 3, "height": 4},
132+
}], file)
133+
file_path = file.name
134+
try:
135+
code, stdout, stderr = run_script(
136+
"render-url-records", [file_path])
137+
finally:
138+
Path(file_path).unlink(missing_ok=True)
139+
140+
self.assertEqual(code, 0)
141+
self.assertEqual(
142+
stdout,
143+
"dropdown\034screenshot\034http://localhost:3000/demo\034\034\034"
144+
"{\"x\":1,\"y\":2,\"width\":3,\"height\":4}\n")
123145
self.assertEqual(stderr, "")
124146

125147
def test_create_compare_link_uses_latest_deployment_sha(self):

scripts/fps.js

Lines changed: 70 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ function parseArgs(argv) {
1717
'--fps-samples-path': 'samplesCsvPath',
1818
'--actions': 'actions',
1919
'--state': 'state',
20+
'--roi': 'roi',
2021
};
2122

2223
for (let i = 0; i < argv.length; i++) {
@@ -56,6 +57,15 @@ const openWindow = Boolean(process.env.DISPLAY && process.env.OPEN_WINDOW);
5657
const executablePath = process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
5758
const screenshotOnly = options.screenshotOnly;
5859
const actions = options.actions ? JSON.parse(options.actions) : [];
60+
const roi = options.roi ? JSON.parse(options.roi) : undefined;
61+
const roiScale = (() => {
62+
if (!roi) { return undefined; }
63+
const value = Number(roi.scale || 2);
64+
if (!Number.isFinite(value) || value <= 0) {
65+
throw new Error(`ROI scale must be a positive number: ${JSON.stringify(roi)}`);
66+
}
67+
return value;
68+
})();
5969
const state = options.state ? path.join('/tmp', `${options.state}.json`) : undefined;
6070
const saveState = path.join('/tmp', `${name}.json`);
6171
const commitSha = () => {
@@ -108,6 +118,7 @@ function printUsage() {
108118
' --screenshot-only Take a screenshot without FPS metrics.',
109119
' --actions <json> Perform ordered actions after page load.',
110120
' --state <name> Load cookies and localStorage from /tmp/<name>.json.',
121+
' --roi <json> Crop screenshots to {x,y,width,height}.',
111122
'',
112123
'Environment:',
113124
' CI Use CI browser launch behavior.',
@@ -206,6 +217,50 @@ async function performAction(page, action) {
206217
throw new Error(`Unknown action: ${JSON.stringify(action)}`);
207218
}
208219

220+
function screenshotOptions(destination) {
221+
const screenshot = {
222+
path: destination,
223+
fullPage: true,
224+
timeout: 60_000,
225+
};
226+
if (!roi) { return screenshot; }
227+
228+
const clip = {};
229+
for (const key of ['x', 'y', 'width', 'height']) {
230+
const value = Number(roi[key]);
231+
if (!Number.isFinite(value)) {
232+
throw new Error(`ROI ${key} must be a finite number: ${JSON.stringify(roi)}`);
233+
}
234+
clip[key] = value;
235+
}
236+
if (clip.width <= 0 || clip.height <= 0 || clip.x < 0 || clip.y < 0) {
237+
throw new Error(`ROI must have non-negative x/y and positive width/height: ${JSON.stringify(roi)}`);
238+
}
239+
delete screenshot.fullPage;
240+
screenshot.clip = clip;
241+
return screenshot;
242+
}
243+
244+
async function saveScreenshot(page, destination, label) {
245+
fs.mkdirSync(path.dirname(destination), { recursive: true });
246+
await page.screenshot(screenshotOptions(destination));
247+
console.log(`${label}=${destination}`);
248+
}
249+
250+
function actionScreenshotPath(index) {
251+
const extension = path.extname(screenshotPath);
252+
const basePath = extension
253+
? screenshotPath.slice(0, -extension.length)
254+
: screenshotPath;
255+
return `${basePath}_action_${index + 1}${extension}`;
256+
}
257+
258+
async function performScreenshotAction(page, action, destination) {
259+
await performAction(page, action);
260+
await page.waitForTimeout(1000);
261+
await saveScreenshot(page, destination, 'ACTION_SCREENSHOT');
262+
}
263+
209264
async function main() {
210265
console.log(`Launching Chromium with args:\n ${chromiumArgs.join('\n ')}\n`);
211266
if (executablePath) {
@@ -216,7 +271,10 @@ async function main() {
216271
executablePath,
217272
args: chromiumArgs,
218273
});
219-
const contextOptions = state ? { storageState: state } : {};
274+
const contextOptions = {
275+
...(state ? { storageState: state } : {}),
276+
...(roiScale ? { deviceScaleFactor: roiScale } : {}),
277+
};
220278
if (state) {
221279
console.log(`STATE=${state}`);
222280
}
@@ -227,18 +285,18 @@ async function main() {
227285
try {
228286
await page.goto(url, { waitUntil: 'domcontentloaded' });
229287
await prepareStressResources(page, url);
230-
for (const action of actions) {
231-
await performAction(page, action);
288+
for (const [index, action] of actions.entries()) {
289+
if (screenshotOnly) {
290+
await performScreenshotAction(page, action, actionScreenshotPath(index));
291+
} else {
292+
await performAction(page, action);
293+
}
232294
}
233295
if (screenshotOnly) {
234-
await page.waitForTimeout(1000);
235-
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
236-
await page.screenshot({
237-
path: screenshotPath,
238-
fullPage: true,
239-
timeout: 60_000,
240-
});
241-
console.log(`SCREENSHOT=${screenshotPath}`);
296+
if (actions.length === 0) {
297+
await page.waitForTimeout(1000);
298+
await saveScreenshot(page, screenshotPath, 'SCREENSHOT');
299+
}
242300
return;
243301
}
244302
const renderer = await webglRenderer(page);
@@ -307,13 +365,7 @@ async function main() {
307365
throw new Error('window.__scene_metrics was not available');
308366
}
309367
console.log(`SCENE_METRICS=${data}`);
310-
fs.mkdirSync(path.dirname(screenshotPath), { recursive: true });
311-
await page.screenshot({
312-
path: screenshotPath,
313-
fullPage: true,
314-
timeout: 60_000,
315-
});
316-
console.log(`FPS_SCREENSHOT=${screenshotPath}`);
368+
await saveScreenshot(page, screenshotPath, 'FPS_SCREENSHOT');
317369
saveFpsSamplesCsv(sampleValues, samplesCsvPath);
318370
console.log(`FPS_SAMPLES_CSV=${samplesCsvPath}`);
319371
await saveStorage(page);

0 commit comments

Comments
 (0)