331 lines
8.7 KiB
JavaScript
331 lines
8.7 KiB
JavaScript
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
|
|
}
|