Add carousel Slack alerts for nightly Maestro failures (#11645)
This commit is contained in:
@@ -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 <input.json> <output.json> <output-dir>',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
await frameSlackScreenshots({inputPath, outputPath, outputDir})
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -2,6 +2,8 @@ import fs from 'node:fs'
|
|||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import process from 'node:process'
|
import process from 'node:process'
|
||||||
|
|
||||||
|
import {buildSlackMessage, extractSlackFileIds} from './maestro-slack.mjs'
|
||||||
|
|
||||||
const ENTITY_REPLACEMENTS = {
|
const ENTITY_REPLACEMENTS = {
|
||||||
'&': '&',
|
'&': '&',
|
||||||
''': "'",
|
''': "'",
|
||||||
@@ -101,6 +103,54 @@ function readPhase(root) {
|
|||||||
return phaseFile ? fs.readFileSync(phaseFile, 'utf8').trim() : ''
|
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}) {
|
function platformResult({name, status, root, artifactUrl}) {
|
||||||
const files = walk(root)
|
const files = walk(root)
|
||||||
const reports = files.filter(file => /(?:report|junit).*\.xml$/i.test(file))
|
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
|
// 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.
|
// 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
|
// A skipped platform (e.g. iOS while temporarily disabled) is not a failure
|
||||||
// as long as it produced no flow failures.
|
// as long as it produced no flow failures.
|
||||||
const failed =
|
const failed =
|
||||||
@@ -131,52 +186,15 @@ function platformResult({name, status, root, artifactUrl}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusEmoji(status) {
|
function githubSummary({state, platforms, shortSha, runUrl, commitUrl}) {
|
||||||
if (status === 'success') return ':white_check_mark:'
|
const outcome =
|
||||||
if (status === 'skipped') return ':fast_forward:'
|
state === 'cancelled'
|
||||||
return ':x:'
|
? 'cancelled'
|
||||||
}
|
: state === 'passed'
|
||||||
|
? 'passed'
|
||||||
function slackEscape(value) {
|
: 'failed'
|
||||||
return value
|
|
||||||
.replaceAll('&', '&')
|
|
||||||
.replaceAll('<', '<')
|
|
||||||
.replaceAll('>', '>')
|
|
||||||
}
|
|
||||||
|
|
||||||
function platformBlock(platform) {
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`${statusEmoji(platform.status)} *${platform.name}* — job status: \`${platform.status}\``,
|
`# Nightly Maestro E2E ${outcome}`,
|
||||||
]
|
|
||||||
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'}`,
|
|
||||||
'',
|
'',
|
||||||
`- Commit: [\`${shortSha}\`](${commitUrl})`,
|
`- Commit: [\`${shortSha}\`](${commitUrl})`,
|
||||||
`- Workflow run: [open run](${runUrl})`,
|
`- Workflow run: [open run](${runUrl})`,
|
||||||
@@ -235,6 +253,7 @@ export function buildSummary({
|
|||||||
sha,
|
sha,
|
||||||
runUrl,
|
runUrl,
|
||||||
commitUrl,
|
commitUrl,
|
||||||
|
slackFileIds = [],
|
||||||
}) {
|
}) {
|
||||||
const platforms = [
|
const platforms = [
|
||||||
platformResult({
|
platformResult({
|
||||||
@@ -252,73 +271,29 @@ export function buildSummary({
|
|||||||
]
|
]
|
||||||
const notify = platforms.some(platform => platform.failed)
|
const notify = platforms.some(platform => platform.failed)
|
||||||
const shortSha = sha.slice(0, 12)
|
const shortSha = sha.slice(0, 12)
|
||||||
const lines = [
|
const slack = buildSlackMessage({
|
||||||
':rotating_light: *Nightly Maestro E2E failed*',
|
platforms,
|
||||||
`*Commit:* <${commitUrl}|\`${shortSha}\`>`,
|
sha,
|
||||||
`*Workflow run:* <${runUrl}|open run>`,
|
runUrl,
|
||||||
'',
|
commitUrl,
|
||||||
]
|
slackFileIds,
|
||||||
|
})
|
||||||
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'}] : []),
|
|
||||||
]),
|
|
||||||
]
|
|
||||||
return {
|
return {
|
||||||
notify,
|
notify,
|
||||||
|
state: slack.state,
|
||||||
platforms,
|
platforms,
|
||||||
githubSummary: githubSummary({
|
githubSummary: githubSummary({
|
||||||
notify,
|
state: slack.state,
|
||||||
platforms,
|
platforms,
|
||||||
shortSha,
|
shortSha,
|
||||||
runUrl,
|
runUrl,
|
||||||
commitUrl,
|
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,
|
sha: args.sha,
|
||||||
runUrl: args['run-url'],
|
runUrl: args['run-url'],
|
||||||
commitUrl: args['commit-url'],
|
commitUrl: args['commit-url'],
|
||||||
|
slackFileIds: extractSlackFileIds(args['slack-upload-response']),
|
||||||
})
|
})
|
||||||
process.stdout.write(`${JSON.stringify(summary)}\n`)
|
process.stdout.write(`${JSON.stringify(summary)}\n`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -411,14 +411,158 @@ jobs:
|
|||||||
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
--run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||||
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
|
--commit-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}" \
|
||||||
> e2e-summary.json
|
> 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"
|
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'
|
if: steps.summary.outputs.notify == 'true'
|
||||||
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
|
uses: slackapi/slack-github-action@dcb1066f776dd043e64d0e8ba94ca15cc7e1875d # v4.0.0
|
||||||
with:
|
with:
|
||||||
webhook: ${{ secrets.E2E_FAILURES_SLACK_WEBHOOK }}
|
method: chat.postMessage
|
||||||
webhook-type: incoming-webhook
|
token: ${{ secrets.E2E_FAILURES_SLACK_BOT_TOKEN }}
|
||||||
payload: ${{ steps.summary.outputs.payload }}
|
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
|
||||||
|
|||||||
@@ -305,6 +305,7 @@
|
|||||||
"prettier": "3.9.6",
|
"prettier": "3.9.6",
|
||||||
"react-native-dotenv": "^3.4.11",
|
"react-native-dotenv": "^3.4.11",
|
||||||
"react-refresh": "^0.14.0",
|
"react-refresh": "^0.14.0",
|
||||||
|
"sharp": "^0.35.4",
|
||||||
"svgo": "^4.0.2",
|
"svgo": "^4.0.2",
|
||||||
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
"ts-plugin-sort-import-suggestions": "^1.0.4",
|
||||||
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
"typescript": "npm:@typescript/typescript6@^6.0.2",
|
||||||
|
|||||||
Generated
+328
@@ -855,6 +855,9 @@ importers:
|
|||||||
react-refresh:
|
react-refresh:
|
||||||
specifier: ^0.14.0
|
specifier: ^0.14.0
|
||||||
version: 0.14.2
|
version: 0.14.2
|
||||||
|
sharp:
|
||||||
|
specifier: ^0.35.4
|
||||||
|
version: 0.35.4(@types/node@24.12.4)
|
||||||
svgo:
|
svgo:
|
||||||
specifier: ^4.0.2
|
specifier: ^4.0.2
|
||||||
version: 4.0.2
|
version: 4.0.2
|
||||||
@@ -1765,6 +1768,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==}
|
resolution: {integrity: sha512-XQsZgjm2EcVUiZQf11UBJQfmZeEmOW8DpI1gsFeln6w0ae0ii4dMQEQ0kjl6DspdWX1aGY1/loyXnP0JS06e/A==}
|
||||||
engines: {node: '>=0.8.0'}
|
engines: {node: '>=0.8.0'}
|
||||||
|
|
||||||
|
'@emnapi/runtime@1.11.3':
|
||||||
|
resolution: {integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==}
|
||||||
|
|
||||||
'@emoji-mart/data@1.2.1':
|
'@emoji-mart/data@1.2.1':
|
||||||
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
|
resolution: {integrity: sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw==}
|
||||||
|
|
||||||
@@ -2197,6 +2203,168 @@ packages:
|
|||||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||||
deprecated: Use @eslint/object-schema instead
|
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':
|
'@ipld/dag-cbor@9.2.7':
|
||||||
resolution: {integrity: sha512-ZmfXmElRWATr+hoUTSAOr6HUcjVhOcNHDqgczc76qte2DHHFEK0ZhNzUcdTDQhF/VSIvf2ioaRTRLWwLc83sNw==}
|
resolution: {integrity: sha512-ZmfXmElRWATr+hoUTSAOr6HUcjVhOcNHDqgczc76qte2DHHFEK0ZhNzUcdTDQhF/VSIvf2ioaRTRLWwLc83sNw==}
|
||||||
|
|
||||||
@@ -8602,6 +8770,11 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
semver@7.8.5:
|
||||||
|
resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
|
||||||
|
engines: {node: '>=10'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
send@0.19.2:
|
send@0.19.2:
|
||||||
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
|
resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -8652,6 +8825,15 @@ packages:
|
|||||||
shallow-equal@3.1.0:
|
shallow-equal@3.1.0:
|
||||||
resolution: {integrity: sha512-pfVOw8QZIXpMbhBWvzBISicvToTiM5WBF1EeAUZDDSb5Dt29yl4AYbyywbJFSEsRUMr7gJaxqCdr4L3tQf9wVg==}
|
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:
|
shebang-command@2.0.0:
|
||||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -10786,6 +10968,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@types/hammerjs': 2.0.46
|
'@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/data@1.2.1': {}
|
||||||
|
|
||||||
'@emoji-mart/react@1.1.1(emoji-mart@5.6.0)(react@19.2.3)':
|
'@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':
|
'@humanwhocodes/object-schema@2.0.3':
|
||||||
optional: true
|
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':
|
'@ipld/dag-cbor@9.2.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
cborg: 5.1.1
|
cborg: 5.1.1
|
||||||
@@ -18666,6 +18959,8 @@ snapshots:
|
|||||||
|
|
||||||
semver@7.8.0: {}
|
semver@7.8.0: {}
|
||||||
|
|
||||||
|
semver@7.8.5: {}
|
||||||
|
|
||||||
send@0.19.2(supports-color@8.1.1):
|
send@0.19.2(supports-color@8.1.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 2.6.9(supports-color@8.1.1)
|
debug: 2.6.9(supports-color@8.1.1)
|
||||||
@@ -18745,6 +19040,39 @@ snapshots:
|
|||||||
|
|
||||||
shallow-equal@3.1.0: {}
|
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:
|
shebang-command@2.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
shebang-regex: 3.0.0
|
shebang-regex: 3.0.0
|
||||||
|
|||||||
Reference in New Issue
Block a user