Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
3f60781
ci: report published package size and new runtime dependencies on eac…
cixzhang Oct 31, 2026
947e1ed
ci: write package sizes past 1 MB in MB
cixzhang Oct 31, 2026
4c9cfea
ci: per-suite ci:run-visual, ci:run-a11y and ci:run-rtl labels replac…
cixzhang Oct 31, 2026
207c6fa
ci: the PR report leads with one status table: this head's preview, s…
cixzhang Oct 31, 2026
d285fd0
Merge remote-tracking branch 'origin/ci/per-suite-run-labels' into ci…
cixzhang Oct 31, 2026
104373c
ci: the report's not-run sentence comes from run-labels.cjs
cixzhang Oct 31, 2026
b1dc73e
ci: the package budget is advisory; the merge-base lookups share one …
cixzhang Oct 31, 2026
1b07327
Merge branch 'ci/pack-size' into ci/pr-status-table
cixzhang Oct 31, 2026
a4d7aac
Merge the advisory package budget into the status table
cixzhang Oct 31, 2026
918fa69
ci: the status table reads suite jobs by display name, and a job's sk…
cixzhang Oct 31, 2026
197273f
ci: fold a skipped matrix's unexpanded job name into its job
cixzhang Oct 31, 2026
2b2736f
ci: the PR report follows the agreed mockup: preview first, one modif…
cixzhang Oct 31, 2026
6c19dca
ci: the frames step publishes only validated PNG/WebP frames to asset…
cixzhang Oct 31, 2026
219ccc5
ci: the pruner deletes only assets branches the PR report created (ma…
cixzhang Oct 31, 2026
a9980aa
Merge main (#9161 and #9165) into the PR report redesign
cixzhang Oct 31, 2026
7271897
ci: the PR report lists changed snapshots without thumbnails (frames …
cixzhang Oct 31, 2026
4557523
ci: the PR report shows before/after/diff thumbnails from frames a fr…
cixzhang Oct 31, 2026
077507f
Merge main (the compact PR report, landed) into ci/pr-report-frames
cixzhang Oct 31, 2026
b7c3bb4
ci: frames follow the report's own shot selection; drop the unused vi…
cixzhang Oct 31, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/scripts/generate-pr-comment.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

/**
* @description Generates the PR comment from the run's analysis results
* @input --analysis <file> --base-stats <file> --package-size <file> --a11y <file> --visual <file> --preview <file> --jobs <file> --source-conclusion <conclusion> --run-url <url>
* @input --analysis <file> --base-stats <file> --package-size <file> --a11y <file> --visual <file> --visual-frames <file> --preview <file> --jobs <file> --source-conclusion <conclusion> --run-url <url>
* @output Formatted markdown comment body to stdout, compact by design: this
* head's preview first, the modified components (preview links, size, lines
* of code, a mark per browser suite that ran; three rows visible, the rest
Expand Down Expand Up @@ -66,6 +66,8 @@ if (baseStats) {
const preview = readOptionalJson('preview');
const jobs = readOptionalJson('jobs');
const packageSize = readOptionalJson('package-size');
// The visual frames publish-frames.mjs put on this PR's assets branch.
const visualFrames = readOptionalJson('visual-frames');
const visualVerdict = readOptionalJson('visual');
let a11yReport = null;
try {
Expand Down Expand Up @@ -125,6 +127,7 @@ const report = buildCompactReport({
a11yReport,
visualVerdict,
packageSize,
visualFrames,
});
const footerLinksStr = runUrl
? ` | ${extLink('View full report', runUrl)}`
Expand Down
33 changes: 33 additions & 0 deletions .github/scripts/lib/pr-preview.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,35 @@ export async function headPreview({
: {state: 'unknown', sha};
}

/**
* The frames publish-frames.mjs published, written for the generator, only
* when their URL is this pull request's assets branch for this exact head and
* each frame is a `<before|after|diff>/<key>.png` path; otherwise null.
*/
export function trustedFrames({frames, identity, inputs}) {
const expected = `https://raw.githubusercontent.com/${identity.baseRepository}/assets/pr-${identity.prNumber}/visual/${identity.headSha}/`;
let published;
try {
published = JSON.parse(frames?.published || '[]');
} catch {
return null;
}
if (
frames?.imageBase !== expected ||
!Array.isArray(published) ||
published.length === 0 ||
!published.every(entry =>
/^(before|after|diff)\/(?!\.+\.png$)[A-Za-z0-9._-]{1,240}\.png$/.test(
String(entry),
),
)
)
return null;
const file = path.join(inputs, 'visual-frames.json');
fs.writeFileSync(file, JSON.stringify({imageBase: expected, published}));
return file;
}

/**
* The source run's jobs (latest attempt) with each step's conclusion, so the
* report can say which jobs ran and which skipped their work by path. Null
Expand Down Expand Up @@ -584,6 +613,7 @@ export async function reconcilePrComment({
metadataPath = 'pr-analysis/pr-meta.json',
baseStatsPath = 'pr-size-base/size-base.json',
packageSizePath = 'pr-analysis/package-size.json',
visualFrames = null,
a11yPath = 'a11y/a11y-report.json',
visualPath = 'trusted-visual/verdict.json',
createIfMissing = true,
Expand Down Expand Up @@ -648,6 +678,8 @@ export async function reconcilePrComment({
repo,
runId: identity.sourceRunId,
});
// Frames only from this PR's assets branch for this exact head.
const framesPath = trustedFrames({frames: visualFrames, identity, inputs});
const previewPath = path.join(inputs, 'preview.json');
const jobsPath = path.join(inputs, 'jobs.json');
if (preview) fs.writeFileSync(previewPath, JSON.stringify(preview));
Expand Down Expand Up @@ -676,6 +708,7 @@ export async function reconcilePrComment({
// checks, or with the ci:run-a11y label); the comment says so.
...(fs.existsSync(a11yPath) ? ['--a11y', a11yPath] : []),
...(fs.existsSync(visualPath) ? ['--visual', visualPath] : []),
...(framesPath ? ['--visual-frames', framesPath] : []),
...(preview ? ['--preview', previewPath] : []),
...(jobs ? ['--jobs', jobsPath] : []),
'--source-conclusion',
Expand Down
88 changes: 85 additions & 3 deletions .github/scripts/lib/pr-preview.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
confirmSourceRunIdentity,
PR_ANALYSIS_MARKER,
reconcilePrComment,
trustedFrames,
resolveWorkflowRunPullRequest,
validateAnalysisMetadata,
} from './pr-preview.mjs';
Expand Down Expand Up @@ -492,6 +493,7 @@ describe('trusted PR preview identity', () => {

expect(writeCapableJobs).toEqual([
'spec-only-reconcile',
'frames',
'comment',
'report',
]);
Expand Down Expand Up @@ -561,7 +563,13 @@ describe('trusted PR preview identity', () => {
describe('PR Comment keeps its outcome off main', () => {
// workflow_run attaches these jobs to main's latest commit, so a failing step
// here would turn main red for a pull request's problem.
const LANE = ['resolve', 'spec-only-reconcile', 'comment', 'report'];
const LANE = [
'resolve',
'spec-only-reconcile',
'frames',
'comment',
'report',
];
const OPTIONAL = new Set(['actions/download-artifact@v8']);

it.each(LANE)('never fails the run from the %s job', name => {
Expand All @@ -588,10 +596,20 @@ describe('PR Comment keeps its outcome off main', () => {

it('reports the outcome on the head of the run the event names', () => {
const report = PR_COMMENT_WORKFLOW.jobs.report;
expect(report.needs).toEqual(['resolve', 'spec-only-reconcile', 'comment']);
expect(report.needs).toEqual([
'resolve',
'spec-only-reconcile',
'frames',
'comment',
]);
const [step] = report.steps;
expect(step.env.HEAD_SHA).toBe('${{ github.event.workflow_run.head_sha }}');
for (const name of ['resolve', 'spec-only-reconcile', 'comment']) {
for (const name of [
'resolve',
'spec-only-reconcile',
'frames',
'comment',
]) {
expect(step.env.FAILED).toContain(
`needs.${name}.outputs.failed == 'true'`,
);
Expand Down Expand Up @@ -1358,3 +1376,67 @@ describe('PR comment preview reconciliation', () => {
},
);
});

describe('visual frames', () => {
const identity = {
prNumber: 9171,
headSha: 'a'.repeat(40),
baseRepository: 'meta/astryx',
};
const imageBase = `https://raw.githubusercontent.com/meta/astryx/assets/pr-9171/visual/${'a'.repeat(40)}/`;
const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'frames-'));

it("passes frames only from this pull request's assets branch for this head", () => {
const published = JSON.stringify(['before/b.png', 'diff/b.png']);
const file = trustedFrames({
frames: {imageBase, published},
identity,
inputs: tmp(),
});
expect(JSON.parse(fs.readFileSync(file, 'utf8'))).toEqual({
imageBase,
published: ['before/b.png', 'diff/b.png'],
});
for (const bad of [
imageBase.replace('pr-9171', 'pr-1'),
imageBase.replace('a'.repeat(40), 'b'.repeat(40)),
'https://evil.example/visual/',
])
expect(
trustedFrames({
frames: {imageBase: bad, published},
identity,
inputs: tmp(),
}),
).toBeNull();
});

it('refuses frame paths that are not before/after/diff PNGs', () => {
for (const published of [
'["../x.png"]',
'["other/b.png"]',
'[]',
'not json',
])
expect(
trustedFrames({
frames: {imageBase, published},
identity,
inputs: tmp(),
}),
).toBeNull();
});

it('gives only the frames job contents: write, with no persisted credentials', () => {
const {jobs} = PR_COMMENT_WORKFLOW;
for (const [name, job] of Object.entries(jobs))
expect(job.permissions?.contents === 'write', name).toBe(
name === 'frames',
);
const checkout = jobs.frames.steps.find(step =>
String(step.uses).startsWith('actions/checkout@'),
);
expect(checkout.with).toEqual({ref: 'main', 'persist-credentials': false});
expect(jobs.comment.needs).toEqual(['resolve', 'frames']);
});
});
48 changes: 39 additions & 9 deletions .github/scripts/lib/pr-status.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,14 +283,22 @@ function buildComponentTable({analysis, origin, a11yReport, visualVerdict}) {
}

/**
* The changed shots, largest change first: up to VISUAL_FRAME_LIMIT rows
* naming each shot and its change, inside one disclosure whose summary
* carries the counts. The run's visual report holds the frames.
* The changed shots, largest change first: up to VISUAL_FRAME_LIMIT rows,
* inside one disclosure whose summary carries the counts. With `frames`
* (publish-frames.mjs's `imageBase` and the `<kind>/<key>.png` it published),
* each row shows its before/after/diff thumbnails, and only published frames;
* without it, each row names its change.
*/
function buildVisualDeltas({verdict}) {
/**

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: the JSDoc just above (lines 285-291) describes buildVisualDeltas but now sits orphaned above visualShots's own, so buildVisualDeltas (line 314) has none. Moving it down, and adding the published frames and thumbnails to the file header's @input / @output, keeps the docs accurate.

* The changed shots in report order: changed by pixels changed, largest first,
* then added, then removed. The report lists the first VISUAL_FRAME_LIMIT,
* and publish-frames.mjs publishes frames for the same ones, so thumbnails
* and rows cannot drift apart.
*/
function visualShots(verdict) {
if (!verdict || verdict.status === 'skipped' || verdict.status === 'failed')
return '';
const all = [
return [];
return [
...[...(verdict.changes || [])]
.sort((a, b) => Number(b.diffPixels ?? 0) - Number(a.diffPixels ?? 0))
.map(change => ({...change, kinds: ['before', 'after', 'diff']})),
Expand All @@ -301,6 +309,10 @@ function buildVisualDeltas({verdict}) {
kinds: ['before'],
})),
];
}

function buildVisualDeltas({verdict, frames = null}) {
const all = visualShots(verdict);
if (all.length === 0) return '';
const rows = all.slice(0, VISUAL_FRAME_LIMIT);
const unshown = all.length - rows.length;
Expand All @@ -321,8 +333,24 @@ function buildVisualDeltas({verdict}) {
.join(', ');
return variant ? `${story} (${variant})` : story;
};
const header = '| Component · story | Change |\n|---|---|\n';
const line = row => `| ${label(row)} | ${changeText(row)} |`;
const published = new Set(frames?.published || []);
const base =
frames?.imageBase && published.size > 0
? `${String(frames.imageBase).replace(/\/+$/, '')}/`
: null;
const frame = (row, kind) => {
if (!row.kinds.includes(kind)) return '—';
const file = `${kind}/${row.key}.png`;
if (!published.has(file)) return 'not published';
return `<img src="${base}${kind}/${encodeURIComponent(String(row.key))}.png" height="120" alt="${kind} frame">`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a second Before / After / Diff renderer: lib/visual-format.js buildVisualSection already builds these <img> cells from an image base (lines 83-113), and after this change nothing calls it except its own test. Please keep one: extend buildVisualSection for the compact layout and call it here, or delete visual-format.js and its test.

};
const header = base
? '| Component · story | Before | After | Diff |\n|---|---|---|---|\n'
: '| Component · story | Change |\n|---|---|\n';
const line = row =>
base
? `| ${label(row)} | ${frame(row, 'before')} | ${frame(row, 'after')} | ${frame(row, 'diff')} |`
: `| ${label(row)} | ${changeText(row)} |`;
// A shot key is `<package>-<component>--<story>__<theme>-<mode>`.
const componentOf = row =>
String(
Expand Down Expand Up @@ -432,6 +460,7 @@ function buildCompactReport({
a11yReport = null,
visualVerdict = null,
packageSize = null,
visualFrames = null,
}) {
const origin =
preview?.state === 'ready' ? preview.url : preview?.aliasOrigin || '';
Expand All @@ -440,10 +469,11 @@ function buildCompactReport({
origin,
a11yReport,
visualVerdict,
})}${buildVisualDeltas({verdict: visualVerdict})}${packagesLine(packageSize)}${notRunLine({jobs, a11yReport, visualVerdict})}`;
})}${buildVisualDeltas({verdict: visualVerdict, frames: visualFrames})}${packagesLine(packageSize)}${notRunLine({jobs, a11yReport, visualVerdict})}`;
}

module.exports = {
visualShots,
SUITES,
VISUAL_FRAME_LIMIT,
baseJobName,
Expand Down
30 changes: 30 additions & 0 deletions .github/scripts/lib/pr-status.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,36 @@ describe('buildVisualDeltas', () => {
expect(section).toContain("3 more are in the run's visual report.");
});

it('shows thumbnails for the frames publish-frames.mjs published, and only those', () => {
const section = buildVisualDeltas({
verdict: {
status: 'changed',
changes: [change('b', 50), change('a', 1)],
added: ['core-dialog--default__neutral-light'],
},
frames: {
imageBase: 'https://raw.example/frames',
published: [
'before/b.png',
'after/b.png',
'diff/b.png',
'after/core-dialog--default__neutral-light.png',
],
},
});
expect(section).toContain('| Component · story | Before | After | Diff |');
expect(section).toContain(
'<img src="https://raw.example/frames/before/b.png" height="120" alt="before frame">',
);
expect(section).toContain(
'| Card · a (neutral, light) | not published | not published | not published |',
);
// An added shot has an after frame only.
expect(section).toContain(
'| — | <img src="https://raw.example/frames/after/core-dialog--default__neutral-light.png" height="120" alt="after frame"> | — |',
);
});

it('is absent without changes, or when the visual run failed', () => {
expect(buildVisualDeltas({verdict: {status: 'ok', changes: []}})).toBe('');
expect(buildVisualDeltas({verdict: null})).toBe('');
Expand Down
Loading
Loading