mirror of
https://github.com/expo/explore.git
synced 2026-11-14 17:51:38 -10:00
973 lines
87 KiB
JavaScript
973 lines
87 KiB
JavaScript
import assert from 'node:assert/strict';
|
||
import test from 'node:test';
|
||
import { renderPublicComment, platformRecordOf, PUBLIC_COMMENT_BODY_BUDGET } from '../../dist/report/pr-comment-body.js';
|
||
import { parsePlatformRecord } from '../../dist/report/pr-comment-record.js';
|
||
import { CI_COMMENT_MARKER } from '../../dist/report/pr-comment.js';
|
||
import { previewLinks, viewerLinks } from '../../dist/report/viewer-links.js';
|
||
|
||
const baseSha = 'a'.repeat(40);
|
||
const headSha = 'b'.repeat(40);
|
||
const artifactUrl = 'https://github.com/expo/explore-playground/actions/runs/42/artifacts/7654';
|
||
const pullRequest = {
|
||
repositoryId: '1', repository: 'expo/explore-playground', number: 1,
|
||
title: 'Add cats care guide, retire Sweet Bee, refresh share label', url: 'https://github.com/expo/explore-playground/pull/1',
|
||
base: { ref: 'main', sha: baseSha }, head: { ref: 'pilot/viewer-coverage', sha: headSha },
|
||
};
|
||
const image = { sha256: 'c'.repeat(64), width: 1179, height: 2556, capturedAt: '2026-09-11T03:05:00Z', buildId: 'eas-build' };
|
||
const entry = (id, route, kind = 'screen') => ({ id, route, routeFile: `src/app/${id}.tsx`, sourceFiles: [], provider: 'expo-router', logicalPath: route.slice(1) || 'index', path: route, variant: 'default', kind, ...(kind === 'redirect' ? { redirectTo: '/home' } : {}) });
|
||
function sourceChange(summary, impact = 'direct') {
|
||
return { contract: 'expo.explore.source-change-summary.v1', baseSha, headSha, impact, summary, files: [], truncated: false };
|
||
}
|
||
function surface(id, status, options = {}) {
|
||
const route = options.route ?? `/${id}`;
|
||
const kind = options.kind ?? 'screen';
|
||
const base = status === 'added' ? null : entry(id, route, kind);
|
||
const head = status === 'removed' ? null : entry(id, route, kind);
|
||
const withImages = kind !== 'redirect';
|
||
return {
|
||
id, status, base, head,
|
||
...(withImages && base ? { baseImage: { ...image, url: `https://cdn.convex.cloud/api/storage/base-${id}`, sourceCommit: baseSha }, baseOutcome: { status: 'captured', landing: 'verified', attempts: 1 } } : {}),
|
||
...(withImages && head ? { headImage: { ...image, url: `https://cdn.convex.cloud/api/storage/head-${id}`, sourceCommit: headSha }, headOutcome: { status: 'captured', landing: 'verified', attempts: 1 } } : {}),
|
||
...options.extra,
|
||
};
|
||
}
|
||
function review(surfaces, options = {}) {
|
||
return {
|
||
contract: 'explore.review', pullRequest, mode: options.mode ?? 'changes', createdAt: '2026-09-11T03:07:00Z',
|
||
runId: '42', runAttempt: '2',
|
||
runtime: { projectId: 'project', buildId: 'eas-build', gitCommit: headSha, appName: 'Example', appVersion: '1.4.0', appBuildNumber: '27' },
|
||
surfaces,
|
||
};
|
||
}
|
||
const evidenceReview = () => review([
|
||
surface('care-guide', 'added', { route: '/cats/care-guide', extra: { review: { status: 'added', description: 'Added' } } }),
|
||
surface('sweet-bee', 'removed', { route: '/cats/sweet-bee', extra: { review: { status: 'removed', description: 'Removed' } } }),
|
||
surface('settings', 'modified', { extra: {
|
||
review: { status: 'modified', description: 'Changed button title from “Share” to “Share Example”.', facts: [{ category: 'visible-text', templateId: 'visible-text.changed', bullet: 'Changed button title from “Share” to “Share Example”.' }] },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage: 3.2, threshold: 0.1, tool: '[email protected]' },
|
||
sourceChange: sourceChange('Renamed the share label in settings-screen.tsx.'),
|
||
} }),
|
||
surface('cats', 'modified', { extra: {
|
||
review: { status: 'unchanged', description: 'No visible change' },
|
||
visual: { verdict: 'unchanged', reason: 'matched', mismatchPercentage: 0, threshold: 0.1, tool: '[email protected]' },
|
||
sourceChange: sourceChange('Shared component recapture.', 'dependency'),
|
||
} }),
|
||
surface('home', 'unchanged', { kind: 'redirect' }),
|
||
]);
|
||
function state(overrides = {}) {
|
||
const links = viewerLinks({ artifactUrl, repository: pullRequest.repository, headSha, runId: '42', runAttempt: '2', configPath: '.expo-explore/config.yaml' });
|
||
return {
|
||
phase: 'ready', pullRequest, mode: 'changes', executionMode: 'deterministic', configPath: '.expo-explore/config.yaml', links,
|
||
startedAt: Date.parse('2026-09-11T03:00:48Z'), updatedAt: Date.parse('2026-09-11T03:07:00Z'),
|
||
build: { appName: 'Example', appVersion: '1.4.0', appBuildNumber: '27', platform: 'ios', easProfile: 'explore', easBuildId: 'eas-build', reusedEasBuild: true },
|
||
runtime: { provider: 'eas-simulator', controller: 'agent-device', controllerPackageVersion: '0.20.2', easCliVersion: '25.2.0', platform: 'ios', deviceModel: 'iPhone 17', osVersion: '26.5', locale: 'en-US', orientation: 'portrait', colorScheme: 'light', sessionLifecycle: 'stopped' },
|
||
capture: { planned: { base: 3, head: 3 }, done: { base: 3, head: 3 }, baseFromCache: true },
|
||
review: evidenceReview(),
|
||
evidence: {
|
||
visual: { compared: 2, modified: 1, unchanged: 1, needsReview: 0, failed: 0, errors: [], threshold: 0.1, tool: '[email protected]' },
|
||
accessibility: { compared: 2, described: 1, failed: 0, skipped: 0, errors: [] },
|
||
},
|
||
inventory: { routes: 54 },
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
/** The comment for a review with no capture evidence or runtime details, as a republish would render it. */
|
||
function reviewOnlyState(review) {
|
||
const now = Date.parse(review.createdAt);
|
||
const count = (predicate) => ({ base: review.surfaces.filter((entry) => predicate(entry, 'base')).length,
|
||
head: review.surfaces.filter((entry) => predicate(entry, 'head')).length });
|
||
return {
|
||
phase: 'ready', pullRequest: review.pullRequest, mode: review.mode, executionMode: 'deterministic',
|
||
configPath: '.expo-explore/config.yaml', links: state().links, startedAt: now, updatedAt: now,
|
||
build: { appName: review.runtime.appName, appVersion: review.runtime.appVersion, appBuildNumber: review.runtime.appBuildNumber,
|
||
platform: 'ios', easProfile: '', easBuildId: review.runtime.buildId },
|
||
capture: { planned: count((entry, side) => entry[`${side}Image`] || entry[`${side}Outcome`]),
|
||
done: count((entry, side) => entry[`${side}Image`]), baseFromCache: false },
|
||
review,
|
||
};
|
||
}
|
||
|
||
const visible = body => body.split('\n').filter(line => !line.startsWith('<!-- expo-explore-record') && !line.startsWith('<!-- expo-explore-previous') && !line.startsWith('<!-- expo-explore-history')).join('\n');
|
||
const summaries = body => [...body.matchAll(/<summary>([^<]+)<\/summary>/g)].map(match => match[1]);
|
||
const titleOf = body => body.split('\n').find(line => line.startsWith('### '));
|
||
/** The `<sub>` two lines under the heading; the footer is the last line. */
|
||
const subtitleOf = body => { const lines = body.split('\n'); return lines[lines.findIndex(line => line.startsWith('### ')) + 2]; };
|
||
const statusOf = (body, label) => body.split('\n').find(line => line.startsWith(`${label} · `) || line.startsWith(`**${label} failed**`));
|
||
/** The changed-screen rows: the list before any collapsed block. */
|
||
const listOf = body => visible(body).split('\n<details>')[0].split('\n').filter(line => line.startsWith('- '));
|
||
const runLink = '[run](<https://github.com/expo/explore-playground/actions/runs/42/attempts/2>)';
|
||
const workflowRun = '[Workflow run](<https://github.com/expo/explore-playground/actions/runs/42/attempts/2>)';
|
||
const evidenceLink = '[evidence](<https://github.com/expo/explore-playground/actions/runs/42#artifacts>)';
|
||
const reportUrl = 'https://explore-preview.expo.app/?repo=expo%2Fexplore-playground&run=42&attempt=2&artifact=7654';
|
||
const commits = `[bbbbbbbb](<https://github.com/expo/explore-playground/commit/${headSha}>) vs [aaaaaaaa](<https://github.com/expo/explore-playground/commit/${baseSha}>)`;
|
||
const configLink = `[Config](<https://github.com/expo/explore-playground/blob/${headSha}/.expo-explore/config.yaml>)`;
|
||
|
||
test('the ready comment is a heading with the count and the report, a subtitle, the changed screens, and a footer', () => {
|
||
const body = renderPublicComment(state({ artifactOnly: true }));
|
||
const lines = body.split('\n');
|
||
assert.equal(lines[0], CI_COMMENT_MARKER);
|
||
assert.match(lines[1], /^<!-- expo-explore-record:ios:[A-Za-z0-9_-]+ -->$/);
|
||
assert.equal(lines[2], `### 3 screens changed · [Review in Explore](<${reportUrl}>)`, 'the report link is the heading, where a phone shows it first');
|
||
assert.equal(lines[4], `<sub>[iOS](<${reportUrl}>) · ${commits} · updated Sep 11, 3:07 AM UTC</sub>`);
|
||
assert.deepEqual(lines.slice(5, 10), ['',
|
||
`- Added [\`/cats/care-guide\`](<${reportUrl}&view=compare&screen=care-guide>)`,
|
||
`- Removed [\`/cats/sweet-bee\`](<${reportUrl}&view=compare&screen=sweet-bee>)`,
|
||
`- [\`/settings\`](<${reportUrl}&view=compare&screen=settings>) · Changed button title from “Share” to “Share Example”`, '']);
|
||
assert.equal(lines.at(-1), `<sub>${workflowRun} · ${configLink}</sub>`, 'the run and the config sit in the footer');
|
||
assert.equal(lines.length, 11);
|
||
assert.deepEqual(summaries(body), []);
|
||
assert.doesNotMatch(body, /<img|\*\*Changes\*\*|Validation|Capture issues|🟩|🟧|🟨|Managed via|zip|6m 12s|iPhone 17|Example 1|Unchanged|screen=cats|\/home|Reviewed at|Open Explore|Download report|View run|^\|/m);
|
||
// Hidden state is the only thing the comment carries besides markdown.
|
||
assert.equal(lines.filter(line => line.startsWith('<!--')).length, 2);
|
||
});
|
||
|
||
test('report links appear only once a report exists; a failed target says so with its run and evidence', () => {
|
||
const beforeUpload = viewerLinks({ repository: pullRequest.repository, headSha, runId: '42', runAttempt: '2', configPath: '.expo-explore/config.yaml' });
|
||
const ready = renderPublicComment(state({ links: beforeUpload }));
|
||
assert.equal(titleOf(ready), '### 3 screens changed', 'before the artifact exists there is nothing to open');
|
||
assert.match(subtitleOf(ready), /^<sub>iOS · \[bbbbbbbb\]/);
|
||
assert.match(ready, /\n- Added `\/cats\/care-guide`\n/);
|
||
assert.doesNotMatch(ready, /\[zip\]|\[Open\]|Review in Explore|explore-preview/);
|
||
const failed = renderPublicComment(state({ phase: 'failed', review: undefined, evidence: undefined, failure: { step: 'capture-head', message: 'head capture did not complete; token=abc123 leaked' } }));
|
||
assert.equal(titleOf(failed), '### Explore review · capture failed');
|
||
assert.equal(statusOf(failed, 'iOS'), `**iOS failed** · capture\\-head · head capture did not complete; token=\\[redacted\\] leaked · ${runLink} · ${evidenceLink}`);
|
||
assert.deepEqual(listOf(failed), []);
|
||
assert.doesNotMatch(failed, /\[Open\]|\[zip\]|abc123|Review in Explore|Validation/);
|
||
});
|
||
|
||
test('a secondary appearance surface (<route>@<scheme>) is listed with its scheme and deep-linked like any other route', () => {
|
||
const dark = surface('settings@dark', 'added', { route: '/settings', extra: { colorScheme: 'dark', review: { status: 'added', description: 'Added the dark appearance of /settings.' } } });
|
||
const body = renderPublicComment(state({ review: review([dark]) }));
|
||
assert.match(body, /\n- Added \[`\/settings`\]\(<[^>]+&view=compare&screen=settings%40dark>\) \(dark\)\n/, 'an added screen needs no description beyond its route');
|
||
});
|
||
|
||
test('agent notes replace descriptions without changing counts or the rest of the comment', () => {
|
||
const original = evidenceReview();
|
||
const annotated = structuredClone(original);
|
||
for (const [index, text] of [[0, 'Added care guidance and a daily reminder action.'], [1, 'Removed the Sweet Bee activity screen.'], [2, 'The settings share action now reads “Share Example”.']]) {
|
||
annotated.surfaces[index].note = { text, evidenceDigest: 'd'.repeat(64), evidence: [] };
|
||
}
|
||
const before = visible(renderPublicComment(state({ review: original })));
|
||
const after = visible(renderPublicComment(state({ review: annotated })));
|
||
assert.equal(titleOf(after), titleOf(before));
|
||
assert.equal(subtitleOf(after), subtitleOf(before));
|
||
assert.equal(after.split('\n').at(-1), before.split('\n').at(-1));
|
||
assert.equal(listOf(after).length, listOf(before).length);
|
||
assert.match(after, /\n- Added \[`\/cats\/care-guide`\]\(<[^>]+>\) · Added care guidance and a daily reminder action\n/, 'a note on an added screen follows the route');
|
||
assert.match(after, /\n- \[`\/settings`\]\(<[^>]+>\) · The settings share action now reads “Share Example”\n/);
|
||
assert.doesNotMatch(after, /Changed button title from|evidenceDigest|Model|Tokens/);
|
||
});
|
||
|
||
test('the list shows ten screens and links the rest; the comment stays under GitHub\'s limit', () => {
|
||
const screens = (count, description) => review(Array.from({ length: count }, (_, index) => surface(`screen-${index}`, 'modified', { extra: {
|
||
review: { status: 'modified', description: description(index) },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage: 1, threshold: 0.1, tool: '[email protected]' },
|
||
} })));
|
||
const body = renderPublicComment(state({ review: screens(40, index => `Changed ${index}`), capture: { planned: { base: 40, head: 40 }, done: { base: 40, head: 40 }, baseFromCache: false } }));
|
||
assert.equal(titleOf(body), `### 40 screens changed · [Review in Explore](<${reportUrl}>)`);
|
||
assert.equal((body.match(/\n- \[`\/screen-\d+`\]/g) ?? []).length, 10);
|
||
assert.equal(listOf(body).at(-1), `- [30 more screens in the report](<${reportUrl}>)`);
|
||
assert.ok(body.length < PUBLIC_COMMENT_BODY_BUDGET);
|
||
assert.equal(listOf(renderPublicComment(state({ review: screens(11, () => 'Changed') }))).at(-1), `- [1 more screen in the report](<${reportUrl}>)`);
|
||
assert.equal(listOf(renderPublicComment(state({ review: screens(10, () => 'Changed') }))).length, 10, 'ten screens need no link to the rest');
|
||
const long = renderPublicComment(state({ review: screens(900, index => `Changed ${index} in a way that takes a sentence to describe for the budget check.`) }));
|
||
assert.ok(long.length < PUBLIC_COMMENT_BODY_BUDGET, String(long.length));
|
||
assert.match(titleOf(long), /^### 900 screens changed · /);
|
||
assert.match(long, /\n- \[890 more screens in the report\]\(<[^>]+>\)\n/);
|
||
});
|
||
|
||
test('pending and capturing runs say what they are doing in the heading and a line per target, with no list', () => {
|
||
const links = viewerLinks({ repository: pullRequest.repository, headSha, runId: '42', runAttempt: '2', configPath: '.expo-explore/config.yaml' });
|
||
const pending = renderPublicComment(state({ phase: 'pending', links, review: undefined, evidence: undefined, runtime: undefined, capture: { planned: { base: 3, head: 3 }, done: { base: 0, head: 0 }, baseFromCache: false } }));
|
||
assert.equal(titleOf(pending), '### Explore review · starting');
|
||
assert.equal(subtitleOf(pending), `<sub>iOS · ${commits} · updated Sep 11, 3:07 AM UTC</sub>`);
|
||
assert.equal(statusOf(pending, 'iOS'), `iOS · starting · ${runLink}`);
|
||
assert.deepEqual(listOf(pending), []);
|
||
assert.deepEqual(summaries(pending), []);
|
||
assert.doesNotMatch(pending, /changed|Review in Explore|Validation|Waiting|Open|zip/);
|
||
const progress = (current, baseFromCache) => statusOf(renderPublicComment(state({ phase: 'capturing', links, review: undefined, evidence: undefined,
|
||
capture: { planned: { base: 3, head: 3 }, done: { base: 2, head: 1 }, baseFromCache, current } })), 'iOS');
|
||
assert.equal(progress('base', false), `iOS · capturing base 2/3 · ${runLink}`);
|
||
assert.equal(progress('head', false), `iOS · capturing head 1/3 · ${runLink}`);
|
||
assert.equal(progress('head', true), `iOS · capturing head 1/3 · base reused · ${runLink}`);
|
||
assert.equal(progress(undefined, false), `iOS · capturing head 1/3 · ${runLink}`);
|
||
assert.equal(titleOf(renderPublicComment(state({ phase: 'capturing', links, review: undefined, evidence: undefined }))), '### Explore review · capturing');
|
||
const none = renderPublicComment(state({ review: review([surface('cats', 'modified', { extra: { review: { status: 'unchanged', description: 'No visible change' } } })]) }));
|
||
assert.equal(titleOf(none), `### No screens changed · [Review in Explore](<${reportUrl}>)`, 'unchanged surfaces are no change');
|
||
assert.deepEqual(listOf(none), []);
|
||
});
|
||
|
||
test('without evidence the comment lists only added and removed screens and counts the uncompared one', () => {
|
||
const plain = review([surface('care-guide', 'added'), surface('sweet-bee', 'removed'), surface('settings', 'modified'), surface('cats', 'unchanged')]);
|
||
const body = renderPublicComment(reviewOnlyState(plain));
|
||
// A source change nobody compared is not a change the captures show.
|
||
assert.match(titleOf(body), /^### 2 screens changed · /);
|
||
assert.deepEqual(listOf(body).map(line => /^- (Added|Removed) /.exec(line)?.[1]), ['Added', 'Removed']);
|
||
assert.equal(statusOf(body, 'iOS'), `iOS · 1 not compared · ${runLink}`, 'the uncompared /settings is counted, not hidden');
|
||
assert.doesNotMatch(body, /Source-affected|screen=settings|Needs review|Unchanged|screen=cats|not run/);
|
||
});
|
||
|
||
test('the subtitle says when the comment last changed: the review\'s time once ready, the writer\'s before', () => {
|
||
assert.match(subtitleOf(renderPublicComment(state({ updatedAt: Date.parse('2026-09-12T09:42:00Z') }))), /· updated Sep 11, 3:07 AM UTC<\/sub>$/, 'a republished review keeps its time');
|
||
assert.match(subtitleOf(renderPublicComment(state({ phase: 'capturing', review: undefined, updatedAt: Date.parse('2026-09-12T09:42:00Z') }))), /· updated Sep 12, 9:42 AM UTC<\/sub>$/);
|
||
});
|
||
|
||
test('needs-review surfaces are tagged on their row; screen variants and capture warnings stay out', () => {
|
||
const flagged = evidenceReview();
|
||
flagged.surfaces[2].review = { status: 'needs-review', description: 'Screenshots differ (2.1%) without a source change' };
|
||
flagged.surfaces.push(surface('coins', 'unchanged', { extra: { sourceScope: 'none', review: { status: 'variant', description: 'Runtime state differs: Removed text “84”.' },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage: 0.2, threshold: 0.1, tool: '[email protected]' } } }));
|
||
const body = renderPublicComment(state({ review: flagged, warnings: ['src/app/orphan.tsx has unresolved navigation'], inventory: { routes: 54 } }));
|
||
assert.match(titleOf(body), /^### 3 screens changed · /);
|
||
assert.match(body, /\n- \[`\/settings`\]\(<[^>]+>\) · Screenshots differ \\\(2\\\.1%\\\) without a source change · needs review\n/);
|
||
assert.doesNotMatch(body, /coins|Capture warnings|orphan|Needs review \(|54 routes/);
|
||
});
|
||
|
||
test('text is redacted and escaped before it reaches the comment, and credentialed links are refused', () => {
|
||
const tainted = evidenceReview();
|
||
tainted.surfaces[2].review.description = 'Changed title to <b>bold</b> | secret=abc';
|
||
const body = renderPublicComment(state({ review: tainted }));
|
||
assert.match(body, /<b>bold<\/b> \\\| secret=\\\[redacted\\\]/);
|
||
assert.throws(() => renderPublicComment(state({ links: { ...state().links, run: 'https://user:[email protected]/x' } })), /credentials/);
|
||
});
|
||
|
||
test('a completed partial capture gets one line with the count of missing captures, not ongoing work', () => {
|
||
const capture = { planned: { base: 34, head: 34 }, done: { base: 21, head: 34 }, baseFromCache: false };
|
||
const ready = renderPublicComment(state({ capture }));
|
||
assert.equal(statusOf(ready, 'iOS'), `iOS · 13 captures unavailable · ${runLink}`);
|
||
assert.match(titleOf(ready), /^### 3 screens changed · /);
|
||
assert.doesNotMatch(ready, /⏳|Capture issues|surfaces/);
|
||
assert.equal(statusOf(renderPublicComment(state({ phase: 'failed', capture, failure: { step: 'capture-base', message: 'failed' } })), 'iOS'), `**iOS failed** · capture\\-base · failed · ${runLink} · ${evidenceLink}`);
|
||
assert.equal(statusOf(renderPublicComment(state({ capture: { ...capture, done: { base: 34, head: 34 } } })), 'iOS'), undefined, 'a complete capture needs no line');
|
||
assert.equal(statusOf(renderPublicComment(state({ capture: { ...capture, done: { base: 0, head: 0 } } })), 'iOS'), `iOS · 62 captures unavailable · ${runLink}`, 'the review’s own frames count as captured');
|
||
});
|
||
|
||
test('capture problems are counted on the target\'s line and listed, collapsed, with both sides', () => {
|
||
const flagged = evidenceReview();
|
||
const failure = side => ({ status: 'failed', attempts: 1, landing: 'unverified', error: {
|
||
code: `${side.toUpperCase()}_SESSION_FAILED`, stage: 'capture', message: `${side} simulator disconnected; token=private`,
|
||
nextAction: `Restart the ${side} simulator session and rerun capture.`, retryable: true,
|
||
} });
|
||
flagged.surfaces[0].headOutcome = failure('head'); delete flagged.surfaces[0].headImage;
|
||
flagged.surfaces[0].review = { status: 'needs-review', description: 'old capture failure' };
|
||
flagged.surfaces[1].baseOutcome = failure('base'); delete flagged.surfaces[1].baseImage;
|
||
flagged.surfaces[1].review = { status: 'needs-review', description: 'old capture failure' };
|
||
flagged.surfaces[2].baseOutcome = failure('base'); delete flagged.surfaces[2].baseImage;
|
||
flagged.surfaces[2].headOutcome.landing = 'unverified';
|
||
flagged.surfaces[2].review = { status: 'needs-review', description: 'old destination failure' };
|
||
const body = renderPublicComment(state({ review: flagged, capture: { planned: { base: 3, head: 3 }, done: { base: 1, head: 2 }, baseFromCache: false } }));
|
||
// /settings could not be compared, so it is no change: its failures are named under Capture issues.
|
||
assert.match(titleOf(body), /^### 2 screens changed · /);
|
||
assert.equal(statusOf(body, 'iOS'), `iOS · 3 captures unavailable · 1 unattributed · ${runLink}`);
|
||
assert.deepEqual(summaries(body), ['Capture issues (4)']);
|
||
assert.ok(body.indexOf('<summary>Capture issues') > body.indexOf('\n- Removed '), 'the list comes first');
|
||
assert.match(body, /\n- Base · \/settings · /);
|
||
assert.match(body, /\n- Base · \/cats\/sweet\\-bee · BASE\\_SESSION\\_FAILED: base simulator disconnected; token=\\\[redacted\\\] Next action: Restart the base simulator session and rerun capture\\\./);
|
||
assert.match(body, /\n- Head · \/cats\/care\\-guide · HEAD\\_SESSION\\_FAILED/);
|
||
assert.doesNotMatch(body, /needs review|Modified|old capture|old destination|token=private|\*\*iOS\*\*|Validation/);
|
||
});
|
||
|
||
test('a target whose dark session failed reads as ready, says dark was not captured, and names the session once', async () => {
|
||
const { readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
// brentvatne/euxy's main baseline run 39506374824: the light session captured every route, the dark session's install failed.
|
||
const error = { code: 'CAPTURE_SESSION_FAILED', stage: 'session', message: 'install: argent run list-devices failed: GET /tools failed: 404 Not Found',
|
||
nextAction: 'Rerun the capture on a new session.', retryable: true };
|
||
const unchanged = { review: { status: 'unchanged', description: 'No visible change' }, visual: { verdict: 'unchanged', reason: 'matched', mismatchPercentage: 0, threshold: 0.1, tool: '[email protected]' } };
|
||
const surfaces = [];
|
||
// 24 routes: more dark frames than the comment lists issues, and one light route the light session lost.
|
||
for (let index = 0; index < 24; index++) {
|
||
const id = `route-${index}`;
|
||
const light = surface(id, 'modified', { extra: { colorScheme: 'light', ...unchanged } });
|
||
if (index === 23) { delete light.headImage; delete light.review; light.headOutcome = { status: 'failed', attempts: 2, landing: 'unverified', reason: 'The route observer never answered.' }; }
|
||
const dark = surface(`${id}@dark`, 'modified', { route: `/${id}`, extra: { colorScheme: 'dark', headOutcome: { status: 'failed', attempts: 0, landing: 'unverified', reason: error.message, error } } });
|
||
delete dark.headImage;
|
||
surfaces.push(light, dark);
|
||
}
|
||
const health = { planned: 24, captured: 24, verified: 24, unverified: 0, unavailable: 0 };
|
||
const evidence = { visual: null, accessibility: null, capture: { base: health, head: { ...health, captured: 23, verified: 23, unavailable: 1 } },
|
||
comparison: { planned: 24, eligible: 23, skipped: 0 }, diagnostics: [], sessionFailures: [{ side: 'head', scheme: 'dark', frames: 24, ...error }] };
|
||
const body = renderPublicComment(state({ review: review(surfaces), evidence, capture: { planned: { base: 24, head: 24 }, done: { base: 24, head: 23 }, baseFromCache: true } }));
|
||
assert.doesNotMatch(body, /failed\*\*|capture failed|🟥/, 'one appearance missing is not a failed review');
|
||
assert.match(titleOf(body), /^### No compared screens changed · dark not captured · /);
|
||
assert.equal(statusOf(body, 'iOS'), `iOS · dark not captured · 1 capture unavailable · ${runLink}`);
|
||
assert.deepEqual(summaries(body), ['Capture issues (2)'], 'the session once, and the light route it lost');
|
||
assert.match(body, /\n- Head · dark session · CAPTURE\\_SESSION\\_FAILED: install\\: argent run list\\-devices failed\\: GET \/tools failed\\: 404 Not Found\\. 24 dark frames are unavailable\\. Next action: Rerun the capture on a new session\\\.\n/);
|
||
assert.match(body, /\n- Head · \/route\\-23 · CAPTURE\\_UNAVAILABLE: The route observer never answered\\. /, 'the light route is not crowded out');
|
||
assert.doesNotMatch(body, /\(dark\\\) · CAPTURE\\_SESSION\\_FAILED/, 'no dark frame repeats the session');
|
||
const record = readCommentRecords(body).current.ios;
|
||
assert.deepEqual(record.sessionFailures, [{ side: 'head', scheme: 'dark', frames: 24 }], 'the record keeps it, so a republished comment says it too');
|
||
});
|
||
|
||
test('a dark issue that is not the session\'s is still listed with its appearance', () => {
|
||
const error = { code: 'CAPTURE_OBSERVER_MISSING', stage: 'capture', message: 'The capture build has not exposed its active navigation destination.', nextAction: 'Rerun capture.', retryable: true };
|
||
const dark = surface('settings@dark', 'modified', { route: '/settings', extra: { colorScheme: 'dark', headOutcome: { status: 'failed', attempts: 2, landing: 'unverified', reason: error.message, error } } });
|
||
delete dark.headImage;
|
||
const body = renderPublicComment(state({ review: review([surface('settings', 'modified', { extra: { colorScheme: 'light' } }), dark]), evidence: undefined }));
|
||
assert.match(body, /\n- Head · \/settings \\\(dark\\\) · CAPTURE\\_OBSERVER\\_MISSING/);
|
||
assert.doesNotMatch(titleOf(body), /not captured/);
|
||
});
|
||
|
||
test('zero eligible pairs are counted as unattributed instead of successful comparisons', () => {
|
||
const flagged = evidenceReview();
|
||
for (const surface of flagged.surfaces) {
|
||
for (const side of ['base', 'head']) if (surface[`${side}Outcome`]) surface[`${side}Outcome`].landing = 'unverified';
|
||
if (surface.status === 'modified') delete surface.review;
|
||
}
|
||
const counts = { planned: 3, captured: 3, verified: 0, unverified: 3, unavailable: 0 };
|
||
const body = renderPublicComment(state({ review: flagged, evidence: {
|
||
capture: { base: counts, head: counts }, comparison: { planned: 2, eligible: 0, skipped: 2 }, diagnostics: [],
|
||
visual: { compared: 0, modified: 0, unchanged: 0, needsReview: 0, failed: 0, errors: [], threshold: 0.1, tool: '[email protected]' },
|
||
accessibility: { compared: 0, described: 0, failed: 0, skipped: 0, errors: [] },
|
||
} }));
|
||
assert.match(titleOf(body), /^### 2 screens changed · /, 'the uncompared modified screens are not changes');
|
||
assert.equal(statusOf(body, 'iOS'), `iOS · 6 unattributed · ${runLink}`);
|
||
});
|
||
|
||
test('the review-only comment counts failed outcomes as unavailable and lists the reason', () => {
|
||
const added = surface('added', 'added');
|
||
delete added.headImage;
|
||
added.headOutcome = { status: 'missing-params', attempts: 0, landing: 'unverified', reason: 'Provide the item ID.' };
|
||
const body = renderPublicComment(reviewOnlyState(review([added])));
|
||
assert.equal(statusOf(body, 'iOS'), `iOS · 1 capture unavailable · ${runLink}`);
|
||
assert.match(body, /\n- Added \[`\/added`\]\(<[^>]+screen=added>\)\n/);
|
||
assert.deepEqual(summaries(body), ['Capture issues (1)']);
|
||
assert.match(body, /CAPTURE\\_UNAVAILABLE: Provide the item ID\\\./);
|
||
});
|
||
|
||
test('a failed run keeps its message on the target\'s line and lists nothing', () => {
|
||
const body = renderPublicComment(state({ phase: 'failed', failure: { step: 'capture', message: 'Capture coverage is incomplete.' } }));
|
||
assert.equal(titleOf(body), '### Explore review · capture failed');
|
||
assert.equal(statusOf(body, 'iOS'), `**iOS failed** · capture · Capture coverage is incomplete\\. · ${runLink} · ${evidenceLink}`);
|
||
assert.deepEqual(listOf(body), [], 'a failed run has no review to list');
|
||
});
|
||
|
||
test('screen variants and app-wide source changes are left to the report', () => {
|
||
const variant = surface('coins', 'unchanged', { extra: { sourceScope: 'none',
|
||
review: { status: 'variant', description: 'Runtime state differs: Removed text “84”.' },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage: 0.2, threshold: 0.1, tool: '[email protected]' } } });
|
||
const appWide = surface('profile', 'modified', { extra: { sourceScope: 'app-wide',
|
||
review: { status: 'unchanged', description: 'No visible change' },
|
||
sourceChange: { ...sourceChange('Dependency update.', 'global'), files: [{ path: 'package.json', status: 'modified', relation: 'global', hunks: [], facts: [], truncated: false }] } } });
|
||
const body = renderPublicComment(state({ review: review([...evidenceReview().surfaces, variant, appWide]) }));
|
||
assert.match(titleOf(body), /^### 3 screens changed · /);
|
||
assert.doesNotMatch(body, /\/coins|\/profile|runtime state|App-wide|package\.json/);
|
||
});
|
||
|
||
test('a changed splash screen is one line after the list; an unchanged one is not mentioned', () => {
|
||
const launch = verdict => ({ ...evidenceReview(), launch: [
|
||
{ colorScheme: 'light', head: { status: 'observed', source: 'recording', splashVisibleMs: 900, firstScreenMs: 1900 }, base: { status: 'observed' }, visual: { verdict } },
|
||
{ colorScheme: 'dark', head: { status: 'observed', source: 'recording', splashVisibleMs: 900, firstScreenMs: 1900 }, base: { status: 'observed' }, visual: { verdict: 'unchanged' } }] });
|
||
const changed = renderPublicComment(state({ review: launch('modified'), artifactOnly: true }));
|
||
assert.match(changed, /\n- \[`\/settings`\][^\n]+\n\nSplash screen changed in light mode\n\n<sub>/);
|
||
assert.match(renderPublicComment(state({ review: launch('needs-review'), artifactOnly: true })), /\nSplash screen needs review in light mode\n/);
|
||
assert.doesNotMatch(renderPublicComment(state({ review: launch('unchanged'), artifactOnly: true })), /Splash|Launch|first screen/);
|
||
const both = { ...evidenceReview(), launch: [{ head: { status: 'observed', source: 'recording' }, base: { status: 'observed' }, visual: { verdict: 'modified' } }] };
|
||
assert.match(renderPublicComment(state({ review: both, artifactOnly: true })), /\nSplash screen changed\n/);
|
||
});
|
||
|
||
test('a hosted preview is the report link, and the footer says how long the links work', () => {
|
||
const token = 'abcdefghijklmnopqrstuvwxyz234567';
|
||
const preview = previewLinks(`https://acme-explore--1lcbdcpwn3.expo.app/p/${token}`, Date.parse('2026-09-18T03:07:00Z'));
|
||
const links = { ...viewerLinks({ repository: pullRequest.repository, headSha, runId: '42', runAttempt: '2', configPath: '.expo-explore/config.yaml', artifactUrl }), preview };
|
||
const body = renderPublicComment(state({ links, artifactOnly: true }));
|
||
const lines = body.split('\n');
|
||
assert.equal(lines[1], '<!-- expo-explore-deployments:acme-explore--1lcbdcpwn3 -->');
|
||
assert.equal(titleOf(body), `### 3 screens changed · [Review in Explore](<https://acme-explore--1lcbdcpwn3.expo.app/p/${token}>)`);
|
||
assert.equal(subtitleOf(body), `<sub>[iOS](<https://acme-explore--1lcbdcpwn3.expo.app/p/${token}>) · ${commits} · updated Sep 11, 3:07 AM UTC</sub>`);
|
||
assert.match(body, new RegExp(`\\n- \\[\`/settings\`\\]\\(<https://acme-explore--1lcbdcpwn3\\.expo\\.app/p/${token}\\?view=compare&screen=settings>\\) · `));
|
||
assert.equal(lines.at(-1), `<sub>Preview links work for anyone who has them until Sep 18, 2026 (UTC) · ${workflowRun} · ${configLink}</sub>`);
|
||
assert.doesNotMatch(body, /Open Explore|explore-viewer\.expo\.app|explore-preview\.expo\.app|zip/);
|
||
assert.doesNotMatch(renderPublicComment(state({ links, phase: 'failed', review: undefined, failure: { step: 'capture-head', message: 'boom' } })), /acme-explore--1lcbdcpwn3\.expo\.app\/p|for anyone who has/);
|
||
const forever = renderPublicComment(state({ links: { ...links, preview: previewLinks(`https://acme-explore--1lcbdcpwn3.expo.app/p/${token}`, null) }, artifactOnly: true }));
|
||
assert.match(forever, /\n<sub>Preview links work for anyone who has them until the deployment is deleted · \[Workflow run\]/);
|
||
});
|
||
|
||
test('two platforms share one comment: both named in the subtitle, every changed screen listed once, a line only for the one still working', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const ready = state({ artifactOnly: true });
|
||
const single = renderPublicComment(ready);
|
||
assert.equal(platformSectionMerge(ready, undefined).merge(undefined), single);
|
||
assert.equal(platformSectionMerge(ready, 'ios').merge(undefined), single, 'the iOS writer of a matrix renders the same comment as a single-platform run');
|
||
const androidState = overrides => state({ artifactOnly: true, build: { ...state().build, platform: 'android' },
|
||
runtime: { ...state().runtime, platform: 'android', deviceModel: 'EasAndroidDevice01', osVersion: '15.0' }, ...overrides });
|
||
const capturing = platformSectionMerge(androidState({ phase: 'capturing', review: undefined, evidence: undefined, capture: { planned: { base: 3, head: 3 }, done: { base: 3, head: 1 }, baseFromCache: false, current: 'head' } }), 'android').merge(single);
|
||
assert.deepEqual(Object.keys(readCommentRecords(capturing).current), ['ios', 'android']);
|
||
assert.equal(titleOf(capturing), titleOf(single), 'a platform still capturing has no report to link yet');
|
||
assert.equal(subtitleOf(capturing), `<sub>[iOS](<${reportUrl}>) · Android · ${commits} · updated Sep 11, 3:07 AM UTC</sub>`);
|
||
assert.equal(statusOf(capturing, 'Android'), `Android · capturing head 1/3 · ${runLink}`);
|
||
assert.equal(statusOf(capturing, 'iOS'), undefined);
|
||
assert.deepEqual(listOf(capturing), listOf(single), 'a platform still capturing adds nothing to the list');
|
||
const flagged = evidenceReview();
|
||
flagged.surfaces[2].review = { status: 'needs-review', description: 'Screenshots differ (2.1%) without a source change' };
|
||
flagged.surfaces.push(surface('coins', 'unchanged', { extra: { sourceScope: 'none', review: { status: 'variant', description: 'Runtime state differs: Removed text “84”.' },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage: 0.2, threshold: 0.1, tool: '[email protected]' } } }));
|
||
const both = platformSectionMerge(androidState({ review: flagged }), 'android').merge(capturing);
|
||
assert.deepEqual(Object.keys(readCommentRecords(both).current), ['ios', 'android']);
|
||
assert.equal(titleOf(both), titleOf(single), 'the heading links the first platform’s report');
|
||
assert.match(subtitleOf(both), /^<sub>\[iOS\]\(<[^>]+artifact=7654>\) · \[Android\]\(<[^>]+artifact=7654>\) · \[bbbbbbbb\]/);
|
||
assert.equal(statusOf(both, 'Android'), undefined);
|
||
assert.equal(listOf(both).length, 3, 'no screen is listed twice');
|
||
assert.match(both, /\n- Added \[`\/cats\/care-guide`\]\(<[^>]+screen=care-guide>\)\n/);
|
||
assert.match(both, /\n- \[`\/settings`\]\(<[^>]+>\) · Changed button title from “Share” to “Share Example”\n/, 'a platform whose verdict differs does not change the row');
|
||
assert.doesNotMatch(both, / only\n|not on |coins|runtime state|iOS · /);
|
||
assert.ok(both.length < 8_000, String(both.length));
|
||
// A writer that finds no records (a legacy or hand-edited comment) starts over rather than failing.
|
||
const legacy = `${CI_COMMENT_MARKER}\nExplore review for x\n<!-- expo-explore-platform:ios -->\n## iOS · 3 changes\n<!-- /expo-explore-platform:ios -->`;
|
||
assert.equal(platformSectionMerge(ready, 'ios').merge(legacy), single);
|
||
const tampered = both.replace(/expo-explore-record:android:[A-Za-z0-9_-]+/, 'expo-explore-record:android:AAAA');
|
||
assert.equal(platformSectionMerge(ready, 'ios').merge(tampered), single);
|
||
});
|
||
|
||
test('a screen that changed on some devices only says which, the shorter way', async () => {
|
||
const { platformSectionMerge } = await import('../../dist/report/pr-comment-body.js');
|
||
const [iphone, closed, open, android] = duoTargets;
|
||
const settingsOnly = review([evidenceReview().surfaces[2]]);
|
||
let body;
|
||
for (const target of [iphone, closed, open]) body = platformSectionMerge(targetState(target), target.id).merge(body);
|
||
body = platformSectionMerge(targetState(android, { review: settingsOnly }), 'android').merge(body);
|
||
assert.match(body, /\n- Added \[`\/cats\/care-guide`\]\(<[^>]+>\) · not on Pixel 7\n/);
|
||
assert.match(body, /\n- \[`\/settings`\]\(<[^>]+>\) · Changed button title from “Share” to “Share Example”\n/, 'a screen changed everywhere names no device');
|
||
const two = platformSectionMerge(targetState(android, { review: settingsOnly }), 'android').merge(platformSectionMerge(targetState(iphone), 'iphone').merge(undefined));
|
||
assert.match(two, /\n- Added \[`\/cats\/care-guide`\]\(<[^>]+>\) · iPhone 17 only\n/);
|
||
// A device missing a capture may have changed the screen too: the row says where it changed, not where it did not.
|
||
const missing = platformSectionMerge(targetState(android, { review: settingsOnly, capture: { planned: { base: 3, head: 3 }, done: { base: 3, head: 2 }, baseFromCache: false } }), 'android')
|
||
.merge(platformSectionMerge(targetState(iphone), 'iphone').merge(undefined));
|
||
assert.match(missing, /\nPixel 7 · 1 capture unavailable · /);
|
||
assert.match(missing, /\n- Added \[`\/cats\/care-guide`\]\(<[^>]+>\) · on iPhone 17\n/);
|
||
assert.doesNotMatch(missing, / only\n|not on /);
|
||
});
|
||
|
||
test('a new run keeps the last finished review up, under a line saying it is working, until it has a finished target; the old run joins Previous runs', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const first = platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined);
|
||
const androidReady = state({ artifactOnly: true, build: { ...state().build, platform: 'android' }, runtime: { ...state().runtime, platform: 'android', deviceModel: 'Pixel 9' } });
|
||
const both = platformSectionMerge(androidReady, 'android').merge(first);
|
||
const nextHead = 'd'.repeat(40);
|
||
const nextLinks = (runId, artifact) => viewerLinks({ ...(artifact ? { artifactUrl: `https://github.com/expo/explore-playground/actions/runs/${runId}/artifacts/${artifact}` } : {}),
|
||
repository: pullRequest.repository, headSha: nextHead, runId, runAttempt: '1', configPath: '.expo-explore/config.yaml' });
|
||
const nextState = overrides => state({ phase: 'capturing', review: undefined, evidence: undefined, links: nextLinks('43'), pullRequest: { ...pullRequest, head: { ref: 'feature', sha: nextHead } },
|
||
startedAt: Date.parse('2026-09-11T04:00:00Z'), updatedAt: Date.parse('2026-09-11T04:01:00Z'), capture: { planned: { base: 3, head: 3 }, done: { base: 3, head: 0 }, baseFromCache: true, current: 'head' }, ...overrides });
|
||
const next = platformSectionMerge(nextState(), 'ios').merge(both);
|
||
assert.equal(visible(next).split('\n')[1], `> **New run in progress** for [dddddddd](<https://github.com/expo/explore-playground/commit/${nextHead}>) · capturing on iOS · [run](<https://github.com/expo/explore-playground/actions/runs/43/attempts/1>)`);
|
||
assert.equal(titleOf(next), titleOf(both), 'the previous review stays up');
|
||
assert.equal(subtitleOf(next), subtitleOf(both));
|
||
assert.deepEqual(listOf(next), listOf(both));
|
||
assert.deepEqual(Object.keys(readCommentRecords(next).current), ['ios']);
|
||
assert.deepEqual(Object.keys(readCommentRecords(next).previous), ['ios', 'android']);
|
||
assert.deepEqual(summaries(next), ['Previous runs (1)']);
|
||
assert.match(next, /\n- \[bbbbbbbb\]\(<https:\/\/github\.com\/expo\/explore-playground\/commit\/b{40}>\) · iOS 🟩 3 changes \(\[report\]\(<https:\/\/explore-preview\.expo\.app\/\?repo=[^>]+&artifact=7654>\) · \[run\]\(<[^>]+runs\/42\/attempts\/2>\)\) · Android 🟩 3 changes \(\[report\]\(<[^>]+>\) · \[run\]\(<[^>]+>\)\)\n/);
|
||
// The previous run's Android report job finishes after the new run started: it is superseded and lands in history.
|
||
const late = platformSectionMerge(androidReady, 'android').merge(next);
|
||
assert.deepEqual(Object.keys(readCommentRecords(late).current), ['ios']);
|
||
assert.equal(readCommentRecords(late).history.length, 1);
|
||
assert.equal(titleOf(late), titleOf(both));
|
||
// The new run's Android writer joins; nothing finished yet, so the previous review stays.
|
||
const joined = platformSectionMerge(nextState({ build: { ...state().build, platform: 'android' } }), 'android').merge(late);
|
||
assert.deepEqual(Object.keys(readCommentRecords(joined).current), ['ios', 'android']);
|
||
assert.match(visible(joined).split('\n')[1], /^> \*\*New run in progress\*\* for \[dddddddd\]\(<[^>]+>\) · capturing on iOS, Android · \[run\]/);
|
||
assert.deepEqual(listOf(joined), listOf(both));
|
||
assert.deepEqual(summaries(joined), ['Previous runs (1)']);
|
||
// The new run's iOS finishes: its review takes over; the previous Android record waits for the new one.
|
||
const iosDone = platformSectionMerge(nextState({ phase: 'ready', review: evidenceReview(), links: nextLinks('43', '7700') }), 'ios').merge(joined);
|
||
assert.doesNotMatch(iosDone, /New run in progress/);
|
||
assert.match(titleOf(iosDone), /^### 3 screens changed · \[Review in Explore\]\(<[^>]+artifact=7700>\)$/);
|
||
assert.match(subtitleOf(iosDone), /^<sub>\[iOS\]\(<[^>]+artifact=7700>\) · Android · \[dddddddd\]/);
|
||
assert.equal(statusOf(iosDone, 'Android'), 'Android · capturing head 0/3 · base reused · [run](<https://github.com/expo/explore-playground/actions/runs/43/attempts/1>)');
|
||
assert.deepEqual(Object.keys(readCommentRecords(iosDone).previous), ['android']);
|
||
const androidDone = platformSectionMerge(nextState({ phase: 'ready', review: evidenceReview(), links: nextLinks('43', '7700'), build: { ...state().build, platform: 'android' } }), 'android').merge(iosDone);
|
||
assert.equal(readCommentRecords(androidDone).previous, undefined, 'once every target finished nothing previous is kept');
|
||
assert.doesNotMatch(androidDone, /expo-explore-previous/);
|
||
assert.equal(statusOf(androidDone, 'Android'), undefined);
|
||
// Each later run pushes one more entry; history is capped.
|
||
let body = androidDone;
|
||
for (let run = 44; run < 52; run += 1) {
|
||
const sha = run.toString(16).padStart(40, '0');
|
||
body = platformSectionMerge(state({ links: viewerLinks({ repository: pullRequest.repository, headSha: sha, runId: String(run), runAttempt: '1', configPath: '.expo-explore/config.yaml' }),
|
||
pullRequest: { ...pullRequest, head: { ref: 'feature', sha } }, startedAt: Date.parse('2026-09-11T04:00:00Z') + run, updatedAt: Date.parse('2026-09-11T04:01:00Z') + run }), 'ios').merge(body);
|
||
}
|
||
const history = readCommentRecords(body).history;
|
||
assert.equal(history.length, 5);
|
||
assert.deepEqual(history.map(entry => entry.runId), ['50', '49', '48', '47', '46']);
|
||
assert.match(body, /<summary>Previous runs \(5\)<\/summary>/);
|
||
assert.ok(body.length < PUBLIC_COMMENT_BODY_BUDGET);
|
||
assert.doesNotMatch(body, /expo-explore-previous|New run in progress/);
|
||
// A state marked superseded by its own writer goes straight to history even when the comment has nothing newer yet.
|
||
const superseded = platformSectionMerge(state({ artifactOnly: true, superseded: true }), 'ios').merge(undefined);
|
||
assert.match(superseded, /^### Explore review\n\nThe pull request moved while this run worked; its report is kept here until the current run replaces it\./m);
|
||
const replaced = platformSectionMerge(nextState(), 'ios').merge(superseded);
|
||
assert.equal(titleOf(replaced), '### Explore review · capturing');
|
||
assert.match(replaced, /Previous runs \(1\)/);
|
||
});
|
||
|
||
test('the review a new run keeps up comes from one run: a target the last run never finished does not bring back an older run', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const androidOf = overrides => state({ artifactOnly: true, build: { ...state().build, platform: 'android' }, runtime: { ...state().runtime, platform: 'android', deviceModel: 'Pixel 9' }, ...overrides });
|
||
const run = (runId, head, artifact) => ({ pullRequest: { ...pullRequest, head: { ref: 'feature', sha: head } },
|
||
links: viewerLinks({ ...(artifact ? { artifactUrl: `https://github.com/expo/explore-playground/actions/runs/${runId}/artifacts/${artifact}` } : {}),
|
||
repository: pullRequest.repository, headSha: head, runId, runAttempt: '1', configPath: '.expo-explore/config.yaml' }) });
|
||
const working = { phase: 'capturing', review: undefined, evidence: undefined };
|
||
const [c, d] = ['c'.repeat(40), 'd'.repeat(40)];
|
||
let body = platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined);
|
||
body = platformSectionMerge(androidOf({}), 'android').merge(body);
|
||
// Run 43 finishes Android only; its iOS job never reports (cancelled).
|
||
body = platformSectionMerge(state({ ...working, ...run('43', c) }), 'ios').merge(body);
|
||
body = platformSectionMerge(androidOf({ ...run('43', c, '7800'), review: review([evidenceReview().surfaces[2]]) }), 'android').merge(body);
|
||
body = platformSectionMerge(state({ ...working, ...run('44', d) }), 'ios').merge(body);
|
||
const previous = readCommentRecords(body).previous;
|
||
assert.deepEqual(Object.keys(previous), ['android']);
|
||
assert.equal(previous.android.runId, '43');
|
||
assert.match(subtitleOf(body), /^<sub>\[Android\]\(<[^>]+artifact=7800>\) · \[cccccccc\]/);
|
||
assert.equal(titleOf(body), '### 1 screen changed · [Review in Explore](<https://explore-preview.expo.app/?repo=expo%2Fexplore-playground&run=43&attempt=1&artifact=7800>)');
|
||
assert.doesNotMatch(visible(body).split('<details>')[0], /artifact=7654/, 'run 42 is only under Previous runs');
|
||
});
|
||
|
||
test('a new run\'s failure does not replace the previous review: it is named on the new-run line until a target is ready', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const androidOf = overrides => state({ artifactOnly: true, build: { ...state().build, platform: 'android' }, runtime: { ...state().runtime, platform: 'android', deviceModel: 'Pixel 9' }, ...overrides });
|
||
const both = platformSectionMerge(androidOf({}), 'android').merge(platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined));
|
||
const nextHead = 'd'.repeat(40);
|
||
const next = { pullRequest: { ...pullRequest, head: { ref: 'feature', sha: nextHead } },
|
||
links: viewerLinks({ repository: pullRequest.repository, headSha: nextHead, runId: '43', runAttempt: '1', configPath: '.expo-explore/config.yaml' }) };
|
||
const commit = `[dddddddd](<https://github.com/expo/explore-playground/commit/${nextHead}>)`;
|
||
const run43 = '[run](<https://github.com/expo/explore-playground/actions/runs/43/attempts/1>) · [evidence](<https://github.com/expo/explore-playground/actions/runs/43#artifacts>)';
|
||
const failure = { phase: 'failed', review: undefined, evidence: undefined, failure: { step: 'capture-head', message: 'boom' } };
|
||
// Android fails before iOS posts anything.
|
||
const failed = platformSectionMerge(androidOf({ ...next, ...failure }), 'android').merge(both);
|
||
assert.equal(visible(failed).split('\n')[1], `> **New run failed** for ${commit} · **Android failed** · capture\\-head · boom · ${run43}`);
|
||
assert.equal(titleOf(failed), titleOf(both));
|
||
assert.deepEqual(listOf(failed), listOf(both));
|
||
assert.deepEqual(Object.keys(readCommentRecords(failed).previous), ['ios', 'android']);
|
||
const working = platformSectionMerge(state({ ...next, phase: 'capturing', review: undefined, evidence: undefined, capture: undefined }), 'ios').merge(failed);
|
||
assert.equal(visible(working).split('\n')[1], `> **New run in progress** for ${commit} · capturing on iOS · **Android failed** · capture\\-head · boom · ${run43}`);
|
||
assert.deepEqual(listOf(working), listOf(both));
|
||
// iOS is ready: its review takes over, and Android's failure is its own line.
|
||
const ready = platformSectionMerge(state({ ...next, artifactOnly: true }), 'ios').merge(working);
|
||
assert.doesNotMatch(ready, /New run|expo-explore-previous/);
|
||
assert.match(statusOf(ready, 'Android'), /^\*\*Android failed\*\* · capture\\-head · boom · /);
|
||
// A single target that failed keeps the review before it, and the run after it still has that review to keep.
|
||
const single = platformSectionMerge(state({ ...next, ...failure }), 'ios').merge(platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined));
|
||
assert.equal(visible(single).split('\n')[1], `> **New run failed** for ${commit} · capture\\-head · boom · ${run43}`);
|
||
assert.match(titleOf(single), /^### 3 screens changed · /);
|
||
const later = platformSectionMerge(state({ phase: 'capturing', review: undefined, evidence: undefined, capture: undefined,
|
||
links: viewerLinks({ repository: pullRequest.repository, headSha: 'e'.repeat(40), runId: '44', runAttempt: '1', configPath: '.expo-explore/config.yaml' }),
|
||
pullRequest: { ...pullRequest, head: { ref: 'feature', sha: 'e'.repeat(40) } } }), 'ios').merge(single);
|
||
assert.equal(readCommentRecords(later).previous.ios.runId, '42');
|
||
assert.match(titleOf(later), /^### 3 screens changed · /);
|
||
});
|
||
|
||
test('a rerun keeps the attempt it replaces up until the new attempt finishes', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const ready = platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined);
|
||
const attempt3 = viewerLinks({ repository: pullRequest.repository, headSha, runId: '42', runAttempt: '3', configPath: '.expo-explore/config.yaml' });
|
||
const rerun = platformSectionMerge(state({ phase: 'capturing', review: undefined, evidence: undefined, links: attempt3 }), 'ios').merge(ready);
|
||
assert.equal(visible(rerun).split('\n')[1], `> **New run in progress** for [bbbbbbbb](<https://github.com/expo/explore-playground/commit/${headSha}>) · capturing · [run](<https://github.com/expo/explore-playground/actions/runs/42/attempts/3>)`);
|
||
assert.equal(titleOf(rerun), titleOf(ready));
|
||
assert.deepEqual(listOf(rerun), listOf(ready));
|
||
assert.equal(readCommentRecords(rerun).previous.ios.runAttempt, '2');
|
||
// The same attempt writing again (capture progress, a republish) keeps nothing.
|
||
assert.equal(readCommentRecords(platformSectionMerge(state({ phase: 'capturing', review: undefined, evidence: undefined }), 'ios').merge(ready)).previous, undefined);
|
||
const done = platformSectionMerge(state({ artifactOnly: true, links: { ...attempt3, viewer: state().links.viewer } }), 'ios').merge(rerun);
|
||
assert.equal(readCommentRecords(done).previous, undefined);
|
||
assert.doesNotMatch(done, /New run in progress/);
|
||
});
|
||
|
||
test('over budget, the previous run\'s records go before the hidden state is cut', async () => {
|
||
const { readCommentRecords, renderCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const previous = platformRecordOf(state({ artifactOnly: true }));
|
||
const nextHead = 'd'.repeat(40);
|
||
const current = platformRecordOf(state({ phase: 'capturing', review: undefined, evidence: undefined, pullRequest: { ...pullRequest, head: { ref: 'feature', sha: nextHead } },
|
||
links: viewerLinks({ repository: pullRequest.repository, headSha: nextHead, runId: '43', runAttempt: '1', configPath: '.expo-explore/config.yaml' }) }));
|
||
const records = { current: { ios: current }, previous: { ios: previous }, history: [] };
|
||
const full = renderCommentRecords(records);
|
||
assert.match(full, /New run in progress/);
|
||
assert.deepEqual(Object.keys(readCommentRecords(full).previous), ['ios']);
|
||
const without = renderCommentRecords({ current: { ios: current }, history: [] });
|
||
assert.equal(renderCommentRecords(records, undefined, without.length + 10), without);
|
||
// Once the new run shows a finished target, the previous records it still carries are hidden: they go before the list shrinks.
|
||
const twelve = review(Array.from({ length: 12 }, (_, index) => surface(`screen-${index}`, 'modified', { extra: {
|
||
review: { status: 'modified', description: `Changed ${index} in a way that takes a sentence to describe for the budget check.` },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage: 1, threshold: 0.1, tool: '[email protected]' } } })));
|
||
const finished = platformRecordOf(state({ artifactOnly: true, review: twelve, pullRequest: { ...pullRequest, head: { ref: 'feature', sha: nextHead } },
|
||
links: viewerLinks({ artifactUrl: 'https://github.com/expo/explore-playground/actions/runs/43/artifacts/7700', repository: pullRequest.repository, headSha: nextHead, runId: '43', runAttempt: '1', configPath: '.expo-explore/config.yaml' }) }));
|
||
const android = { ...current, platform: 'android' };
|
||
const hidden = { current: { ios: finished, android }, previous: { android: { ...previous, platform: 'android' } }, history: [] };
|
||
const lean = renderCommentRecords({ ...hidden, previous: {} });
|
||
assert.equal(renderCommentRecords(hidden, undefined, lean.length + 10), lean);
|
||
assert.equal(listOf(lean).length, 11, 'ten screens and the link to the rest');
|
||
});
|
||
|
||
test('the deployments line names every hosted preview the comment links right now, current and previous', async () => {
|
||
const { platformSectionMerge, existingDeploymentMarker } = await import('../../dist/report/pr-comment-body.js');
|
||
const marker = '<!-- expo-explore-deployments:acme-explore--abc123 -->';
|
||
const preview = previewLinks('https://acme-explore--abc123.expo.app/p/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', Date.now() + 86_400_000);
|
||
const deployed = state({ links: { ...state().links, preview } });
|
||
const single = platformSectionMerge(deployed, undefined).merge(undefined);
|
||
assert.equal(single.split('\n')[1], marker);
|
||
assert.equal(existingDeploymentMarker(single), marker);
|
||
assert.equal(platformSectionMerge(state(), undefined).merge(undefined).includes('expo-explore-deployments'), false);
|
||
// A redeploy of the same run replaces the record and so the line; a newer run keeps the old preview in history.
|
||
const redeployed = state({ links: { ...state().links, preview: previewLinks('https://acme-explore--def456.expo.app/p/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', Date.now() + 86_400_000) } });
|
||
assert.equal(platformSectionMerge(redeployed, 'ios').merge(single).split('\n')[1], '<!-- expo-explore-deployments:acme-explore--def456 -->');
|
||
const nextHead = 'd'.repeat(40);
|
||
const next = state({ links: { ...viewerLinks({ repository: pullRequest.repository, headSha: nextHead, runId: '43', runAttempt: '1', configPath: '.expo-explore/config.yaml' }),
|
||
preview: previewLinks('https://acme-explore--def456.expo.app/p/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', Date.now() + 86_400_000) }, pullRequest: { ...pullRequest, head: { ref: 'feature', sha: nextHead } } });
|
||
assert.equal(platformSectionMerge(next, 'ios').merge(single).split('\n')[1], '<!-- expo-explore-deployments:acme-explore--abc123,acme-explore--def456 -->');
|
||
// While the new run works the previous run's preview is still the one on show.
|
||
const working = platformSectionMerge({ ...next, phase: 'capturing', review: undefined, evidence: undefined, links: viewerLinks({ repository: pullRequest.repository, headSha: nextHead, runId: '43', runAttempt: '1', configPath: '.expo-explore/config.yaml' }) }, 'ios').merge(single);
|
||
assert.equal(working.split('\n')[1], marker);
|
||
assert.equal(existingDeploymentMarker(`${CI_COMMENT_MARKER}\n<!-- expo-explore-deployments:Bad Host -->\nx`), undefined);
|
||
});
|
||
|
||
test('a report whose hosted preview is still deploying says so instead of linking the central Viewer', () => {
|
||
const deploying = renderPublicComment(state({ links: { ...state().links, deploying: true }, artifactOnly: true }));
|
||
assert.equal(titleOf(deploying), '### 3 screens changed · report deploying…', 'the heading says the report is not ready');
|
||
assert.ok(!deploying.includes('explore-preview.expo.app'), 'no download-and-drop Viewer link while the hosted report is on its way');
|
||
assert.match(subtitleOf(deploying), /^<sub>iOS · \[bbbbbbbb\]/, 'nothing to link the platform to yet');
|
||
assert.match(deploying, /\n- Added `\/cats\/care-guide`\n/);
|
||
assert.equal(deploying.split('\n').at(-1), `<sub>${workflowRun} · ${configLink}</sub>`);
|
||
const deployed = renderPublicComment(state({ links: { ...state().links, deploying: true,
|
||
preview: previewLinks('https://acme-explore--1lcbdcpwn3.expo.app/p/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', null) } }));
|
||
assert.match(titleOf(deployed), /^### 3 screens changed · \[Review in Explore\]\(<https:\/\/acme-explore--1lcbdcpwn3\.expo\.app[^>]*>\)$/, 'once deployed the hosted report is the heading');
|
||
const record = JSON.parse(JSON.stringify(platformRecordOf(state({ links: { ...state().links, deploying: true }, artifactOnly: true }))));
|
||
assert.equal(record.links.report, undefined, 'the record carries no report link while deploying');
|
||
assert.equal(parsePlatformRecord(record).links.deploying, true, 'the deploying flag survives the comment round trip');
|
||
});
|
||
|
||
test('a screen whose screenshots match but whose accessibility tree changed is listed after the visual changes, tagged accessibility only', () => {
|
||
const accessibilityOnly = (id) => surface(id, 'modified', { extra: {
|
||
review: { status: 'modified', description: 'Added button “Edit tempo”.', facts: [
|
||
{ category: 'element', templateId: 'element.added', bullet: 'Added button “Edit tempo”.' },
|
||
{ category: 'accessibility', templateId: 'accessibility.label-changed', bullet: 'Changed accessibility label from “lane-row-title-3” to “Clap”.' },
|
||
] },
|
||
visual: { verdict: 'unchanged', reason: 'matched', mismatchPercentage: 0, threshold: 0.1, tool: '[email protected]' },
|
||
sourceChange: sourceChange('Labelled the lane rows.'),
|
||
} });
|
||
const withAccessibility = review([...evidenceReview().surfaces, accessibilityOnly('device-picker'), accessibilityOnly('enable-midi')]);
|
||
const body = renderPublicComment(state({ review: withAccessibility, artifactOnly: true }));
|
||
assert.match(titleOf(body), /^### 5 screens changed · /);
|
||
assert.deepEqual(listOf(body).slice(3), [
|
||
`- [\`/device-picker\`](<${reportUrl}&view=compare&screen=device-picker>) · Added button “Edit tempo” · accessibility only`,
|
||
`- [\`/enable-midi\`](<${reportUrl}&view=compare&screen=enable-midi>) · Added button “Edit tempo” · accessibility only`]);
|
||
assert.doesNotMatch(body, /Before and After look the same/);
|
||
const record = parsePlatformRecord(JSON.parse(JSON.stringify(platformRecordOf(state({ review: withAccessibility, artifactOnly: true })))));
|
||
assert.deepEqual(record.changes.filter(change => change.group === 'accessibility-only').map(change => change.id), ['device-picker', 'enable-midi'],
|
||
'the group survives the comment round trip');
|
||
});
|
||
|
||
test('a comment whose only change is for assistive technology counts it like any other', () => {
|
||
const only = review([surface('device-picker', 'modified', { extra: {
|
||
review: { status: 'modified', description: 'Changed “Play” from a text to a button.', facts: [{ category: 'accessibility', templateId: 'accessibility.role-changed', bullet: 'Changed “Play” from a text to a button.' }] },
|
||
visual: { verdict: 'unchanged', reason: 'matched', mismatchPercentage: 0, threshold: 0.1, tool: '[email protected]' },
|
||
sourceChange: sourceChange('Made Play a button.'),
|
||
} })]);
|
||
const body = renderPublicComment(state({ review: only, artifactOnly: true }));
|
||
assert.match(titleOf(body), /^### 1 screen changed · /);
|
||
assert.deepEqual(listOf(body), [`- [\`/device-picker\`](<${reportUrl}&view=compare&screen=device-picker>) · Changed “Play” from a text to a button · accessibility only`]);
|
||
});
|
||
|
||
test('a screen accessibility-only on iOS and visibly modified on Android is listed as the visible change, whichever platform comes first', async () => {
|
||
const { platformSectionMerge } = await import('../../dist/report/pr-comment-body.js');
|
||
const settings = (verdict) => {
|
||
const changed = evidenceReview();
|
||
changed.surfaces[2].visual = { ...changed.surfaces[2].visual, verdict, mismatchPercentage: verdict === 'modified' ? 3.2 : 0 };
|
||
return changed;
|
||
};
|
||
const androidState = overrides => state({ artifactOnly: true, build: { ...state().build, platform: 'android' },
|
||
runtime: { ...state().runtime, platform: 'android', deviceModel: 'EasAndroidDevice01', osVersion: '15.0' }, ...overrides });
|
||
const androidLinks = viewerLinks({ artifactUrl: 'https://github.com/expo/explore-playground/actions/runs/42/artifacts/7700', repository: pullRequest.repository, headSha, runId: '42', runAttempt: '2', configPath: '.expo-explore/config.yaml' });
|
||
const ios = renderPublicComment(state({ review: settings('unchanged'), artifactOnly: true }));
|
||
assert.match(ios, /\n- \[`\/settings`\]\(<[^>]+>\) · [^\n]+ · accessibility only\n/, 'iOS alone: the screenshots match');
|
||
const both = platformSectionMerge(androidState({ review: settings('modified'), links: androidLinks }), 'android').merge(ios);
|
||
assert.match(both, /\n- \[`\/settings`\]\(<[^>]+artifact=7700&view=compare&screen=settings>\) · Changed button title from “Share” to “Share Example”\n/, 'the link opens Android, where it changed visibly');
|
||
assert.doesNotMatch(both, /accessibility only/);
|
||
const androidFirst = platformSectionMerge(state({ review: settings('unchanged'), artifactOnly: true }), 'ios')
|
||
.merge(platformSectionMerge(androidState({ review: settings('modified'), links: androidLinks }), 'android').merge(undefined));
|
||
assert.match(androidFirst, /\n- \[`\/settings`\]\(<[^>]+artifact=7700&view=compare&screen=settings>\) · Changed button title from “Share” to “Share Example”\n/);
|
||
});
|
||
|
||
test('screens whose comparison did not run are counted as not compared, never as a green "no change"', () => {
|
||
const uncompared = review([surface('settings', 'modified'), surface('tempo', 'modified')]);
|
||
const body = renderPublicComment(state({ review: uncompared, artifactOnly: true }));
|
||
assert.equal(titleOf(body), `### No compared screens changed · [Review in Explore](<${reportUrl}>)`);
|
||
assert.equal(statusOf(body, 'iOS'), `iOS · 2 not compared · ${runLink}`);
|
||
// A screen that was never captured (outside the capture scope) stays out of the count.
|
||
const outOfScope = surface('channel', 'modified');
|
||
for (const key of ['baseImage', 'headImage', 'baseOutcome', 'headOutcome']) delete outOfScope[key];
|
||
const quiet = renderPublicComment(state({ review: review([outOfScope]), artifactOnly: true, evidence: undefined, capture: { planned: { base: 0, head: 0 }, done: { base: 0, head: 0 }, baseFromCache: false } }));
|
||
assert.equal(titleOf(quiet), `### No screens changed · [Review in Explore](<${reportUrl}>)`);
|
||
assert.equal(statusOf(quiet, 'iOS'), undefined);
|
||
});
|
||
|
||
const duoTargets = [{ id: 'iphone', platform: 'ios', label: 'iPhone 17' }, { id: 'duo-closed', platform: 'ios', label: 'iPhone Duo (closed)' },
|
||
{ id: 'duo-open', platform: 'ios', label: 'iPhone Duo (open)' }, { id: 'android', platform: 'android', label: 'Pixel 7' }];
|
||
const targetState = (target, overrides = {}) => state({ artifactOnly: true, target, targets: duoTargets,
|
||
build: { ...state().build, platform: target.platform }, runtime: { ...state().runtime, platform: target.platform, deviceModel: target.label }, ...overrides });
|
||
|
||
test('a config without targets writes the same records and comment as before targets: target ios and android are the platforms', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const perPlatform = [{ id: 'ios', platform: 'ios', label: 'iOS' }, { id: 'android', platform: 'android', label: 'Android' }];
|
||
const android = overrides => state({ artifactOnly: true, build: { ...state().build, platform: 'android' }, runtime: { ...state().runtime, platform: 'android', deviceModel: 'Pixel 9' }, ...overrides });
|
||
const before = platformSectionMerge(android(), 'android').merge(platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined));
|
||
const after = platformSectionMerge(android({ target: perPlatform[1], targets: perPlatform }), 'android')
|
||
.merge(platformSectionMerge(state({ artifactOnly: true, target: perPlatform[0], targets: perPlatform }), 'ios').merge(undefined));
|
||
assert.equal(after, before, 'byte-identical comment, record lines included');
|
||
const record = platformRecordOf(state({ target: perPlatform[0], targets: perPlatform }));
|
||
assert.deepEqual(Object.keys(record).slice(0, 3), ['v', 'platform', 'runId'], 'no target, label or order fields');
|
||
// A writer that knows its target only from the CLI flag (the state names none) keys its record the same way.
|
||
assert.equal(platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined), renderPublicComment(state({ artifactOnly: true, target: perPlatform[0] })));
|
||
assert.deepEqual(Object.keys(readCommentRecords(after).current), ['ios', 'android']);
|
||
});
|
||
|
||
/**
|
||
* brentvatne/euxy#172, review run 39362642343: five targets, two strings changed. Tempo's footnote sits low in a
|
||
* scrolling sheet. On the closed iPhone Duo the accessibility tree has the new text but the screenshots match: the
|
||
* footnote runs past the bottom of the screen (`offScreen`, from the elements' bounds). On the half-open and opened Duo
|
||
* neither the tree nor the pixels have it. Android captures no dark appearance.
|
||
*/
|
||
const euxyTargets = [{ id: 'iphone', platform: 'ios', label: 'iPhone 17' }, { id: 'duo-closed', platform: 'ios', label: 'iPhone Duo (closed)' },
|
||
{ id: 'duo-half-open', platform: 'ios', label: 'iPhone Duo (half-open)' }, { id: 'duo-open', platform: 'ios', label: 'iPhone Duo (open)' },
|
||
{ id: 'android', platform: 'android', label: 'Android' }];
|
||
const footnote = 'Added text “Changes apply live while playing. Base resolution sets the step grid for lanes you add next.”.';
|
||
const footnoteText = 'Added text “Changes apply live while playing\\. Base resolution sets the step grid for lanes you add next\\.”';
|
||
function euxyReview(target, options = {}) {
|
||
const own = { sourceImpact: ['own'], sourceScope: 'screen' };
|
||
const matched = { verdict: 'unchanged', reason: 'matched', mismatchPercentage: 0, threshold: 0.1, tool: '[email protected]' };
|
||
const changed = (description, mismatchPercentage) => ({ ...own,
|
||
review: { status: 'modified', description, facts: [{ category: 'element', templateId: 'element.added', bullet: description }] },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage, threshold: 0.1, tool: '[email protected]' } });
|
||
const inTreeOnly = { ...changed(footnote, 0), visual: matched };
|
||
if (options.offScreen !== false) inTreeOnly.review.offScreen = true;
|
||
if (options.diffFailed) inTreeOnly.visual = { ...matched, reason: 'diff-failed' };
|
||
const unseen = { ...own, review: { status: 'unchanged', description: 'No visible change' }, visual: matched };
|
||
const tempo = target === 'duo-closed' ? inTreeOnly : ['duo-half-open', 'duo-open'].includes(target) ? unseen : changed(footnote, 0.11);
|
||
const rename = changed('Added text “Pattern name”.', 0.16);
|
||
const schemes = target === 'android' ? [''] : ['', '@dark'];
|
||
return review(schemes.flatMap(scheme => [
|
||
surface(`tempo${scheme}`, 'modified', { route: '/tempo', extra: structuredClone(tempo) }),
|
||
surface(`rename-pattern${scheme}`, 'modified', { route: '/rename-pattern', extra: structuredClone(rename) }),
|
||
]));
|
||
}
|
||
async function euxyRows(reviewOf = euxyReview) {
|
||
const { platformSectionMerge } = await import('../../dist/report/pr-comment-body.js');
|
||
let body;
|
||
for (const target of euxyTargets) body = platformSectionMerge(state({ artifactOnly: true, target, targets: euxyTargets, review: reviewOf(target.id),
|
||
build: { ...state().build, platform: target.platform }, runtime: { ...state().runtime, platform: target.platform, deviceModel: target.label } }), target.id).merge(body);
|
||
return { body, rows: listOf(body).map(line => line.replace(/\(<[^>]+>\)/, '')) };
|
||
}
|
||
|
||
test('a change whose elements run past the screen on a device is named off-screen there, beside the devices that show it (euxy#172)', async () => {
|
||
const { body, rows } = await euxyRows();
|
||
assert.deepEqual(rows, [
|
||
`- [\`/tempo\`] · ${footnoteText} · iPhone 17, Android only · off-screen on iPhone Duo \\(closed\\)`,
|
||
`- [\`/rename-pattern\`] · Added text “Pattern name”`,
|
||
`- [\`/tempo\`] (dark) · ${footnoteText} · iPhone 17 only · off-screen on iPhone Duo \\(closed\\)`,
|
||
`- [\`/rename-pattern\`] (dark) · Added text “Pattern name” · not on Android`,
|
||
], 'the half-open and opened Duo have no trace of it, so they stay among the devices it is not on');
|
||
const { readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
assert.deepEqual(readCommentRecords(body).current['duo-closed'].changes.filter(change => change.offScreen).map(change => change.id), ['tempo', 'tempo@dark'],
|
||
'the mark survives the comment round trip');
|
||
});
|
||
|
||
test('without geometry that puts it past the screen, or with a failed comparison, a device keeps today\'s wording', async () => {
|
||
// The review euxy#172 published: the closed Duo's change is accessibility-only, with nothing saying where its elements are.
|
||
const { rows } = await euxyRows(target => euxyReview(target, { offScreen: false }));
|
||
assert.equal(rows[0], `- [\`/tempo\`] · ${footnoteText} · not on iPhone Duo \\(half\\-open\\), iPhone Duo \\(open\\)`);
|
||
assert.equal(rows[2], `- [\`/tempo\`] (dark) · ${footnoteText} · iPhone 17, iPhone Duo \\(closed\\) only`);
|
||
// A comparison that failed is no verdict at all: the closed Duo is not counted among the devices with the change.
|
||
const failed = await euxyRows(target => euxyReview(target, { diffFailed: true }));
|
||
assert.match(failed.rows[0], / · on iPhone 17, Android$/);
|
||
assert.doesNotMatch(failed.rows.join('\n'), /off-screen/);
|
||
});
|
||
|
||
test('a change one platform has and another lacks keeps "only" and "not on"', async () => {
|
||
// An iOS-only branch in a shared screen file: Android's capture of the screen shows no change, and says nothing off-screen.
|
||
const iosOnly = target => {
|
||
const reviewed = euxyReview(target);
|
||
if (target === 'android') for (const entry of reviewed.surfaces) Object.assign(entry, { review: { status: 'unchanged', description: 'No visible change' },
|
||
visual: { verdict: 'unchanged', reason: 'matched', mismatchPercentage: 0, threshold: 0.1, tool: '[email protected]' } });
|
||
return reviewed;
|
||
};
|
||
const { rows } = await euxyRows(iosOnly);
|
||
assert.equal(rows[0], `- [\`/tempo\`] · ${footnoteText} · iPhone 17 only · off-screen on iPhone Duo \\(closed\\)`);
|
||
assert.match(rows.find(row => row.startsWith('- [`/rename-pattern`] ·')), / · not on Android$/);
|
||
});
|
||
|
||
test('several targets share a comment: named in config order in the subtitle, each linking its report, keyed by id in the records and history', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const [iphone, closed, open, android] = duoTargets;
|
||
// Report jobs finish in any order; the comment follows config order.
|
||
let body;
|
||
for (const target of [android, open, iphone]) body = platformSectionMerge(targetState(target), target.id).merge(body);
|
||
body = platformSectionMerge(targetState(closed, { phase: 'capturing', review: undefined, evidence: undefined,
|
||
capture: { planned: { base: 3, head: 3 }, done: { base: 3, head: 1 }, baseFromCache: false, current: 'head' } }), 'duo-closed').merge(body);
|
||
assert.deepEqual([...body.matchAll(/^<!-- expo-explore-record:([a-z0-9-]+):/gm)].map(match => match[1]), ['iphone', 'duo-closed', 'duo-open', 'android']);
|
||
const records = readCommentRecords(body).current;
|
||
assert.deepEqual(Object.keys(records).sort(), ['android', 'duo-closed', 'duo-open', 'iphone']);
|
||
assert.deepEqual([records['duo-open'].platform, records['duo-open'].target, records['duo-open'].label, records['duo-open'].order], ['ios', 'duo-open', 'iPhone Duo (open)', 2]);
|
||
assert.deepEqual([records.android.platform, records.android.target, records.android.label, records.android.order], ['android', undefined, 'Pixel 7', 3]);
|
||
assert.equal(titleOf(body), `### 3 screens changed · [Review in Explore](<${reportUrl}>)`);
|
||
assert.equal(subtitleOf(body), `<sub>[iPhone 17](<${reportUrl}>) · iPhone Duo \\(closed\\) · [iPhone Duo \\(open\\)](<${reportUrl}>) · [Pixel 7](<${reportUrl}>) · ${commits} · updated Sep 11, 3:07 AM UTC</sub>`);
|
||
assert.equal(statusOf(body, 'iPhone Duo \\(closed\\)'), `iPhone Duo \\(closed\\) · capturing head 1/3 · ${runLink}`);
|
||
assert.equal(listOf(body).length, 3);
|
||
assert.doesNotMatch(body, / only\n|not on /);
|
||
// A newer run moves them all into history, still labelled and ordered by target, and keeps their review up while it works.
|
||
const nextHead = 'd'.repeat(40);
|
||
const next = platformSectionMerge(targetState(open, { phase: 'capturing', review: undefined, evidence: undefined, pullRequest: { ...pullRequest, head: { ref: 'feature', sha: nextHead } },
|
||
links: viewerLinks({ repository: pullRequest.repository, headSha: nextHead, runId: '43', runAttempt: '1', configPath: '.expo-explore/config.yaml' }) }), 'duo-open').merge(body);
|
||
assert.deepEqual(Object.keys(readCommentRecords(next).current), ['duo-open']);
|
||
assert.deepEqual(Object.keys(readCommentRecords(next).previous).sort(), ['android', 'duo-open', 'iphone'], 'the target still capturing had nothing to keep');
|
||
assert.match(visible(next).split('\n')[1], /^> \*\*New run in progress\*\* for \[dddddddd\]\(<[^>]+>\) · capturing on iPhone Duo \\\(open\\\) · \[run\]/);
|
||
assert.equal(subtitleOf(next), `<sub>[iPhone 17](<${reportUrl}>) · [iPhone Duo \\(open\\)](<${reportUrl}>) · [Pixel 7](<${reportUrl}>) · ${commits} · updated Sep 11, 3:07 AM UTC</sub>`);
|
||
const history = readCommentRecords(next).history;
|
||
assert.deepEqual(Object.keys(history[0].platforms).sort(), ['android', 'duo-closed', 'duo-open', 'iphone']);
|
||
assert.match(next, /\n- \[bbbbbbbb\]\(<[^>]+>\) · iPhone 17 🟩 3 changes \(.*?\) · iPhone Duo \\\(closed\\\) ⏳ did not finish \(.*?\) · iPhone Duo \\\(open\\\) 🟩 3 changes \(.*?\) · Pixel 7 🟩 3 changes /);
|
||
});
|
||
|
||
test('one of five targets failed: the comment shows the four reports and that failure, and no new-run banner (brentvatne/euxy#172)', async t => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const { publishFailureComment } = await import('../../dist/report/failure-comment.js');
|
||
const { mkdir, mkdtemp, rm, writeFile } = await import('node:fs/promises');
|
||
const { tmpdir } = await import('node:os');
|
||
const { join } = await import('node:path');
|
||
const targets = [...duoTargets, { id: 'pixel-dark', platform: 'android', label: 'Pixel 7 (dark)' }];
|
||
const configPath = '.expo-explore/config.yaml';
|
||
const firstAttempt = viewerLinks({ artifactUrl: 'https://github.com/expo/explore-playground/actions/runs/42/artifacts/7650', repository: pullRequest.repository, headSha, runId: '42', runAttempt: '1', configPath });
|
||
const working = viewerLinks({ repository: pullRequest.repository, headSha, runId: '42', runAttempt: '2', configPath });
|
||
// Attempt 1 published every target; attempt 2 captured them all at once, and the iPhone Duo (open) session failed.
|
||
let body;
|
||
for (const target of targets) body = platformSectionMerge(targetState(target, { targets, links: firstAttempt }), target.id).merge(body);
|
||
for (const target of targets) body = platformSectionMerge(targetState(target, { targets, links: working, phase: 'capturing', review: undefined, evidence: undefined,
|
||
capture: { planned: { base: 3, head: 3 }, done: { base: 3, head: 1 }, baseFromCache: false, current: 'head' } }), target.id).merge(body);
|
||
assert.match(visible(body), /New run in progress/);
|
||
for (const target of targets.filter(target => target.id !== 'duo-open')) body = platformSectionMerge(targetState(target, { targets }), target.id).merge(body);
|
||
|
||
const root = await mkdtemp(join(tmpdir(), 'explore-one-failed-')); t.after(() => rm(root, { recursive: true, force: true }));
|
||
for (const target of targets) await mkdir(join(root, 'capture', `explore-diagnostics-42-2-${target.id}`, 'explore-diagnostics'), { recursive: true });
|
||
await writeFile(join(root, 'capture', 'explore-diagnostics-42-2-duo-open', 'explore-diagnostics', 'error.json'), JSON.stringify({ code: 'ARGENT_TOOL_FAILED', stage: 'session',
|
||
message: 'fold: argent run fold failed: [Tool:fold] Service dependency failed', nextAction: 'Rerun the capture.', retryable: false, target: 'duo-open' }));
|
||
let comments = [{ id: 5, user: { login: 'github-actions[bot]' }, body }];
|
||
const transport = async (input, init = {}) => {
|
||
const url = new URL(input);
|
||
if (url.pathname === '/repos/expo/explore-playground/pulls/1') return Response.json({ state: 'open', draft: false, title: pullRequest.title, html_url: pullRequest.url,
|
||
base: { ref: 'main', sha: baseSha, repo: { id: 1, full_name: pullRequest.repository } }, head: { ref: pullRequest.head.ref, sha: headSha, repo: { full_name: pullRequest.repository } } });
|
||
if (url.pathname === '/repos/expo/explore-playground/issues/1/comments') return Response.json(comments);
|
||
if (url.pathname === '/repos/expo/explore-playground/issues/comments/5' && init.method === 'PATCH') {
|
||
comments = [{ ...comments[0], body: JSON.parse(init.body).body }]; return Response.json({ id: 5 });
|
||
}
|
||
throw new Error(`unexpected request ${init.method ?? 'GET'} ${url}`);
|
||
};
|
||
assert.deepEqual(await publishFailureComment({ repository: pullRequest.repository, pullNumber: 1, headSha, baseSha, runId: '42', runAttempt: '2', diagnosticsPath: root,
|
||
environment: { GITHUB_TOKEN: 'test-token' }, transport }), { posted: true });
|
||
const final = comments[0].body;
|
||
assert.doesNotMatch(visible(final), /New run in progress|New run failed/);
|
||
assert.equal(readCommentRecords(final).previous, undefined, "attempt 1's records go once every target of attempt 2 finished");
|
||
const records = readCommentRecords(final).current;
|
||
assert.deepEqual(targets.map(target => [records[target.id].runAttempt, records[target.id].phase]),
|
||
[['2', 'ready'], ['2', 'ready'], ['2', 'failed'], ['2', 'ready'], ['2', 'ready']]);
|
||
assert.match(statusOf(final, 'iPhone Duo \\(open\\)'), /^\*\*iPhone Duo \\\(open\\\) failed\*\* · session · ARGENT\\_TOOL\\_FAILED\\: fold\\: argent run fold failed/);
|
||
// Each published target links its report; the failure's record is the newest, so it sets the updated time.
|
||
assert.ok(subtitleOf(final).startsWith(`<sub>[iPhone 17](<${reportUrl}>) · [iPhone Duo \\(closed\\)](<${reportUrl}>) · iPhone Duo \\(open\\) · [Pixel 7](<${reportUrl}>) · [Pixel 7 \\(dark\\)](<${reportUrl}>) · ${commits} · updated `), subtitleOf(final));
|
||
assert.equal(titleOf(final), `### 3 screens changed · [Review in Explore](<${reportUrl}>)`);
|
||
});
|
||
|
||
test('a comment written by a per-platform version keeps parsing, and malformed target records are dropped', async () => {
|
||
const { platformSectionMerge, readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const { encodeRecordPayload } = await import('../../dist/report/pr-comment-record.js');
|
||
const old = platformSectionMerge(state({ artifactOnly: true }), 'ios').merge(undefined);
|
||
// A run with targets follows a per-platform run: the iOS run lands in history under its old key and label.
|
||
const nextHead = 'd'.repeat(40);
|
||
const next = platformSectionMerge(targetState(duoTargets[0], { pullRequest: { ...pullRequest, head: { ref: 'feature', sha: nextHead } },
|
||
links: viewerLinks({ artifactUrl: 'https://github.com/expo/explore-playground/actions/runs/43/artifacts/7655', repository: pullRequest.repository, headSha: nextHead, runId: '43', runAttempt: '1', configPath: '.expo-explore/config.yaml' }) }), 'iphone').merge(old);
|
||
assert.deepEqual(Object.keys(readCommentRecords(next).current), ['iphone']);
|
||
assert.deepEqual(Object.keys(readCommentRecords(next).history[0].platforms), ['ios']);
|
||
assert.match(next, /\n- \[bbbbbbbb\]\(<[^>]+>\) · iOS 🟩 3 changes /);
|
||
const valid = platformRecordOf(targetState(duoTargets[2]));
|
||
const line = (key, record) => `<!-- expo-explore-record:${key}:${encodeRecordPayload(record)} -->`;
|
||
const parse = (...lines) => Object.keys(readCommentRecords([CI_COMMENT_MARKER, ...lines].join('\n')).current);
|
||
assert.deepEqual(parse(line('duo-open', valid)), ['duo-open']);
|
||
assert.deepEqual(parse(line('ios', valid)), [], 'the line names the record it carries');
|
||
assert.deepEqual(parse(line('base', { ...valid, target: 'base' })), [], 'reserved ids are not targets');
|
||
assert.deepEqual(parse(line('duo-open', { ...valid, label: 'x'.repeat(81) })), []);
|
||
assert.deepEqual(parse(line('duo-open', { ...valid, order: 8 })), []);
|
||
const many = Array.from({ length: 10 }, (_, index) => line(`t${index}`, { ...valid, target: `t${index}` }));
|
||
assert.equal(parse(...many).length, 8, 'a comment holds at most one record per configurable target');
|
||
const previous = readCommentRecords([CI_COMMENT_MARKER, line('duo-open', valid), `<!-- expo-explore-previous:iphone:${encodeRecordPayload({ ...valid, target: 'iphone' })} -->`, '<!-- expo-explore-previous:iphone:AAAA -->'].join('\n')).previous;
|
||
assert.deepEqual(Object.keys(previous), ['iphone'], 'previous records parse the same way');
|
||
});
|
||
|
||
test('with report.comment.map, the map draws the added, removed and visually changed screens, the screens one step before them, and tabs as a group', async () => {
|
||
const { readCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const edge = (from, to, kind) => ({ from, to, kind, sourceFile: 'src/app/_layout.tsx', provenance: 'source' });
|
||
const graph = edges => ({ contract: 'explore.navigation', edges, modalSurfaceIds: [], unresolved: [] });
|
||
const base = evidenceReview();
|
||
const dark = surface('settings@dark', 'modified', { route: '/settings', extra: base.surfaces[2].review && { review: base.surfaces[2].review, visual: base.surfaces[2].visual } });
|
||
const withNavigation = { ...base, surfaces: [...base.surfaces, dark], navigation: {
|
||
// home is a redirect, not a screen; cats and settings are tabs; cats changed only in source, so it is drawn as the way to care-guide.
|
||
head: graph([edge('cats', 'care-guide', 'push'), edge('cats', 'settings', 'tab'), edge('settings', 'cats', 'tab'), edge('home', 'cats', 'replace')]),
|
||
base: graph([edge('cats', 'sweet-bee', 'push'), edge('cats', 'settings', 'tab')]),
|
||
} };
|
||
// Off by default: most reviews do not need it, so a config opts in with report.comment.map.
|
||
assert.ok(!/<summary>Map/.test(renderPublicComment(state({ artifactOnly: true, review: withNavigation }))));
|
||
assert.equal(platformRecordOf(state({ review: withNavigation })).navigation, undefined);
|
||
const body = renderPublicComment(state({ artifactOnly: true, map: true, review: withNavigation }));
|
||
assert.deepEqual(summaries(body), ['Map (4 screens)']);
|
||
const map = body.slice(body.indexOf('```mermaid'), body.indexOf('```\n', body.indexOf('```mermaid')) + 3);
|
||
assert.equal(map, ['```mermaid', 'flowchart LR', ' subgraph tabs["Tabs"]', ' n0["/cats"]', ' n3["/settings"]', ' end',
|
||
' n1["/cats/care-guide"]', ' n2["/cats/sweet-bee"]', ' n0 -->|push| n1', ' n0 -.->|push| n2',
|
||
' classDef added stroke:#1a7f37,stroke-width:3px', ' class n1 added', ' classDef modified stroke:#bc4c00,stroke-width:3px', ' class n3 modified',
|
||
' classDef removed stroke:#cf222e,stroke-width:2px,stroke-dasharray:5 4', ' class n2 removed', '```'].join('\n'));
|
||
assert.ok(body.indexOf('\n- [`/settings`]') < body.indexOf('<summary>Map'), 'the map follows the list');
|
||
assert.match(body, /<summary>Map \(4 screens\)<\/summary>\n\n```mermaid\n/, 'a blank line opens the fence inside the collapsible');
|
||
assert.deepEqual(readCommentRecords(body).current.ios.navigation, platformRecordOf(state({ map: true, review: withNavigation })).navigation, 'the map is carried in the record');
|
||
|
||
// A label cannot end itself, its line or the fence.
|
||
const odd = { ...withNavigation, surfaces: withNavigation.surfaces.map(entry => entry.id === 'care-guide'
|
||
? { ...entry, head: { ...entry.head, logicalPath: 'cats/"a"#<b>`c`' } } : entry) };
|
||
assert.match(renderPublicComment(state({ map: true, review: odd })), /\n {2}n1\["\/cats\/#quot;a#quot;#35;#lt;b#gt;#96;c#96;"\]\n/);
|
||
// No navigation, or nothing changed visually: no map.
|
||
assert.ok(!summaries(renderPublicComment(state({ map: true }))).includes('Map (4 screens)'));
|
||
const unchanged = { ...withNavigation, surfaces: withNavigation.surfaces.filter(entry => entry.status !== 'added' && entry.status !== 'removed' && !entry.id.startsWith('settings')) };
|
||
assert.ok(!/<summary>Map/.test(renderPublicComment(state({ map: true, review: unchanged }))));
|
||
});
|
||
|
||
test('the map is dropped before the list shrinks, and a malformed map is dropped from the record', async () => {
|
||
const { encodeRecordPayload } = await import('../../dist/report/pr-comment-record.js');
|
||
const { readCommentRecords, renderCommentRecords } = await import('../../dist/report/pr-comment-body.js');
|
||
const record = { ...platformRecordOf(state()), navigation: { nodes: [{ route: '/a', group: 'modified' }, { route: '/b' }], edges: [{ from: 1, to: 0, kinds: ['push'] }] } };
|
||
const records = { current: { ios: record }, history: [] };
|
||
assert.match(renderCommentRecords(records), /<summary>Map \(2 screens\)<\/summary>/);
|
||
const tight = renderCommentRecords(records, undefined, renderCommentRecords(records).length - 1);
|
||
assert.ok(!tight.includes('```mermaid'));
|
||
assert.equal(listOf(tight).length, 3, 'the list is intact');
|
||
const parse = navigation => readCommentRecords(`<!-- expo-explore-record:ios:${encodeRecordPayload({ ...record, navigation })} -->`).current.ios;
|
||
assert.deepEqual(parse(record.navigation).navigation, record.navigation);
|
||
for (const navigation of [{ ...record.navigation, edges: [{ from: 0, to: 2, kinds: ['push'] }] }, { ...record.navigation, edges: [{ from: 0, to: 1, kinds: ['teleport'] }] },
|
||
{ ...record.navigation, nodes: [{ route: '/a', group: 'needs-review' }, { route: '/b' }] }, { nodes: [], edges: [] }]) {
|
||
const parsed = parse(navigation);
|
||
assert.ok(parsed, 'the rest of the record still parses');
|
||
assert.equal(parsed.navigation, undefined);
|
||
}
|
||
});
|
||
|
||
test('an excluded route is neither listed nor counted as an app-wide screen', () => {
|
||
const excluded = surface('channel', 'modified', { extra: { sourceScope: 'app-wide', excluded: true,
|
||
sourceChange: { ...sourceChange('Dependency update.', 'global'), files: [{ path: 'package.json', status: 'modified', relation: 'global', hunks: [], facts: [], truncated: false }] } } });
|
||
for (const key of ['baseImage', 'headImage', 'baseOutcome', 'headOutcome']) delete excluded[key];
|
||
const body = renderPublicComment(state({ review: review([...evidenceReview().surfaces, excluded]) }));
|
||
assert.doesNotMatch(body, /\/channel|App-wide source changes|not compared/);
|
||
});
|
||
|
||
test('an excluded route that is new is not one of the changed screens, nor of the ones the list links to', () => {
|
||
const screens = Array.from({ length: 11 }, (_, index) => surface(`screen-${index}`, 'modified', { extra: {
|
||
review: { status: 'modified', description: `Changed ${index}` },
|
||
visual: { verdict: 'modified', reason: 'repeated-cross-build-delta', mismatchPercentage: 1, threshold: 0.1, tool: '[email protected]' },
|
||
} }));
|
||
const excluded = surface('channel', 'added', { route: '/c/[channel]', extra: { excluded: true } });
|
||
for (const key of ['baseImage', 'headImage', 'baseOutcome', 'headOutcome']) delete excluded[key];
|
||
const body = renderPublicComment(state({ review: review([...screens, excluded]) }));
|
||
assert.equal(titleOf(body), `### 11 screens changed · [Review in Explore](<${reportUrl}>)`);
|
||
assert.equal(listOf(body).at(-1), `- [1 more screen in the report](<${reportUrl}>)`);
|
||
assert.doesNotMatch(body, /\/c\/\[channel\]/);
|
||
});
|