From 853dd648f26c01f451301a17fad902dc96698d0e Mon Sep 17 00:00:00 2001 From: seanperez Date: Thu, 12 Nov 2026 00:36:12 -0500 Subject: [PATCH] test(expo): list each video's marked parts in a published hand-off The hand-off manifest gets parts and before.parts lists as version 3. The workflow publishes them as numbered lists under the video's table, as attach does on a machine whose gh can attach. Version 2 manifests, which sessions on older commits still write, are read as having no parts. Co-Authored-By: Claude Opus 5.5 --- .changeset/verify-attach-evidence-parts.md | 2 + .claude/skills/verify-clerk-expo/SKILL.md | 2 +- scripts/verify-attach.mjs | 82 +++++++++++++--- scripts/verify-attach.test.mjs | 109 +++++++++++++++++++-- 4 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 .changeset/verify-attach-evidence-parts.md diff --git a/.changeset/verify-attach-evidence-parts.md b/.changeset/verify-attach-evidence-parts.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/verify-attach-evidence-parts.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.claude/skills/verify-clerk-expo/SKILL.md b/.claude/skills/verify-clerk-expo/SKILL.md index 683b6839710..4ac5d43e450 100644 --- a/.claude/skills/verify-clerk-expo/SKILL.md +++ b/.claude/skills/verify-clerk-expo/SKILL.md @@ -238,7 +238,7 @@ For screenshots, show each screen the change touches, and each state of a screen Some states are out of reach for a test user on the standard settings with the default roles. Try `host.useSettings` in the spec, or test data that the spec seeds, first. When neither reaches it, force the state with a small local patch to the build. Never commit or push the patch. Put the same patch on the before build and the after build, and run both on a local device, because `attach` refuses a run on a session build with uncommitted changes. In the pull request, say exactly what the patch changed and why no test user could reach the state without it. -`attach` puts the files in the description of the pull request with `gh pr edit --attach`. It writes one block for the platform of the run, between the comments `` and ``, or the same two with `android`. The block is a line and then one or more tables. The line leads with the result and names the run, the device, the commit, and how many tests passed. With `--before` it also names the before run, its commit, and how many of its tests passed. With `--before`, one table has a `Before` and an `After` column, and a row for each screenshot or one row for the video. A `--new-screen` row has `New screen` in place of its before. Without it, the screenshots are two across, each under its label in code font, and the video is alone in a table under its name. Each screenshot is 280 pixels wide. +`attach` puts the files in the description of the pull request with `gh pr edit --attach`. It writes one block for the platform of the run, between the comments `` and ``, or the same two with `android`. The block is a line and then one or more tables. The line leads with the result and names the run, the device, the commit, and how many tests passed. With `--before` it also names the before run, its commit, and how many of its tests passed. With `--before`, one table has a `Before` and an `After` column, and a row for each screenshot or one row for the video. A `--new-screen` row has `New screen` in place of its before. Without it, the screenshots are two across, each under its label in code font, and the video is alone in a table under its name. Each screenshot is 280 pixels wide. Under a video's table, a numbered list names the parts the run marked with `host.evidence`, in the order they started, so a reviewer, and CodeRabbit, which cannot watch a video, can tell which states the video shows. With `--before`, one list says each video shows those parts when both runs marked the same ones, and otherwise the block has a list for the before video and one for the after video. A part whose test failed inside its block says it stopped before it finished. Past 20 parts, the last line says how many more there are. The first `attach` of a platform adds its block after the description. A later `attach` of that platform replaces its block, so the description holds the latest run of each platform and the media does not pile up. `attach` changes nothing outside the block. Keep both comments of a block or remove both: `attach` refuses a description that has one without the other, or either one twice. A comment counts only when it is a whole line outside a code fence, so a description can quote one in a sentence or show a whole block as an example. diff --git a/scripts/verify-attach.mjs b/scripts/verify-attach.mjs index 65483065014..3e090c52d93 100644 --- a/scripts/verify-attach.mjs +++ b/scripts/verify-attach.mjs @@ -22,7 +22,9 @@ const LIMITS = { const ARTIFACT = 'verify-evidence'; const MANIFEST_FILE = 'manifest.json'; const SESSION_WORKFLOW = '.github/workflows/verify-remote.yml'; -const MANIFEST_VERSION = 2; +const MANIFEST_VERSION = 3; +// Version 2 came before the parts of the video, and sessions that started on an older commit still write it. +const VERSIONS_TAKEN = [2, MANIFEST_VERSION]; const SHAPE = { run: /^r\d{8}-\d{6}-[0-9a-f]{4}$/, @@ -35,8 +37,25 @@ const SHAPE = { actor: /^[A-Za-z0-9][A-Za-z0-9_-]{0,99}(\[bot\])?$/, }; -const MANIFEST_KEYS = ['v', 'pr', 'run', 'platform', 'device', 'commit', 'passed', 'flaky', 'total', 'files', 'before']; -const BEFORE_KEYS = ['run', 'commit', 'passed', 'flaky', 'total', 'files']; +const MANIFEST_KEYS = [ + 'v', + 'pr', + 'run', + 'platform', + 'device', + 'commit', + 'passed', + 'flaky', + 'total', + 'files', + 'before', + 'parts', +]; +const KEYS_OF_VERSION_2 = MANIFEST_KEYS.filter(key => key !== 'parts'); +const PART = /^[^\u0000-\u001f<>`|]{1,200}$/; +const MOST_PARTS = 20; +const BEFORE_KEYS = ['run', 'commit', 'passed', 'flaky', 'total', 'files', 'parts']; +const BEFORE_KEYS_OF_VERSION_2 = BEFORE_KEYS.filter(key => key !== 'parts'); const FILE_KEYS = ['name', 'bytes', 'sha256']; class Refused extends Error {} @@ -251,8 +270,8 @@ function parseFiles(list, what) { return files; } -function parseBefore(value, run, files) { - const raw = record(value, BEFORE_KEYS, 'before'); +function parseBefore(value, run, files, withParts) { + const raw = record(value, withParts ? BEFORE_KEYS : BEFORE_KEYS_OF_VERSION_2, 'before'); const before = { run: shaped(raw.run, SHAPE.run, 'the run of before'), commit: shaped(raw.commit, SHAPE.commit, 'the commit of before'), @@ -260,6 +279,7 @@ function parseBefore(value, run, files) { flaky: whole(raw.flaky, 0, LIMITS.tests, 'flaky of before'), total: whole(raw.total, 1, LIMITS.tests, 'total of before'), files: parseBeforeFiles(raw.files, files), + parts: withParts ? parseParts(raw.parts) : [], }; if (before.run === run) { refuse('before is the run itself'); @@ -286,6 +306,35 @@ function parseBeforeFiles(list, files) { return paired; } +function parseParts(list) { + if (!Array.isArray(list) || list.length > MOST_PARTS) { + refuse(`parts must be a list of at most ${MOST_PARTS}`); + } + return list.map((part, i) => { + if (typeof part !== 'string' || !PART.test(part)) { + refuse(`part ${i + 1} must be 1 to 200 characters of one line, without <, >, \` or |`); + } + return part; + }); +} + +const listed = (heading, parts) => + [`${heading} these parts, in order:`, ...parts.map((part, at) => `${at + 1}. ${part}`)].join('\n'); + +// The before video is cut to the before run's own parts, which differ when that run failed partway or seeded other users. +function partsSection(parts, beforeParts) { + if (beforeParts === null) { + return parts.length === 0 ? [] : [listed('The video shows', parts)]; + } + if (parts.length > 0 && parts.length === beforeParts.length && parts.every((part, at) => part === beforeParts[at])) { + return [listed('Each video shows', parts)]; + } + return [ + ...(beforeParts.length === 0 ? [] : [listed('The before video shows', beforeParts)]), + ...(parts.length === 0 ? [] : [listed('The after video shows', parts)]), + ]; +} + function parseManifest(text) { if (Buffer.byteLength(text) > LIMITS.manifestBytes) { refuse(`the manifest is over ${LIMITS.manifestBytes} bytes`); @@ -297,16 +346,17 @@ function parseManifest(text) { return refuse('the manifest is not JSON'); } const { v } = anObject(parsed, 'the manifest'); - if (v !== MANIFEST_VERSION) { + if (!VERSIONS_TAKEN.includes(v)) { const found = Number.isInteger(v) && v >= 0 && v <= 999 ? `version ${v}` : 'of no version this script knows'; - refuse(`the manifest is ${found}, and this script publishes only version ${MANIFEST_VERSION}`); + refuse(`the manifest is ${found}, and this script publishes only versions ${VERSIONS_TAKEN.join(' and ')}`); } - const raw = record(parsed, MANIFEST_KEYS, 'the manifest'); + const raw = record(parsed, v === MANIFEST_VERSION ? MANIFEST_KEYS : KEYS_OF_VERSION_2, 'the manifest'); + const parts = v === MANIFEST_VERSION ? parseParts(raw.parts) : []; if (raw.platform !== 'ios' && raw.platform !== 'android') { refuse('platform must be ios or android'); } const files = parseFiles(raw.files, 'files'); - const before = raw.before === null ? null : parseBefore(raw.before, raw.run, files); + const before = raw.before === null ? null : parseBefore(raw.before, raw.run, files, v === MANIFEST_VERSION); const all = everyFile({ files, before }); if (all.length > LIMITS.files) { refuse(`at most ${LIMITS.files} files in all`); @@ -329,6 +379,7 @@ function parseManifest(text) { total: whole(raw.total, 1, LIMITS.tests, 'total'), files, before, + parts, }; if (manifest.passed + manifest.flaky < 1 || manifest.passed + manifest.flaky > manifest.total) { refuse('passed and flaky must count at least one test and at most total'); @@ -486,11 +537,14 @@ function evidenceBlock(manifest, session, onDisk = file => file.name, uploaded = const media = uploaded === null ? everyFile(manifest).map(file => `![${isVideo(file.name) ? file.name : stem(file.name)}](./${onDisk(file)})`) - : mediaTables( - manifest.files.map(file => file.name), - before === null ? null : before.files.map(file => file !== null), - uploaded, - ); + : [ + ...mediaTables( + manifest.files.map(file => file.name), + before === null ? null : before.files.map(file => file !== null), + uploaded, + ), + ...partsSection(manifest.parts, before === null ? null : before.parts), + ]; return [start, '', line, ...media.flatMap(part => ['', part]), '', end].join('\n'); } diff --git a/scripts/verify-attach.test.mjs b/scripts/verify-attach.test.mjs index 2a6f2eb1a30..6d824d0e4e2 100644 --- a/scripts/verify-attach.test.mjs +++ b/scripts/verify-attach.test.mjs @@ -37,7 +37,7 @@ const JPG = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), Buffer.from('t const MP4 = Buffer.concat([Buffer.from([0, 0, 0, 0x18]), Buffer.from('ftypmp42'), Buffer.alloc(4096, 'v')]); const manifestFor = (files, change = {}) => ({ - v: 2, + v: 3, pr: 669, run: 'r20261008-052713-253e', platform: 'ios', @@ -48,6 +48,7 @@ const manifestFor = (files, change = {}) => ({ total: 3, files: files.map(([name, data]) => ({ name, bytes: data.length, sha256: sha256(data) })), before: null, + parts: [], ...change, }); @@ -151,6 +152,7 @@ const beforeFor = (files, change = {}) => ({ flaky: 0, total: 3, files: files.map(([name, data]) => ({ name, bytes: data.length, sha256: sha256(data) })), + parts: [], ...change, }); const WITH_BEFORE = manifestFor(MEDIA, { before: beforeFor(WAS_MEDIA) }); @@ -557,25 +559,54 @@ describe('the manifest', () => { const { before: _, ...versionOne } = { ...GOOD, v: 1 }; assert.equal( refusal(() => parseManifest(JSON.stringify(versionOne))), - 'the manifest is version 1, and this script publishes only version 2', + 'the manifest is version 1, and this script publishes only versions 2 and 3', ); assert.equal( - refusal(() => parseManifest(JSON.stringify({ ...GOOD, v: 3, layout: 'a key of a later version' }))), - 'the manifest is version 3, and this script publishes only version 2', + refusal(() => parseManifest(JSON.stringify({ ...GOOD, v: 4, layout: 'a key of a later version' }))), + 'the manifest is version 4, and this script publishes only versions 2 and 3', ); for (const v of ['2', 2.5, -1, 1000, null, '\n::error::pwned']) { assert.equal( refusal(() => parse({ v })), - 'the manifest is of no version this script knows, and this script publishes only version 2', + 'the manifest is of no version this script knows, and this script publishes only versions 2 and 3', JSON.stringify(v), ); } assert.equal( refusal(() => parseManifest(JSON.stringify({ device: 'x' }))), - 'the manifest is of no version this script knows, and this script publishes only version 2', + 'the manifest is of no version this script knows, and this script publishes only versions 2 and 3', ); }); + it('reads a version 2 manifest, which has no parts, as one with no parts', () => { + const { parts: _, ...versionTwo } = { ...GOOD, v: 2 }; + assert.deepEqual(parseManifest(JSON.stringify(versionTwo)), { ...GOOD, parts: [] }); + assert.match( + refusal(() => parseManifest(JSON.stringify({ ...versionTwo, parts: [] }))), + /the manifest must have exactly the keys/, + ); + }); + + it('refuses parts that are not a short list of one-line labels without markup', () => { + assert.deepEqual(parseManifest(JSON.stringify({ ...GOOD, parts: ['search members'] })).parts, ['search members']); + for (const parts of [ + 'a label', + [''], + ['two\nlines'], + ['bold'], + ['a `tick`'], + ['a | pipe'], + ['x'.repeat(201)], + Array.from({ length: 21 }, () => 'a part'), + ]) { + assert.match( + refusal(() => parseManifest(JSON.stringify({ ...GOOD, parts }))), + /^part|^parts/, + JSON.stringify(parts).slice(0, 30), + ); + } + }); + it('accepts a before run, which may have passed nothing, with one file of the same kind for each file of the run', () => { assert.deepEqual(parseManifest(JSON.stringify(WITH_BEFORE)), WITH_BEFORE); }); @@ -1466,8 +1497,11 @@ describe('the step that edits the description', () => { const WAS_SHOT = [['before-profile.png', WAS_PNG]]; const CLIP = [['video.mp4', MP4]]; const WAS_CLIP = [['before-video.mp4', WAS_MP4]]; - const published = async (media, wasMedia) => { - const manifest = manifestFor(media, wasMedia === undefined ? {} : { before: beforeFor(wasMedia) }); + const published = async (media, wasMedia, parts = [], beforeParts = parts) => { + const manifest = manifestFor(media, { + parts, + ...(wasMedia === undefined ? {} : { before: beforeFor(wasMedia, { parts: beforeParts }) }), + }); const fake = fakeGh({ ...PULL, body: '' }); const { outcome } = publish(await fetched(manifest, [...media, ...(wasMedia ?? [])]), fake); assert.equal(outcome, 'published'); @@ -1515,6 +1549,65 @@ describe('the step that edits the description', () => { ); }); + it('lists the parts of a video under its table, in the order the run marked them', async () => { + const parts = ['search members for a +clerk_test address', 'search members for a name nobody has']; + assert.equal( + await published(CLIP, WAS_CLIP, parts), + laidOut( + `${SAID}${WAS}`, + '', + '| Before | After |', + '| :---: | :---: |', + `| ${asset(2)} | ${asset(1)} |`, + '', + 'Each video shows these parts, in order:', + `1. ${parts[0]}`, + `2. ${parts[1]}`, + ), + ); + assert.equal( + await published(CLIP, undefined, parts), + laidOut( + SAID, + '', + '| `video` |', + '| :---: |', + `| ${asset(1)} |`, + '', + 'The video shows these parts, in order:', + `1. ${parts[0]}`, + `2. ${parts[1]}`, + ), + ); + assert.equal( + await published(CLIP, WAS_CLIP, parts, [parts[0]]), + laidOut( + `${SAID}${WAS}`, + '', + '| Before | After |', + '| :---: | :---: |', + `| ${asset(2)} | ${asset(1)} |`, + '', + 'The before video shows these parts, in order:', + `1. ${parts[0]}`, + '', + 'The after video shows these parts, in order:', + `1. ${parts[0]}`, + `2. ${parts[1]}`, + ), + ); + }); + + it('reads the before of a version 2 manifest as having no parts', () => { + const { parts: _, ...versionTwo } = { ...WITH_BEFORE, v: 2 }; + const { parts: __, ...beforeTwo } = WITH_BEFORE.before; + assert.deepEqual(parseManifest(JSON.stringify({ ...versionTwo, before: beforeTwo })), { + ...WITH_BEFORE, + parts: [], + before: { ...WITH_BEFORE.before, parts: [] }, + }); + }); + it('lays out a flow that is new as its video alone, in a table that keeps it from the full width', async () => { assert.equal(await published(CLIP), laidOut(SAID, '', '| `video` |', '| :---: |', `| ${asset(1)} |`)); });