/*! * Copyright © 2005-2026 Hyland Software, Inc. and its affiliates. All rights reserved. * * Alfresco Example Content Application * * This file is part of the Alfresco Example Content Application. * If the software was purchased under a paid Alfresco license, the terms of * the paid license agreement will prevail. Otherwise, the software is * provided under the following open source license terms: * * The Alfresco Example Content Application is free software: you can redistribute it and/or modify * it under the terms of the GNU Lesser General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * The Alfresco Example Content Application is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Lesser General Public License for more details. * * You should have received a copy of the GNU Lesser General Public License * from Hyland Software. If not, see . */ 'use strict'; /** * Aggregates Playwright blob reports produced by the cron multibrowser E2E run and posts a * per-suite summary (passed / failed / skipped / excluded) as an Adaptive Card to a Teams channel. * * Expects blob artifacts downloaded under `all-blobs/blob--/` (each containing a * Playwright blob `report.zip`) and the Teams webhook URL in `TEAMS_WEBHOOK_URL`. */ const fs = require('fs'); const os = require('os'); const path = require('path'); const { execFileSync } = require('child_process'); const BROWSERS = ['chrome', 'firefox', 'webkit', 'msedge']; const BLOBS_DIR = 'all-blobs'; const EXCLUDE_ROOT = path.join('e2e', 'playwright'); // Matrix suite name -> test folder, for the single suite whose folder name differs. const SUITE_TO_FOLDER = { 'special-permissions': 'special-permissions-actions-available' }; const PLAYWRIGHT_BIN = path.join('node_modules', '.bin', 'playwright'); function emptyCounts() { return { run: 0, passed: 0, failed: 0, skipped: 0, excluded: 0, excludedOwn: 0 }; } function addCounts(target, source) { target.run += source.run; target.passed += source.passed; target.failed += source.failed; target.skipped += source.skipped; target.excluded += source.excluded; target.excludedOwn += source.excludedOwn; } /** Parse `blob--` artifact directory names. */ function parseArtifactName(dirName) { const match = /^blob-([^-]+)-(.+)$/.exec(dirName); if (!match) { return null; } const [, browser, suite] = match; if (!BROWSERS.includes(browser)) { return null; } return { browser, suite }; } /** Merge a single artifact's blob report into JSON and return Playwright `stats`. */ function readStats(artifactDir) { const outFile = path.join(os.tmpdir(), `pw-json-${Date.now()}-${Math.random().toString(36).slice(2)}.json`); try { execFileSync(PLAYWRIGHT_BIN, ['merge-reports', artifactDir, '--reporter', 'json'], { stdio: ['ignore', 'ignore', 'inherit'], maxBuffer: 1 << 28, env: { ...process.env, PLAYWRIGHT_JSON_OUTPUT_NAME: outFile } }); const report = JSON.parse(fs.readFileSync(outFile, 'utf8')); const stats = report.stats || {}; const passed = (stats.expected || 0) + (stats.flaky || 0); const failed = stats.unexpected || 0; const skipped = stats.skipped || 0; return { run: passed + failed + skipped, passed, failed, skipped, excluded: 0, excludedOwn: 0 }; } catch (error) { console.warn(`Could not read blob report in ${artifactDir}: ${error instanceof Error ? error.message : String(error)}`); return null; } finally { fs.rmSync(outFile, { force: true }); } } /** Count excluded test cases for a suite/browser, mirroring getExcludedTestsRegExpArray. */ function excludedForBrowser(excludeJson, browser) { const browserKeys = ['firefox', 'chromium', 'webkit', 'msedge']; const allCount = excludeJson.all && typeof excludeJson.all === 'object' ? Object.keys(excludeJson.all).length : 0; let ownCount = 0; const key = browserKeys.find((k) => k.toLowerCase() === browser.toLowerCase()); if (key && excludeJson[key] && typeof excludeJson[key] === 'object') { // Browser-specific ids not already covered by the 'all' bucket. const allIds = new Set(excludeJson.all ? Object.keys(excludeJson.all) : []); ownCount = Object.keys(excludeJson[key]).filter((id) => !allIds.has(id)).length; } return { total: allCount + ownCount, own: ownCount }; } function readExcludeJson(suite) { const folder = SUITE_TO_FOLDER[suite] || suite; const file = path.join(EXCLUDE_ROOT, folder, 'exclude.tests.json'); try { return JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return null; } } function collect() { const suiteStats = new Map(); const browserStats = new Map(BROWSERS.map((b) => [b, emptyCounts()])); const browserSuiteStats = new Map(BROWSERS.map((b) => [b, new Map()])); const suiteBrowsers = new Map(); if (!fs.existsSync(BLOBS_DIR)) { return { suiteStats, browserStats, browserSuiteStats }; } for (const dirName of fs.readdirSync(BLOBS_DIR)) { const parsed = parseArtifactName(dirName); if (!parsed) { continue; } const { browser, suite } = parsed; const stats = readStats(path.join(BLOBS_DIR, dirName)); if (!stats) { continue; } if (!suiteStats.has(suite)) { suiteStats.set(suite, emptyCounts()); suiteBrowsers.set(suite, new Set()); } suiteBrowsers.get(suite).add(browser); addCounts(suiteStats.get(suite), stats); addCounts(browserStats.get(browser), stats); const bsMap = browserSuiteStats.get(browser); if (!bsMap.has(suite)) { bsMap.set(suite, emptyCounts()); } addCounts(bsMap.get(suite), stats); } for (const [suite, browsers] of suiteBrowsers) { const excludeJson = readExcludeJson(suite); if (!excludeJson) { continue; } let suiteExcluded = 0; for (const browser of browsers) { const { total, own } = excludedForBrowser(excludeJson, browser); suiteExcluded += total; browserStats.get(browser).excluded += total; browserStats.get(browser).excludedOwn += own; const bsMap = browserSuiteStats.get(browser); if (bsMap.has(suite)) { bsMap.get(suite).excluded += total; bsMap.get(suite).excludedOwn += own; } } suiteStats.get(suite).excluded += suiteExcluded; } return { suiteStats, browserStats, browserSuiteStats }; } function tableCell(text, opts = {}) { return { type: 'TableCell', verticalContentAlignment: 'Center', items: [{ type: 'TextBlock', text: String(text), wrap: true, weight: 'Bolder', size: 'Medium', horizontalAlignment: 'Center', ...opts }] }; } function buildBrowserTable(browserStats) { const columns = [ { width: 3 }, { width: 1 }, { width: 1 }, { width: 1 }, { width: 1 }, { width: 1 }, { width: 1 } ]; const headerRow = { type: 'TableRow', cells: [ tableCell('Browser', { horizontalAlignment: 'Left' }), tableCell('🔢'), tableCell('✅'), tableCell('❌'), tableCell('⏭️'), tableCell('🚫'), tableCell('🚫🌐') ] }; const rows = BROWSERS.map((browser) => { const c = browserStats.get(browser); return { type: 'TableRow', cells: [ tableCell(browser, { horizontalAlignment: 'Left' }), tableCell(c.run), tableCell(c.passed, { color: 'Good' }), tableCell(c.failed, { color: c.failed > 0 ? 'Attention' : 'Default' }), tableCell(c.skipped, { color: c.skipped > 0 ? 'Warning' : 'Default' }), tableCell(c.excluded), tableCell(c.excludedOwn, { color: c.excludedOwn > 0 ? 'Warning' : 'Default' }) ] }; }); return { type: 'Table', gridStyle: 'accent', firstRowAsHeader: true, columns, rows: [headerRow, ...rows] }; } function buildBrowserSuiteText(suiteMap) { const lines = []; for (const suite of [...suiteMap.keys()].sort()) { const c = suiteMap.get(suite); const failMark = c.failed > 0 ? `❌${c.failed}` : `${c.failed}`; lines.push(`${suite}: 🔢${c.run} ✅${c.passed} ${failMark} ⏭️${c.skipped} 🚫${c.excluded} (own ${c.excludedOwn})`); } return lines.join('\n\n'); } const FAILURE_ALERT_THRESHOLD_PCT = 2; /** Optional @mention block for the alert, if TEAMS_ALERT_MENTION_ID/NAME are set (needs a Teams UPN/AAD id). */ function buildMention() { const id = process.env.TEAMS_ALERT_MENTION_ID; const name = process.env.TEAMS_ALERT_MENTION_NAME; if (!id || !name) { return null; } return { tag: `${name}`, entity: { type: 'mention', text: `${name}`, mentioned: { id, name } } }; } function buildCard({ suiteStats, browserStats, browserSuiteStats }) { const total = emptyCounts(); for (const c of suiteStats.values()) { addCounts(total, c); } const date = new Date().toISOString().slice(0, 10); const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; const failedPct = total.run > 0 ? (total.failed / total.run) * 100 : 0; const alertTriggered = failedPct > FAILURE_ALERT_THRESHOLD_PCT; const browserContainers = BROWSERS.map((browser) => ({ type: 'Container', id: `details-${browser}`, isVisible: false, separator: true, items: [ { type: 'TextBlock', weight: 'Bolder', text: `${browser} — per suite`, spacing: 'Medium' }, { type: 'TextBlock', text: buildBrowserSuiteText(browserSuiteStats.get(browser)), wrap: true, size: 'Small' } ] })); const browserToggleActions = BROWSERS.map((browser) => ({ type: 'Action.ToggleVisibility', title: browser, targetElements: [`details-${browser}`] })); const body = [ { type: 'TextBlock', size: 'Large', weight: 'Bolder', text: `ACA Cron Multibrowser Workflow - ${date}` } ]; const mention = alertTriggered ? buildMention() : null; if (alertTriggered) { const alertText = `⚠️ Failure rate ${failedPct.toFixed(1)}% exceeds ${FAILURE_ALERT_THRESHOLD_PCT}% threshold${mention ? ` — ${mention.tag}` : ''}`; body.push({ type: 'TextBlock', text: alertText, wrap: true, weight: 'Bolder', color: 'Attention', size: 'Medium' }); } body.push( { type: 'FactSet', facts: [ { title: 'Total run', value: String(total.run) }, { title: 'Passed ✅', value: String(total.passed) }, { title: 'Failed ❌', value: String(total.failed) }, { title: 'Skipped ⏭️', value: String(total.skipped) }, { title: 'Excluded 🚫', value: String(total.excluded) } ] }, { type: 'TextBlock', weight: 'Bolder', text: 'Per browser', separator: true, spacing: 'Medium' }, buildBrowserTable(browserStats), { type: 'TextBlock', text: '🔢 Total run • ✅ Passed • ❌ Failed • ⏭️ Skipped • 🚫 Excluded (all) • 🚫🌐 Excluded (browser-specific)', wrap: true, size: 'Small', isSubtle: true, spacing: 'Small' }, ...browserContainers ); const card = { type: 'AdaptiveCard', $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', version: '1.5', body, actions: [...browserToggleActions, { type: 'Action.OpenUrl', title: 'Open run', url: runUrl }] }; if (mention) { card.msteams = { entities: [mention.entity] }; } return card; } function statusLabel(result) { switch (result) { case 'success': return '✅ success'; case 'failure': return '❌ failure'; case 'cancelled': return '🚫 cancelled'; case 'skipped': return '⏭️ skipped'; default: return result || 'unknown'; } } /** * Card posted when no blob reports were produced, i.e. the build or the local ACS deployment * failed before any tests could run. Avoids sending a misleading all-zero results card. */ function buildSetupFailureCard() { const date = new Date().toISOString().slice(0, 10); const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`; const jobResults = [ { browser: 'chrome', result: process.env.CHROME_RESULT }, { browser: 'firefox', result: process.env.FIREFOX_RESULT }, { browser: 'webkit', result: process.env.WEBKIT_RESULT }, { browser: 'msedge', result: process.env.MSEDGE_RESULT } ].filter((job) => job.result); const mention = buildMention(); const body = [ { type: 'TextBlock', size: 'Large', weight: 'Bolder', text: `ACA Cron Multibrowser Workflow - ${date}` }, { type: 'TextBlock', text: `🛑 Setup failed — no tests were run${mention ? ` — ${mention.tag}` : ''}`, wrap: true, weight: 'Bolder', color: 'Attention', size: 'Medium' }, { type: 'TextBlock', text: 'No Playwright blob reports were produced. This usually means the build or the local ACS deployment failed before any tests could run.', wrap: true } ]; if (jobResults.length) { body.push({ type: 'FactSet', facts: jobResults.map((job) => ({ title: job.browser, value: statusLabel(job.result) })) }); } const card = { type: 'AdaptiveCard', $schema: 'http://adaptivecards.io/schemas/adaptive-card.json', version: '1.5', body, actions: [{ type: 'Action.OpenUrl', title: 'Open run', url: runUrl }] }; if (mention) { card.msteams = { entities: [mention.entity] }; } return card; } async function postToTeams(card) { const webhook = process.env.TEAMS_WEBHOOK_URL || process.env.TEAMS_E2E_WEBHOOK_URL; if (!webhook) { console.warn('TEAMS_WEBHOOK_URL is not set, skipping Teams notification.'); return; } const payload = { type: 'message', attachments: [{ contentType: 'application/vnd.microsoft.card.adaptive', content: card }] }; const response = await fetch(webhook, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) }); if (!response.ok) { throw new Error(`Teams webhook returned ${response.status}: ${await response.text()}`); } console.log(`Posted E2E report to Teams (HTTP ${response.status}).`); } async function main() { const results = collect(); const hasResults = results.suiteStats.size > 0; const card = hasResults ? buildCard(results) : buildSetupFailureCard(); await postToTeams(card); } if (require.main === module) { main().catch((error) => { console.error(error); process.exit(1); }); } module.exports = { buildCard, buildSetupFailureCard, postToTeams, emptyCounts, BROWSERS };