diff --git a/.github/scripts/frame-slack-screenshots.mjs b/.github/scripts/frame-slack-screenshots.mjs new file mode 100644 index 0000000000..ef3c9db937 --- /dev/null +++ b/.github/scripts/frame-slack-screenshots.mjs @@ -0,0 +1,55 @@ +import fs from 'node:fs' +import path from 'node:path' +import process from 'node:process' + +import sharp from 'sharp' + +export async function frameSlackScreenshots({ + inputPath, + outputPath, + outputDir, +}) { + const payload = JSON.parse(fs.readFileSync(inputPath, 'utf8')) + if (!Array.isArray(payload.file_uploads)) { + throw new Error('Slack upload payload must contain a file_uploads array') + } + + fs.mkdirSync(outputDir, {recursive: true}) + const framedUploads = [] + for (const [index, upload] of payload.file_uploads.entries()) { + if ( + typeof upload.file !== 'string' || + typeof upload.filename !== 'string' + ) { + throw new Error(`Invalid Slack file upload at index ${index}`) + } + const filename = `${path.parse(path.basename(upload.filename)).name}.png` + const framedFile = path.join(outputDir, filename) + await sharp(upload.file) + .resize(1600, 1200, {fit: 'contain', background: '#f8f8f8'}) + .png() + .toFile(framedFile) + framedUploads.push({ + ...upload, + file: framedFile, + filename, + highlight_type: 'png', + }) + } + + payload.file_uploads = framedUploads + fs.writeFileSync(outputPath, `${JSON.stringify(payload)}\n`) +} + +if ( + process.argv[1] && + path.resolve(process.argv[1]) === path.resolve(import.meta.filename) +) { + const [inputPath, outputPath, outputDir] = process.argv.slice(2) + if (!inputPath || !outputPath || !outputDir) { + throw new Error( + 'Usage: frame-slack-screenshots.mjs ', + ) + } + await frameSlackScreenshots({inputPath, outputPath, outputDir}) +} diff --git a/.github/scripts/frame-slack-screenshots.test.mjs b/.github/scripts/frame-slack-screenshots.test.mjs new file mode 100644 index 0000000000..9c018a7f7a --- /dev/null +++ b/.github/scripts/frame-slack-screenshots.test.mjs @@ -0,0 +1,58 @@ +import assert from 'node:assert/strict' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import sharp from 'sharp' + +import {frameSlackScreenshots} from './frame-slack-screenshots.mjs' + +test('frames Slack screenshots as 4:3 PNGs', async t => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'maestro-slack-frame-')) + t.after(() => fs.rmSync(root, {recursive: true, force: true})) + const source = path.join(root, 'source.png') + const input = path.join(root, 'input.json') + const output = path.join(root, 'output.json') + const outputDir = path.join(root, 'images') + await sharp({ + create: { + width: 2, + height: 4, + channels: 3, + background: '#ffffff', + }, + }) + .png() + .toFile(source) + fs.writeFileSync( + input, + JSON.stringify({ + file_uploads: [ + { + file: source, + filename: '1-android-login.png', + alt_text: 'Android failure screenshot for login', + }, + ], + }), + ) + + await frameSlackScreenshots({inputPath: input, outputPath: output, outputDir}) + + const payload = JSON.parse(fs.readFileSync(output, 'utf8')) + const {data, info} = await sharp(payload.file_uploads[0].file) + .raw() + .toBuffer({resolveWithObject: true}) + const pixelAt = (x, y) => { + const offset = (y * info.width + x) * info.channels + return Array.from(data.subarray(offset, offset + 3)) + } + + assert.equal(info.width, 1600) + assert.equal(info.height, 1200) + assert.deepEqual(pixelAt(0, 0), [248, 248, 248]) + assert.deepEqual(pixelAt(800, 600), [255, 255, 255]) + assert.equal(payload.file_uploads[0].filename, '1-android-login.png') + assert.equal(payload.file_uploads[0].highlight_type, 'png') +}) diff --git a/.github/scripts/maestro-slack.mjs b/.github/scripts/maestro-slack.mjs new file mode 100644 index 0000000000..4c4c0dd27f --- /dev/null +++ b/.github/scripts/maestro-slack.mjs @@ -0,0 +1,330 @@ +import path from 'node:path' + +const CAROUSEL_LIMIT = 10 + +function concise(value, limit) { + return value.length > limit ? `${value.slice(0, limit - 1)}…` : value +} + +function slackEscape(value) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') +} + +function mrkdwnText(value, limit) { + return concise(slackEscape(value), limit) +} + +function pluralize(count, singular) { + return `${count} ${singular}${count === 1 ? '' : 's'}` +} + +function stateFor(platforms) { + if (platforms.some(platform => platform.status === 'cancelled')) { + return 'cancelled' + } + if (platforms.some(platform => platform.failures.length > 0)) { + return 'failed' + } + if (platforms.some(platform => platform.failed)) { + return 'setup_failed' + } + return 'passed' +} + +function statePresentation(state, failureCount) { + if (state === 'cancelled') { + return { + header: '⏹️ Nightly Maestro E2E cancelled', + summary: 'results may be incomplete', + fallback: 'Nightly Maestro E2E was cancelled. Results may be incomplete.', + } + } + if (state === 'setup_failed') { + return { + header: '⚠️ Nightly Maestro setup failed', + summary: 'no complete test results', + fallback: 'Nightly Maestro E2E setup failed before tests could complete.', + } + } + if (state === 'failed') { + return { + header: '🚨 Nightly Maestro E2E failed', + summary: `${pluralize(failureCount, 'failed flow')}`, + fallback: `Nightly Maestro E2E failed with ${pluralize(failureCount, 'failed flow')}.`, + } + } + return { + header: '✅ Nightly Maestro E2E passed', + summary: 'all platforms passed', + fallback: 'Nightly Maestro E2E passed on all platforms.', + } +} + +function platformStatus(platform) { + if (platform.status === 'cancelled') return '⏹️ Cancelled' + if (platform.status === 'skipped') return '⏭️ Skipped' + if (platform.failures.length > 0) { + return `❌ Failed · ${pluralize(platform.failures.length, 'flow')}` + } + if (platform.failed && !platform.hasJUnit) return '⚠️ Setup failed' + if (platform.failed) return '❌ Failed' + return '✅ Passed' +} + +function selectFailures(platforms, limit = CAROUSEL_LIMIT) { + const queues = platforms.map(platform => + platform.failures.map(failure => ({platform, failure})), + ) + const selected = [] + + while (selected.length < limit && queues.some(queue => queue.length > 0)) { + for (const queue of queues) { + const next = queue.shift() + if (next) selected.push(next) + if (selected.length === limit) break + } + } + + return selected +} + +function uploadFilename({platform, failure}, index) { + const extension = path.extname(failure.screenshot) || '.png' + const slug = failure.name + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') + return `${index + 1}-${platform.name.toLowerCase()}-${slug || 'failed-flow'}${extension}` +} + +function buildUploadPayload(selectedFailures) { + return { + file_uploads: selectedFailures + .filter(({failure}) => failure.screenshot) + .map((entry, index) => ({ + file: entry.failure.screenshot, + filename: uploadFilename(entry, index), + highlight_type: 'png', + alt_text: `${entry.platform.name} failure screenshot for ${entry.failure.name}`, + })), + } +} + +function buildThreadPayload(platforms) { + const lines = ['*All Maestro failure details*'] + for (const platform of platforms) { + if (platform.failures.length === 0) continue + lines.push('', `*${slackEscape(platform.name)}*`) + for (const failure of platform.failures) { + lines.push( + `• *${mrkdwnText(failure.name, 140)}*\n ${mrkdwnText(failure.message, 300)}`, + ) + } + if (platform.artifactUrl) { + lines.push(`<${platform.artifactUrl}|Open ${platform.name} artifacts>`) + } + } + return {text: lines.join('\n')} +} + +function buildCarousel(selectedFailures, slackFileIds) { + let screenshotIndex = 0 + const elements = selectedFailures.map(({platform, failure}, index) => { + const slackFileId = failure.screenshot + ? slackFileIds[screenshotIndex++] + : undefined + return { + type: 'card', + block_id: `maestro_failure_${index + 1}`, + title: { + type: 'mrkdwn', + text: `*${mrkdwnText(failure.name, 140)}*`, + verbatim: true, + }, + subtitle: { + type: 'mrkdwn', + text: `${platform.name} · failed flow`, + verbatim: true, + }, + ...(slackFileId + ? { + hero_image: { + type: 'image', + slack_file: {id: slackFileId}, + alt_text: `${platform.name} failure screenshot for ${failure.name}`, + }, + } + : {}), + body: { + type: 'mrkdwn', + text: mrkdwnText(failure.message, 190), + verbatim: true, + }, + ...(platform.artifactUrl + ? { + subtext: { + type: 'mrkdwn', + text: `<${platform.artifactUrl}|Open logs and artifacts>`, + verbatim: true, + }, + } + : {}), + } + }) + + return {type: 'carousel', block_id: 'maestro_failures', elements} +} + +function diagnosticBlock(platform) { + const phase = mrkdwnText( + platform.phase || 'No phase metadata was captured', + 220, + ) + if (platform.status === 'cancelled') { + return { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${platform.name} cancelled*\nLatest phase: ${phase}\nResults may be incomplete.`, + }, + } + } + if (!platform.hasJUnit) { + return { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${platform.name} setup failed*\nLatest phase: ${phase}\nNo JUnit results were produced.`, + }, + } + } + return { + type: 'section', + text: { + type: 'mrkdwn', + text: `*${platform.name} job failed*\nLatest phase: ${phase}`, + }, + } +} + +function footerBlock(platforms, runUrl) { + const links = [`<${runUrl}|Open workflow>`] + for (const platform of platforms) { + if (platform.artifactUrl) { + links.push(`<${platform.artifactUrl}|${platform.name} artifacts>`) + } + } + return { + type: 'section', + text: {type: 'mrkdwn', text: links.join(' • ')}, + } +} + +export function buildSlackMessage({ + platforms, + sha, + runUrl, + commitUrl, + slackFileIds = [], +}) { + const state = stateFor(platforms) + const failureCount = platforms.reduce( + (total, platform) => total + platform.failures.length, + 0, + ) + const presentation = statePresentation(state, failureCount) + const allFailures = selectFailures(platforms, failureCount) + const selectedFailures = allFailures.slice(0, CAROUSEL_LIMIT) + const uploadPayload = buildUploadPayload(allFailures) + const detailBlocks = platforms + .filter( + platform => + platform.status === 'cancelled' || + (platform.failed && platform.failures.length === 0), + ) + .map(diagnosticBlock) + + if (selectedFailures.length > 0) { + detailBlocks.push(buildCarousel(selectedFailures, slackFileIds), { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Showing ${selectedFailures.length} of ${pluralize(failureCount, 'failed flow')} • full details and screenshots are in the thread`, + }, + ], + }) + } + + const shortSha = sha.slice(0, 12) + const blocks = [ + { + type: 'header', + text: {type: 'plain_text', text: presentation.header}, + }, + { + type: 'context', + elements: [ + { + type: 'mrkdwn', + text: `Commit <${commitUrl}|\`${shortSha}\`> • ${presentation.summary}`, + }, + ], + }, + { + type: 'section', + fields: platforms.map(platform => ({ + type: 'mrkdwn', + text: `*${platform.name}*\n${platformStatus(platform)}`, + })), + }, + ...(detailBlocks.length > 0 ? [{type: 'divider'}, ...detailBlocks] : []), + footerBlock(platforms, runUrl), + ] + + return { + state, + failureCount, + screenshotCount: uploadPayload.file_uploads.length, + uploadPayload, + threadPayload: buildThreadPayload(platforms), + payload: {text: presentation.fallback, blocks}, + } +} + +export function extractSlackFileIds(response) { + if (!response) return [] + + let parsed = response + if (typeof response === 'string') { + try { + parsed = JSON.parse(response) + } catch { + return [] + } + } + + const ids = [] + const seen = new Set() + function visit(value) { + if (Array.isArray(value)) { + for (const item of value) visit(item) + return + } + if (!value || typeof value !== 'object') return + if ( + typeof value.id === 'string' && + /^F[A-Z0-9]+$/.test(value.id) && + !seen.has(value.id) + ) { + seen.add(value.id) + ids.push(value.id) + } + for (const child of Object.values(value)) visit(child) + } + visit(parsed) + return ids +} diff --git a/.github/scripts/maestro-slack.test.mjs b/.github/scripts/maestro-slack.test.mjs new file mode 100644 index 0000000000..afa052d61e --- /dev/null +++ b/.github/scripts/maestro-slack.test.mjs @@ -0,0 +1,194 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs' +import {screenshotsByFlow} from './summarize-maestro.mjs' + +function platform({ + name, + status = 'success', + failed = false, + failures = [], + phase = 'Completed', + hasJUnit = true, +}) { + return { + name, + status, + failed, + failures, + phase, + hasJUnit, + artifactUrl: `https://example.com/${name.toLowerCase()}`, + } +} + +function build(platforms, slackFileIds = []) { + return buildSlackMessage({ + platforms, + sha: '1234567890abcdef', + runUrl: 'https://example.com/run', + commitUrl: 'https://example.com/commit', + slackFileIds, + }) +} + +test('builds a screenshot carousel for failed flows', () => { + const platforms = [ + platform({ + name: 'iOS', + status: 'failure', + failed: true, + failures: [ + { + name: 'composer', + message: 'Element not found', + screenshot: '/tmp/screenshot-(composer).png', + }, + ], + }), + platform({name: 'Android'}), + ] + const summary = build(platforms, ['F123ABC']) + const carousel = summary.payload.blocks.find( + block => block.type === 'carousel', + ) + + assert.equal(summary.state, 'failed') + assert.equal(summary.screenshotCount, 1) + assert.equal( + summary.uploadPayload.file_uploads[0].file, + platforms[0].failures[0].screenshot, + ) + assert.equal(summary.uploadPayload.file_uploads[0].highlight_type, 'png') + assert.deepEqual(carousel.elements[0].hero_image.slack_file, {id: 'F123ABC'}) +}) + +test('selects failures across both platforms for the carousel', () => { + const failures = prefix => + Array.from({length: 7}, (_, index) => ({ + name: `${prefix}-${index}`, + message: 'Failed', + screenshot: `/tmp/${prefix}-${index}.png`, + })) + const summary = build([ + platform({ + name: 'iOS', + status: 'failure', + failed: true, + failures: failures('ios'), + }), + platform({ + name: 'Android', + status: 'failure', + failed: true, + failures: failures('android'), + }), + ]) + const carousel = summary.payload.blocks.find( + block => block.type === 'carousel', + ) + + assert.equal(carousel.elements.length, 10) + assert.equal(summary.failureCount, 14) + assert.equal(summary.screenshotCount, 14) + assert.equal(summary.uploadPayload.file_uploads.length, 14) + assert.match(summary.threadPayload.text, /ios-6/) + assert.match(summary.threadPayload.text, /android-6/) + assert.equal(carousel.elements[0].subtitle.text, 'iOS · failed flow') + assert.equal(carousel.elements[1].subtitle.text, 'Android · failed flow') +}) + +test('uses a cancellation presentation for partial results', () => { + const summary = build([ + platform({ + name: 'iOS', + status: 'cancelled', + failed: true, + failures: [], + phase: 'Building iOS development client', + hasJUnit: false, + }), + platform({ + name: 'Android', + status: 'cancelled', + failed: true, + failures: [], + phase: 'Building Android development client', + hasJUnit: false, + }), + ]) + + assert.equal(summary.state, 'cancelled') + assert.equal( + summary.payload.blocks[0].text.text, + '⏹️ Nightly Maestro E2E cancelled', + ) + assert.match(summary.payload.text, /cancelled/) + assert.equal(summary.screenshotCount, 0) +}) + +test('distinguishes setup failures from failed Maestro flows', () => { + const summary = build([ + platform({ + name: 'iOS', + status: 'failure', + failed: true, + failures: [], + phase: 'Starting Metro', + hasJUnit: false, + }), + platform({name: 'Android', status: 'skipped'}), + ]) + + assert.equal(summary.state, 'setup_failed') + assert.equal( + summary.payload.blocks[0].text.text, + '⚠️ Nightly Maestro setup failed', + ) + assert.equal( + summary.payload.blocks.some(block => block.type === 'carousel'), + false, + ) +}) + +test('extracts file ids from single and multi-file upload responses', () => { + const response = { + ok: true, + files: [ + { + ok: true, + files: [{id: 'FONE'}, {id: 'FTWO'}], + }, + ], + } + + assert.deepEqual(extractSlackFileIds(response), ['FONE', 'FTWO']) + assert.deepEqual( + extractSlackFileIds(JSON.stringify({ok: true, files: [{id: 'FTHREE'}]})), + ['FTHREE'], + ) + assert.deepEqual(extractSlackFileIds('not json'), []) +}) + +test('selects the newest Maestro screenshot for each flow', () => { + const screenshots = screenshotsByFlow([ + '/tmp/screenshot-❌-300-(composer).png', + '/tmp/screenshot-❌-100-(composer).png', + '/tmp/screenshot-❌-200-(login).png', + '/tmp/artifacts/maestro/composer-self-label/screenshots/step-020-tapOnElement-openMediaBtn.png', + '/tmp/artifacts/maestro/composer-self-label/screenshots/step-010-launchApp.png', + '/tmp/not-a-maestro-screenshot.png', + ]) + + assert.equal( + screenshots.get('composer'), + '/tmp/screenshot-❌-300-(composer).png', + ) + assert.equal(screenshots.get('login'), '/tmp/screenshot-❌-200-(login).png') + assert.equal( + screenshots.get('composer-self-label'), + '/tmp/artifacts/maestro/composer-self-label/screenshots/step-020-tapOnElement-openMediaBtn.png', + ) + assert.equal(screenshots.size, 3) +}) diff --git a/.github/scripts/summarize-maestro.mjs b/.github/scripts/summarize-maestro.mjs index 7154f5b0ee..375effa115 100644 --- a/.github/scripts/summarize-maestro.mjs +++ b/.github/scripts/summarize-maestro.mjs @@ -2,6 +2,8 @@ import fs from 'node:fs' import path from 'node:path' import process from 'node:process' +import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs' + const ENTITY_REPLACEMENTS = { '&': '&', ''': "'", @@ -101,6 +103,54 @@ function readPhase(root) { return phaseFile ? fs.readFileSync(phaseFile, 'utf8').trim() : '' } +function screenshotMetadata(file) { + const legacyMatch = path + .basename(file) + .match(/^screenshot-.*?-(\d+)-\((.+)\)\.(?:gif|jpe?g|png)$/i) + if (legacyMatch) { + return { + file, + order: Number(legacyMatch[1]), + flowName: legacyMatch[2], + } + } + + const parts = file.split(/[\\/]/) + const screenshotsIndex = parts.lastIndexOf('screenshots') + if ( + screenshotsIndex < 2 || + !parts.slice(0, screenshotsIndex - 1).includes('maestro') + ) { + return undefined + } + const step = parts.at(-1)?.match(/^step-(\d+)-.*\.(?:gif|jpe?g|png)$/i) + return step + ? { + file, + order: Number(step[1]), + flowName: parts[screenshotsIndex - 1], + } + : undefined +} + +export function screenshotsByFlow(files) { + const screenshots = new Map() + for (const file of files) { + const screenshot = screenshotMetadata(file) + if (!screenshot) continue + const current = screenshots.get(screenshot.flowName) + if (!current || screenshot.order > current.order) { + screenshots.set(screenshot.flowName, screenshot) + } + } + return new Map( + [...screenshots].map(([flowName, screenshot]) => [ + flowName, + screenshot.file, + ]), + ) +} + function platformResult({name, status, root, artifactUrl}) { const files = walk(root) const reports = files.filter(file => /(?:report|junit).*\.xml$/i.test(file)) @@ -115,7 +165,12 @@ function platformResult({name, status, root, artifactUrl}) { ) // A cancelled or timed-out Maestro run may never flush JUnit. Its CLI log is // streamed continuously, so use those failure lines when JUnit has no detail. - const failures = junitFailures.length > 0 ? junitFailures : cliFailures + const rawFailures = junitFailures.length > 0 ? junitFailures : cliFailures + const screenshots = screenshotsByFlow(files) + const failures = rawFailures.map(failure => ({ + ...failure, + screenshot: screenshots.get(failure.name), + })) // A skipped platform (e.g. iOS while temporarily disabled) is not a failure // as long as it produced no flow failures. const failed = @@ -131,52 +186,15 @@ function platformResult({name, status, root, artifactUrl}) { } } -function statusEmoji(status) { - if (status === 'success') return ':white_check_mark:' - if (status === 'skipped') return ':fast_forward:' - return ':x:' -} - -function slackEscape(value) { - return value - .replaceAll('&', '&') - .replaceAll('<', '<') - .replaceAll('>', '>') -} - -function platformBlock(platform) { +function githubSummary({state, platforms, shortSha, runUrl, commitUrl}) { + const outcome = + state === 'cancelled' + ? 'cancelled' + : state === 'passed' + ? 'passed' + : 'failed' const lines = [ - `${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``, - ] - if (platform.failures.length > 0) { - for (const failure of platform.failures.slice(0, 8)) { - lines.push( - `• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`, - ) - } - if (platform.failures.length > 8) { - lines.push(`• …and ${platform.failures.length - 8} more failed flows`) - } - } else if (platform.failed && !platform.hasJUnit) { - lines.push( - `• *Setup phase:* ${slackEscape(platform.phase || 'No phase metadata was captured')}`, - ) - } else if (platform.failed) { - lines.push( - `• Job failed after JUnit was written; latest phase: ${slackEscape(platform.phase || 'unknown')}`, - ) - } - if (platform.artifactUrl) { - lines.push( - `• <${platform.artifactUrl}|Open ${platform.name} logs and artifacts>`, - ) - } - return lines.join('\n').slice(0, 3000) -} - -function githubSummary({notify, platforms, shortSha, runUrl, commitUrl}) { - const lines = [ - `# Nightly Maestro E2E ${notify ? 'failed' : 'passed'}`, + `# Nightly Maestro E2E ${outcome}`, '', `- Commit: [\`${shortSha}\`](${commitUrl})`, `- Workflow run: [open run](${runUrl})`, @@ -235,6 +253,7 @@ export function buildSummary({ sha, runUrl, commitUrl, + slackFileIds = [], }) { const platforms = [ platformResult({ @@ -252,73 +271,29 @@ export function buildSummary({ ] const notify = platforms.some(platform => platform.failed) const shortSha = sha.slice(0, 12) - const lines = [ - ':rotating_light: *Nightly Maestro E2E failed*', - `*Commit:* <${commitUrl}|\`${shortSha}\`>`, - `*Workflow run:* <${runUrl}|open run>`, - '', - ] - - for (const platform of platforms) { - lines.push( - `${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``, - ) - if (platform.failures.length > 0) { - for (const failure of platform.failures.slice(0, 10)) { - lines.push( - `• *${slackEscape(failure.name)}:* ${slackEscape(failure.message)}`, - ) - } - if (platform.failures.length > 10) { - lines.push(`• …and ${platform.failures.length - 10} more failed flows`) - } - } else if (platform.failed && !platform.hasJUnit) { - lines.push( - `• Setup phase: ${platform.phase || 'No phase metadata was captured'}`, - ) - } else if (platform.failed) { - lines.push( - `• The job failed after JUnit was written (latest phase: ${platform.phase || 'unknown'})`, - ) - } - if (platform.artifactUrl) { - lines.push( - `• <${platform.artifactUrl}|${platform.name} logs and artifacts>`, - ) - } - lines.push('') - } - - const text = lines.join('\n').trim() - const blocks = [ - { - type: 'header', - text: {type: 'plain_text', text: 'Nightly Maestro E2E failed'}, - }, - { - type: 'section', - text: { - type: 'mrkdwn', - text: `*Commit:* <${commitUrl}|\`${shortSha}\`>\n*Workflow run:* <${runUrl}|open run>`, - }, - }, - {type: 'divider'}, - ...platforms.flatMap((platform, index) => [ - {type: 'section', text: {type: 'mrkdwn', text: platformBlock(platform)}}, - ...(index < platforms.length - 1 ? [{type: 'divider'}] : []), - ]), - ] + const slack = buildSlackMessage({ + platforms, + sha, + runUrl, + commitUrl, + slackFileIds, + }) return { notify, + state: slack.state, platforms, githubSummary: githubSummary({ - notify, + state: slack.state, platforms, shortSha, runUrl, commitUrl, }), - payload: {text, blocks}, + failureCount: slack.failureCount, + screenshotCount: slack.screenshotCount, + uploadPayload: slack.uploadPayload, + threadPayload: slack.threadPayload, + payload: slack.payload, } } @@ -351,6 +326,7 @@ if ( sha: args.sha, runUrl: args['run-url'], commitUrl: args['commit-url'], + slackFileIds: extractSlackFileIds(args['slack-upload-response']), }) process.stdout.write(`${JSON.stringify(summary)}\n`) } diff --git a/.github/workflows/nightly-e2e.yml b/.github/workflows/nightly-e2e.yml index df833779af..2693d70962 100644 --- a/.github/workflows/nightly-e2e.yml +++ b/.github/workflows/nightly-e2e.yml @@ -411,14 +411,158 @@ jobs: --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ --commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \ > e2e-summary.json - echo "notify=$(jq -r .notify e2e-summary.json)" >> "$GITHUB_OUTPUT" - echo "payload=$(jq -c .payload e2e-summary.json)" >> "$GITHUB_OUTPUT" + { + echo "notify=$(jq -r .notify e2e-summary.json)" + echo "failure_count=$(jq -r .failureCount e2e-summary.json)" + echo "screenshot_count=$(jq -r .screenshotCount e2e-summary.json)" + } >> "$GITHUB_OUTPUT" + jq .uploadPayload e2e-summary.json > slack-screenshot-upload.json jq -r .githubSummary e2e-summary.json >> "$GITHUB_STEP_SUMMARY" - - name: 🔔 Notify Slack of E2E failures + - name: 📦 Set up pnpm for Slack screenshot framing + if: >- + steps.summary.outputs.notify == 'true' && + steps.summary.outputs.screenshot_count != '0' + continue-on-error: true + uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 + + - name: 🔧 Set up Node for Slack screenshot framing + if: >- + steps.summary.outputs.notify == 'true' && + steps.summary.outputs.screenshot_count != '0' + continue-on-error: true + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version-file: package.json + cache: pnpm + + - name: 📦 Install Slack screenshot framing dependencies + if: >- + steps.summary.outputs.notify == 'true' && + steps.summary.outputs.screenshot_count != '0' + continue-on-error: true + run: pnpm install --frozen-lockfile + + - name: 🖼️ Frame failure screenshots for Slack cards + id: frame_screenshots + if: >- + steps.summary.outputs.notify == 'true' && + steps.summary.outputs.screenshot_count != '0' + continue-on-error: true + run: | + node .github/scripts/frame-slack-screenshots.mjs \ + slack-screenshot-upload.json \ + slack-screenshot-upload-framed.json \ + slack-screenshots + + - name: 📝 Build Slack message + if: steps.summary.outputs.notify == 'true' + env: + ANDROID_STATUS: ${{ needs.android.result }} + IOS_STATUS: ${{ needs.ios.result }} + SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }} + run: | + node .github/scripts/summarize-maestro.mjs \ + --ios-status "$IOS_STATUS" \ + --android-status "$ANDROID_STATUS" \ + --ios-root downloaded-artifacts/ios \ + --android-root downloaded-artifacts/android \ + --artifact-urls artifact-links.json \ + --sha "$GITHUB_SHA" \ + --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \ + > e2e-summary-for-slack.json + jq --arg channel "$SLACK_CHANNEL_ID" \ + '.payload + {channel: $channel, unfurl_links: false, unfurl_media: false}' \ + e2e-summary-for-slack.json > slack-message.json + + - name: 🔔 Notify Slack of E2E result + id: notify_slack if: steps.summary.outputs.notify == 'true' uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 with: - webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }} - webhook-type: incoming-webhook - payload: ${{ steps.summary.outputs.payload }} + method: chat.postMessage + token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }} + payload-file-path: slack-message.json + errors: true + + - name: 🧵 Prepare Slack thread payloads + if: >- + steps.summary.outputs.notify == 'true' && + steps.summary.outputs.failure_count != '0' + env: + FRAMING_OUTCOME: ${{ steps.frame_screenshots.outcome }} + SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }} + SLACK_THREAD_TS: ${{ steps.notify_slack.outputs.ts }} + run: | + upload_payload=slack-screenshot-upload.json + if [ "$FRAMING_OUTCOME" = "success" ]; then + upload_payload=slack-screenshot-upload-framed.json + fi + jq --arg channel "$SLACK_CHANNEL_ID" --arg thread_ts "$SLACK_THREAD_TS" \ + '.threadPayload + {channel: $channel, thread_ts: $thread_ts, unfurl_links: false, unfurl_media: false}' \ + e2e-summary-for-slack.json > slack-thread-details.json + jq --arg channel_id "$SLACK_CHANNEL_ID" --arg thread_ts "$SLACK_THREAD_TS" \ + '. + {channel_id: $channel_id, thread_ts: $thread_ts}' \ + "$upload_payload" > slack-screenshot-upload-thread.json + + - name: 🧾 Post all failure details to Slack thread + if: >- + steps.summary.outputs.notify == 'true' && + steps.summary.outputs.failure_count != '0' + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + method: chat.postMessage + token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }} + payload-file-path: slack-thread-details.json + errors: true + + - name: 🖼️ Upload failure screenshots to Slack thread + id: upload_screenshots + if: >- + steps.summary.outputs.notify == 'true' && + steps.summary.outputs.screenshot_count != '0' + continue-on-error: true + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + method: files.uploadV2 + token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }} + payload-file-path: slack-screenshot-upload-thread.json + errors: true + + - name: ⏳ Wait for Slack to process failure screenshots + if: steps.upload_screenshots.outcome == 'success' + run: sleep 5 + + - name: 📝 Build Slack message with screenshots + if: steps.upload_screenshots.outcome == 'success' + env: + ANDROID_STATUS: ${{ needs.android.result }} + IOS_STATUS: ${{ needs.ios.result }} + SLACK_CHANNEL_ID: ${{ secrets.E2E_FAILURES_SLACK_CHANNEL_ID }} + SLACK_THREAD_TS: ${{ steps.notify_slack.outputs.ts }} + SLACK_UPLOAD_RESPONSE: ${{ steps.upload_screenshots.outputs.response }} + run: | + node .github/scripts/summarize-maestro.mjs \ + --ios-status "$IOS_STATUS" \ + --android-status "$ANDROID_STATUS" \ + --ios-root downloaded-artifacts/ios \ + --android-root downloaded-artifacts/android \ + --artifact-urls artifact-links.json \ + --sha "$GITHUB_SHA" \ + --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \ + --commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \ + --slack-upload-response "$SLACK_UPLOAD_RESPONSE" \ + > e2e-summary-with-slack-files.json + jq --arg channel "$SLACK_CHANNEL_ID" --arg ts "$SLACK_THREAD_TS" \ + '.payload + {channel: $channel, ts: $ts, unfurl_links: false, unfurl_media: false}' \ + e2e-summary-with-slack-files.json > slack-message-update.json + + - name: 🔄 Add screenshots to Slack message + if: steps.upload_screenshots.outcome == 'success' + uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0 + with: + method: chat.update + token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }} + payload-file-path: slack-message-update.json + errors: true diff --git a/package.json b/package.json index 45fc1102cc..ce9e156c4f 100644 --- a/package.json +++ b/package.json @@ -305,6 +305,7 @@ "prettier": "3.9.6", "react-native-dotenv": "^3.4.11", "react-refresh": "^0.14.0", + "sharp": "^0.35.4", "svgo": "^4.0.2", "ts-plugin-sort-import-suggestions": "^1.0.4", "typescript": "npm:@typescript/typescript6@^6.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b41f0dbafc..fda57cac7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -855,6 +855,9 @@ importers: react-refresh: specifier: ^0.14.0 version: 0.14.2 + sharp: + specifier: ^0.35.4 + version: 0.35.4(@types/node@24.12.4) svgo: specifier: ^4.0.2 version: 4.0.2 @@ -1765,6 +1768,9 @@ packages: resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==} engines: {node: '>=0.8.0'} + '@emnapi/runtime@1.11.3': + resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==} + '@emoji-mart/data@1.2.1': resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==} @@ -2197,6 +2203,168 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} + engines: {node: '>=18'} + + '@img/sharp-darwin-arm64@0.35.4': + resolution: {integrity: sha512-Uhfl4V4lhP2nbUVF9+hyH1+luj86f1gUFeo8ALYxFoULoU+G87D43BfeMP8XHsk9boxAnCY/bf2EHwhA7MuGsA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.35.4': + resolution: {integrity: sha512-hWniXY3bG5qKpkKrAwPe4y+VTPmf086YQAnkxWh7uA1YrlRouWGa0M0Mxj3ZjnXFkv7/TD1bTy9lGUK26vRvWw==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [darwin] + + '@img/sharp-freebsd-wasm32@0.35.4': + resolution: {integrity: sha512-lIsKw/BU+kjB4eZjxrYrZmwOJYi3Ajrv66iAlBmUPyKc3HpnloevB1g3wxGD9P/5BbQ1brBGl65VRRrCvQDEqA==} + engines: {node: '>=20.9.0'} + os: [freebsd] + + '@img/sharp-libvips-darwin-arm64@1.3.3': + resolution: {integrity: sha512-suTBPTDGrI9WodccaDdwZItTSaBYASlBk1NSfElSHrUfzu3szG6lvIF58+WiFvnfzuK8ZBFS5zE00PxqxnRiPg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.3.3': + resolution: {integrity: sha512-FVJZ5mITMobmXIz/hPDTw0EintTW5H3WfrxwLqEqjiIihlu+hVRyGrFQ60xl0Lxn7Bt3zdpevPaQi0HEzqz9fw==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.3.3': + resolution: {integrity: sha512-0DaL0A6Xu6sQSQFwe4iVCrKWU2cCTItnRsYsCdxAMm9NF6twAA9BKnoqy4hqz4+azQ0JHuA26qiUKsf1XJ/v5A==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-arm@1.3.3': + resolution: {integrity: sha512-3rbU4vqXXc3hY/OiXdl52xZvT0F1yEngWfvqudtPJg/KkyiaQw2DRsFrNzpmLvfavbwOq3qXn36GP8obHRULQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-ppc64@1.3.3': + resolution: {integrity: sha512-cdn1OvUBwsXhbC0zSzJnNzf5MZ/mTrobawDvNXBTxe8VtqKAm0sRuEY2Evzovb/w9JMk4TvRxqt1mekSuJz64w==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-riscv64@1.3.3': + resolution: {integrity: sha512-HjPVx7yKz+0lqdhDlTw1tt90wamBoxhiXpvl1XZpJLiHH4RCJ5yDTqH+VlYPv2fwFs89JFw4c1IexYOcQUi4IQ==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-s390x@1.3.3': + resolution: {integrity: sha512-neWLh+3yCNThxnfy3c4BbVBeGgt9aftno+XbT56iK28RgeDs3UOFWviLWlUu0bArYVYJaFDK+RRohbicUNCm8Q==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linux-x64@1.3.3': + resolution: {integrity: sha512-4vKmvAst9nrowcqquKFAyZJUDolUaIp8uRiN0mWFguJ1IplC9/pitXtlnnlU4aa/eJw3J7i67V+pwUL+wZGdsA==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + resolution: {integrity: sha512-Y9kQaLMuNoB0bPYOOdcZMaseNrFpPodIWWMrx+CZyydf2xn68j9WYc6sWWRrDwNkzCQjKYfc68L7jKjGlHMibw==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + resolution: {integrity: sha512-fj8Mv0HHfD1Rr+4I68+3agJynxDWtBFgicTbSOb9Bke6pIwzGcJ+RX/yHjmiEGFMCavY/dxvem7MyNaJF+wDiw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-linux-arm64@0.35.4': + resolution: {integrity: sha512-De4jpEnAU8Hd5oT0j1G3uL4ZvTuipVMn7YC6vPaJhy6/7EwEae0SVAoBrUMYQbkLGDm85taVWwuPc1a44LTzCQ==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-arm@0.35.4': + resolution: {integrity: sha512-7OAS8gI0EReKGVN2HssHlM6umJgxF5VI3xN0p9FA91p/YO+ou5hiNghLdZ5BEHztwaaK5+bLKRf8x/o2L2nk9A==} + engines: {node: '>=20.9.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-ppc64@0.35.4': + resolution: {integrity: sha512-2oYZJeIl4kCcMGk4ouZVjnkCtFrpQFlNEtJ6GbxzhHQchwH0NH/qEb9ykmOl29dqwMq+JhFdZn+1ak2FKhI9fQ==} + engines: {node: '>=20.9.0'} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-riscv64@0.35.4': + resolution: {integrity: sha512-cPbNChoRURAWdebDIHSenxRpgEdy7JkPydSnUxRm9VvKD7m0/xVaR/8Fzlu81pk5nHEvHH87UZUA7cTtwnbJSA==} + engines: {node: '>=20.9.0'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-s390x@0.35.4': + resolution: {integrity: sha512-RY0JFY8Fd6RonCBtHz+DvadaPkXDSI1AUn6yWL9TipqkZ1vY8w8evqdgyDFnkm4/K1ve1TvZiaePP5oSd4+WVQ==} + engines: {node: '>=20.9.0'} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@img/sharp-linux-x64@0.35.4': + resolution: {integrity: sha512-9qvvEAuk8k89TfWUoX2htWjbAMX8p+NxCppjpcg5k6xMsjhBQPTsoIh36h9Qde4WRuGpJeYnOjdosDn/cnv+OA==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@img/sharp-linuxmusl-arm64@0.35.4': + resolution: {integrity: sha512-KB5jxpfWQTr0nc3xdHtWChdbifHrBGsd2SM62Eyxrl8afikm+f5qGBU75SJIZBT/S1MC8XyacdlXBMSWq6OURA==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@img/sharp-linuxmusl-x64@0.35.4': + resolution: {integrity: sha512-f+eZJZIQNEEd26RPSW+76chwOf1XtA2Y/O+5ocVyLliHkeih3e+jhLVBdNTd2rS3IbNXK8+ug93Vf5ZXtF5Lxg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@img/sharp-wasm32@0.35.4': + resolution: {integrity: sha512-zQnl4Kwp7Q6NHsENtU2T/00Zi+w3AQNwz3+UaTyVBy2FpXrzXzGjndpK61onhZjRtRpQXxCTeqw19bVyXOh7jA==} + engines: {node: '>=20.9.0'} + + '@img/sharp-webcontainers-wasm32@0.35.4': + resolution: {integrity: sha512-ESfNkywmCfPNyaZjxooddJQiQ+l/nTpGEOGthxiLnIHXC/CmcBixnfwUleX9mCz9ovrUUvKMap/pm8RYbzfwaA==} + engines: {node: '>=20.9.0'} + cpu: [wasm32] + + '@img/sharp-win32-arm64@0.35.4': + resolution: {integrity: sha512-iNdlBX9gLVvqe2I3uIJSIKTq6wckP/DYxZtcqxm09x5Gi24DnFBmPAWZmr60ZyYMG0xlzo6goG3670ar+RXvRw==} + engines: {node: '>=20.9.0'} + cpu: [arm64] + os: [win32] + + '@img/sharp-win32-ia32@0.35.4': + resolution: {integrity: sha512-kqRsbaa5CS6KHlpxnN7WhE6vAAugXyZButpRdvDWetlv6Qv4N9WTcrWzF7tXfB9T7MsoadqdI8hmwLq6UlLvtw==} + engines: {node: ^20.9.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.35.4': + resolution: {integrity: sha512-XtmnYhBcrORsJ4XJngyzr/EWP0hRZLAZRFaApdKuviyqF78+ylxh2y06ZmtULAMOnObJ3ucpN0AcwSWnMowTRg==} + engines: {node: '>=20.9.0'} + cpu: [x64] + os: [win32] + '@ipld/dag-cbor@9.2.7': resolution: {integrity: sha512-ZmfXmElRWATr+hoUTSAOr6HUcjVhOcNHDqgczc76qte2DHHFEK0ZhNzUcdTDQhF/VSIvf2ioaRTRLWwLc83sNw==} @@ -8602,6 +8770,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -8652,6 +8825,15 @@ packages: shallow-equal@3.1.0: resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==} + sharp@0.35.4: + resolution: {integrity: sha512-n++8XWcj+jCOr2IOl7h8LbKnGBDY4aPbmprMONBNFdn0ImXqpGVv5zliDs0V9HbmbCQLpbuo2ej9rAoOQTvMDA==} + engines: {node: '>=20.9.0'} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -10786,6 +10968,11 @@ snapshots: dependencies: '@types/hammerjs': 2.0.46 + '@emnapi/runtime@1.11.3': + dependencies: + tslib: 2.8.1 + optional: true + '@emoji-mart/data@1.2.1': {} '@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.3)': @@ -11407,6 +11594,112 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': optional: true + '@img/colour@1.1.0': {} + + '@img/sharp-darwin-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.3.3 + optional: true + + '@img/sharp-darwin-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.3.3 + optional: true + + '@img/sharp-freebsd-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-darwin-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-arm@1.3.3': + optional: true + + '@img/sharp-libvips-linux-ppc64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-riscv64@1.3.3': + optional: true + + '@img/sharp-libvips-linux-s390x@1.3.3': + optional: true + + '@img/sharp-libvips-linux-x64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.3.3': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.3.3': + optional: true + + '@img/sharp-linux-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.3.3 + optional: true + + '@img/sharp-linux-arm@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.3.3 + optional: true + + '@img/sharp-linux-ppc64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-ppc64': 1.3.3 + optional: true + + '@img/sharp-linux-riscv64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-riscv64': 1.3.3 + optional: true + + '@img/sharp-linux-s390x@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.3.3 + optional: true + + '@img/sharp-linux-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-arm64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + optional: true + + '@img/sharp-linuxmusl-x64@0.35.4': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + optional: true + + '@img/sharp-wasm32@0.35.4': + dependencies: + '@emnapi/runtime': 1.11.3 + optional: true + + '@img/sharp-webcontainers-wasm32@0.35.4': + dependencies: + '@img/sharp-wasm32': 0.35.4 + optional: true + + '@img/sharp-win32-arm64@0.35.4': + optional: true + + '@img/sharp-win32-ia32@0.35.4': + optional: true + + '@img/sharp-win32-x64@0.35.4': + optional: true + '@ipld/dag-cbor@9.2.7': dependencies: cborg: 5.1.1 @@ -18666,6 +18959,8 @@ snapshots: semver@7.8.0: {} + semver@7.8.5: {} + send@0.19.2(supports-color@8.1.1): dependencies: debug: 2.6.9(supports-color@8.1.1) @@ -18745,6 +19040,39 @@ snapshots: shallow-equal@3.1.0: {} + sharp@0.35.4(@types/node@24.12.4): + dependencies: + '@img/colour': 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.35.4 + '@img/sharp-darwin-x64': 0.35.4 + '@img/sharp-freebsd-wasm32': 0.35.4 + '@img/sharp-libvips-darwin-arm64': 1.3.3 + '@img/sharp-libvips-darwin-x64': 1.3.3 + '@img/sharp-libvips-linux-arm': 1.3.3 + '@img/sharp-libvips-linux-arm64': 1.3.3 + '@img/sharp-libvips-linux-ppc64': 1.3.3 + '@img/sharp-libvips-linux-riscv64': 1.3.3 + '@img/sharp-libvips-linux-s390x': 1.3.3 + '@img/sharp-libvips-linux-x64': 1.3.3 + '@img/sharp-libvips-linuxmusl-arm64': 1.3.3 + '@img/sharp-libvips-linuxmusl-x64': 1.3.3 + '@img/sharp-linux-arm': 0.35.4 + '@img/sharp-linux-arm64': 0.35.4 + '@img/sharp-linux-ppc64': 0.35.4 + '@img/sharp-linux-riscv64': 0.35.4 + '@img/sharp-linux-s390x': 0.35.4 + '@img/sharp-linux-x64': 0.35.4 + '@img/sharp-linuxmusl-arm64': 0.35.4 + '@img/sharp-linuxmusl-x64': 0.35.4 + '@img/sharp-webcontainers-wasm32': 0.35.4 + '@img/sharp-win32-arm64': 0.35.4 + '@img/sharp-win32-ia32': 0.35.4 + '@img/sharp-win32-x64': 0.35.4 + '@types/node': 24.12.4 + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0