From 7a0f2c21f4a64bae8ef8d0c56566bbf047fb118c Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 3 Jun 2026 12:54:39 -0400 Subject: [PATCH 1/3] fix(crypto): left-align signed-by sender strip --- shared/crypto/output.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/crypto/output.tsx b/shared/crypto/output.tsx index 059078239222..9c900fb394c5 100644 --- a/shared/crypto/output.tsx +++ b/shared/crypto/output.tsx @@ -61,7 +61,7 @@ export const CryptoSignedSender = ({isSelfSigned, state}: SignedSenderProps) => direction="horizontal" fullWidth={true} alignItems="center" - justifyContent="center" + justifyContent="flex-start" noShrink={true} style={Kb.Styles.collapseStyles([ styles.signedContainer, From c193b88cf8840bb141470422f9fc33ff2e3fd14f Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 3 Jun 2026 13:06:13 -0400 Subject: [PATCH 2/3] fix(tabs): center tab label within its full-width container --- shared/common-adapters/tabs.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/common-adapters/tabs.tsx b/shared/common-adapters/tabs.tsx index 408eb77e2987..3db507c8c553 100644 --- a/shared/common-adapters/tabs.tsx +++ b/shared/common-adapters/tabs.tsx @@ -62,7 +62,7 @@ const Tabs = (props: Props) => ( style={Styles.collapseStyles([styles.tabContainer, props.clickableBoxStyle, props.clickableTabStyle])} fullWidth={true} > - + {tab.icon ? ( ) : ( From 090379985d24a81354d81b17420ac10a3bd4bc39 Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Wed, 3 Jun 2026 14:50:59 -0400 Subject: [PATCH 3/3] unify reporting --- shared/package.json | 6 +- shared/tests/e2e/generate-electron-report.mts | 193 ++------- shared/tests/e2e/generate-ios-report.mts | 391 +----------------- shared/tests/e2e/generate-report-shared.mts | 380 +++++++++++++++++ 4 files changed, 443 insertions(+), 527 deletions(-) create mode 100644 shared/tests/e2e/generate-report-shared.mts diff --git a/shared/package.json b/shared/package.json index ce1c25de2583..622a6634fa2a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -71,9 +71,9 @@ "test:e2e:desktop:branch": "playwright test --config tests/e2e/electron/playwright.config.ts tests/e2e/electron/flows/team-member.test.ts && yarn test:e2e:desktop:report", "test:e2e:desktop:report": "node tests/e2e/generate-electron-report.mts && open tests/results/electron-report.html", "test:e2e:desktop:save-baseline": "node tests/e2e/generate-electron-report.mts --save-baseline", - "test:e2e:ios": "rm -rf tests/results/ios-debug && MAESTRO_CLI_NO_ANALYTICS=1 maestro test --config .maestro/e2e/config.yaml --debug-output tests/results/ios-debug --flatten-debug-output --env KB_SMOKE_USER=${KB_SMOKE_USER} .maestro/e2e/setup.yaml .maestro/e2e/flows/ ; node tests/e2e/generate-ios-report.mts", - "test:e2e:ios:branch": "rm -rf tests/results/ios-debug && MAESTRO_CLI_NO_ANALYTICS=1 maestro test --config .maestro/e2e/config.yaml --debug-output tests/results/ios-debug --flatten-debug-output --env KB_SMOKE_USER=${KB_SMOKE_USER} .maestro/e2e/setup.yaml .maestro/e2e/flows/team-member.yaml ; node tests/e2e/generate-ios-report.mts && yarn test:e2e:ios:report", - "test:e2e:ios:report": "open tests/results/ios-report.html", + "test:e2e:ios": "rm -rf tests/results/ios-debug && MAESTRO_CLI_NO_ANALYTICS=1 maestro test --config .maestro/e2e/config.yaml --debug-output tests/results/ios-debug --flatten-debug-output --env KB_SMOKE_USER=${KB_SMOKE_USER} .maestro/e2e/setup.yaml .maestro/e2e/flows/", + "test:e2e:ios:branch": "rm -rf tests/results/ios-debug && MAESTRO_CLI_NO_ANALYTICS=1 maestro test --config .maestro/e2e/config.yaml --debug-output tests/results/ios-debug --flatten-debug-output --env KB_SMOKE_USER=${KB_SMOKE_USER} .maestro/e2e/setup.yaml .maestro/e2e/flows/team-member.yaml ; yarn test:e2e:ios:report", + "test:e2e:ios:report": "node tests/e2e/generate-ios-report.mts && open tests/results/ios-report.html", "test:e2e:ios:save-baseline": "node tests/e2e/generate-ios-report.mts --save-baseline", "test:unit": "napi-postinstall unrs-resolver 1.11.1 check; jest --runInBand", "test:unit:ios": "xcodebuild test -project ./ios/Keybase.xcodeproj -scheme 'Keybase For Test' -destination 'platform=iOS Simulator,name=iPhone 6s,OS=9.3'", diff --git a/shared/tests/e2e/generate-electron-report.mts b/shared/tests/e2e/generate-electron-report.mts index 838c89c2d43d..ffbf68a5dea7 100644 --- a/shared/tests/e2e/generate-electron-report.mts +++ b/shared/tests/e2e/generate-electron-report.mts @@ -1,11 +1,7 @@ import * as fs from 'fs' import * as path from 'path' -import {createRequire} from 'module' -import {buildPage} from './generate-ios-report.mts' - -const require = createRequire(import.meta.url) -// pngjs is a transitive dep (via image-diff etc.) — intentionally not in package.json -const {PNG} = require('pngjs') as {PNG: {sync: {read: (buf: Buffer) => {data: Buffer; width: number; height: number}}}} +import {computeDiff, buildReport} from './generate-report-shared.mts' +import type {CardData, Section} from './generate-report-shared.mts' const resultsPath = 'tests/results/report/results.json' const debugDir = 'tests/results/electron-debug' @@ -21,27 +17,6 @@ type PlaywrightSpec = {title: string; ok: boolean; tests: PlaywrightTest[]} type PlaywrightSuite = {title: string; specs: PlaywrightSpec[]; suites?: PlaywrightSuite[]} type Report = {suites: PlaywrightSuite[]} -type DiffResult = {pct: number; changed: number; total: number} - -type StorybookCase = { - relPath: string - label: string - screenshotPath: string - prevScreenshotPath: string | null - diff: DiffResult | null -} - -type TestCase = { - key: string - label: string - passed: boolean - durationMs: number - screenshotPath: string | null - prevScreenshotPath: string | null - diff: DiffResult | null - errorMessage: string | null -} - function slugify(s: string): string { return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') } @@ -54,29 +29,12 @@ function flattenSpecs(suite: PlaywrightSuite): Array<{suiteName: string; spec: P return out } -function computeDiff(pathA: string, pathB: string): DiffResult | null { - try { - const a = PNG.sync.read(fs.readFileSync(pathA)) - const b = PNG.sync.read(fs.readFileSync(pathB)) - if (a.width !== b.width || a.height !== b.height) return null - const total = a.width * a.height - let changed = 0 - for (let i = 0; i < a.data.length; i += 4) { - if (Math.abs(a.data[i]! - b.data[i]!) + Math.abs(a.data[i + 1]! - b.data[i + 1]!) + Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 45) changed++ - } - return {pct: (changed / total) * 100, changed, total} - } catch { - return null - } -} - -function parseReport(report: Report): TestCase[] { - const cases: TestCase[] = [] +function parseReport(report: Report): CardData[] { + const cards: CardData[] = [] for (const suite of report.suites) { for (const {suiteName, spec} of flattenSpecs(suite)) { const baseKey = `${slugify(suiteName)}-${slugify(spec.title)}` - // Group by project so light and dark produce separate TestCase entries const byProject = new Map() for (const t of spec.tests) { const proj = t.projectName @@ -89,7 +47,6 @@ function parseReport(report: Report): TestCase[] { const key = isDark ? `${baseKey}-dark` : baseKey const label = isDark ? `${suiteName} · ${spec.title} (dark)` : `${suiteName} · ${spec.title}` - // take the last non-skipped result (handles retries) const allResults = tests.flatMap(t => t.results) const result = allResults.filter(r => r.status !== 'skipped').at(-1) ?? allResults.at(-1) if (!result) continue @@ -117,16 +74,16 @@ function parseReport(report: Report): TestCase[] { const prevScreenshotPath = fs.existsSync(prevPath) ? prevPath : null const diff = screenshotPath && prevScreenshotPath ? computeDiff(screenshotPath, prevScreenshotPath) : null - cases.push({key, label, passed, durationMs, screenshotPath, prevScreenshotPath, diff, errorMessage}) + cards.push({label, passed, durationMs, screenshotPath, prevScreenshotPath, diff, errorMessage}) } } } - return cases + return cards } -function parseStorybookScreenshots(): StorybookCase[] { +function parseStorybookScreenshots(): CardData[] { if (!fs.existsSync(storybookDir)) return [] - const cases: StorybookCase[] = [] + const cases: CardData[] = [] function walk(dir: string, rel: string) { for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { const fullPath = path.join(dir, entry.name) @@ -138,114 +95,37 @@ function parseStorybookScreenshots(): StorybookCase[] { const prevPath = path.join(storybookPrevDir, relPath) const prevScreenshotPath = fs.existsSync(prevPath) ? prevPath : null const diff = prevScreenshotPath ? computeDiff(fullPath, prevScreenshotPath) : null - cases.push({relPath, label, screenshotPath: fullPath, prevScreenshotPath, diff}) + cases.push({label, passed: true, durationMs: 0, screenshotPath: fullPath, prevScreenshotPath, diff, errorMessage: null}) } } } walk(storybookDir, '') - return cases.sort((a, b) => a.relPath.localeCompare(b.relPath)) + return cases.sort((a, b) => a.label.localeCompare(b.label)) } -function saveStorybookBaseline(cases: StorybookCase[]) { - fs.mkdirSync(storybookPrevDir, {recursive: true}) +function saveBaseline(cards: CardData[]) { + fs.mkdirSync(prevDir, {recursive: true}) let saved = 0 - for (const c of cases) { - if (!fs.existsSync(c.screenshotPath)) continue - const destDir = path.dirname(path.join(storybookPrevDir, c.relPath)) - fs.mkdirSync(destDir, {recursive: true}) - fs.copyFileSync(c.screenshotPath, path.join(storybookPrevDir, c.relPath)) + for (const card of cards) { + if (!card.screenshotPath || !fs.existsSync(card.screenshotPath)) continue + fs.copyFileSync(card.screenshotPath, path.join(prevDir, path.basename(card.screenshotPath))) saved++ } - console.log(`Storybook baseline saved: ${saved} screenshots to ${storybookPrevDir}/`) -} - -function formatDuration(ms: number): string { - return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s` -} - -function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - -function buildHtml(cases: TestCase[], storybookCases: StorybookCase[], timestamp: string): string { - const totalPassed = cases.filter(c => c.passed).length - const totalFailed = cases.length - totalPassed - const allPassed = totalFailed === 0 - const hasDiff = cases.some(c => c.diff !== null) || storybookCases.some(c => c.diff !== null) - - const rel = (p: string) => path.relative(path.dirname(outputPath), p) - - const e2eCards = cases.map((c, i) => { - const badge = c.passed ? 'PASS' : 'FAIL' - const error = c.errorMessage ? `
${escapeHtml(c.errorMessage)}
` : '' - const deltaBadge = c.diff - ? `Δ ${c.diff.pct.toFixed(1)}%` - : '' - - let visual: string - if (c.screenshotPath && c.prevScreenshotPath) { - visual = `
- current - baseline -
-
BASELINE
-
NOW
-
` - } else if (c.screenshotPath) { - visual = `
${escapeHtml(c.label)}
` - } else { - visual = `
No screenshot
` - } - - return `
-
${badge}${deltaBadge}${escapeHtml(c.label)}${formatDuration(c.durationMs)}${error}
- ${visual} -
` - }).join('\n') - - const sbOffset = cases.length - const sbCards = storybookCases.map((c, i) => { - const deltaBadge = c.diff - ? `Δ ${c.diff.pct.toFixed(1)}%` - : '' - - let visual: string - if (c.prevScreenshotPath) { - visual = `
- current - baseline -
-
BASELINE
-
NOW
-
` - } else { - visual = `
${escapeHtml(c.label)}
` - } - - return `
-
${deltaBadge}${escapeHtml(c.label)}
- ${visual} -
` - }).join('\n') - - const storybookSection = storybookCases.length > 0 - ? `
Storybook · ${storybookCases.length} stories
\n${sbCards}` - : '' - - const allCards = [e2eCards, storybookSection].filter(Boolean).join('\n') - - return buildPage('Keybase Electron E2E Tests', allPassed, totalPassed, totalFailed, cases.length, hasDiff, timestamp, allCards) + console.log(`Baseline saved: ${saved} screenshots to ${prevDir}/`) } -function saveBaseline(cases: TestCase[]) { - fs.mkdirSync(prevDir, {recursive: true}) +function saveStorybookBaseline(cards: CardData[]) { + fs.mkdirSync(storybookPrevDir, {recursive: true}) let saved = 0 - for (const c of cases) { - if (!c.screenshotPath || !fs.existsSync(c.screenshotPath)) continue - fs.copyFileSync(c.screenshotPath, path.join(prevDir, `${c.key}.png`)) + for (const card of cards) { + if (!card.screenshotPath || !fs.existsSync(card.screenshotPath)) continue + const relPath = path.relative(storybookDir, card.screenshotPath) + const destDir = path.dirname(path.join(storybookPrevDir, relPath)) + fs.mkdirSync(destDir, {recursive: true}) + fs.copyFileSync(card.screenshotPath, path.join(storybookPrevDir, relPath)) saved++ } - console.log(`Baseline saved: ${saved} screenshots to ${prevDir}/`) + console.log(`Storybook baseline saved: ${saved} screenshots to ${storybookPrevDir}/`) } function main() { @@ -258,31 +138,36 @@ function main() { } const report = JSON.parse(fs.readFileSync(resultsPath, 'utf8')) as Report - const cases = parseReport(report) + const e2eCards = parseReport(report) - if (cases.length === 0) { + if (e2eCards.length === 0) { console.error('No test cases found in', resultsPath) process.exit(1) } - const storybookCases = parseStorybookScreenshots() + const sbCards = parseStorybookScreenshots() if (isSaveBaseline) { - saveBaseline(cases) - saveStorybookBaseline(storybookCases) + saveBaseline(e2eCards) + saveStorybookBaseline(sbCards) return } + const sections: Section[] = [{cards: e2eCards}] + if (sbCards.length > 0) { + sections.push({header: `Storybook · ${sbCards.length} stories`, cards: sbCards, excludeFromStats: true}) + } + const timestamp = new Date().toLocaleString() - const html = buildHtml(cases, storybookCases, timestamp) + const html = buildReport('Keybase Electron E2E Tests', sections, timestamp, outputPath) const outDir = path.dirname(outputPath) if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, {recursive: true}) fs.writeFileSync(outputPath, html) - const withDiff = [...cases, ...storybookCases].filter(c => c.diff !== null).length + const withDiff = [...e2eCards, ...sbCards].filter(c => c.diff !== null).length const diffNote = withDiff > 0 ? `, ${withDiff} vs baseline` : '' - const sbNote = storybookCases.length > 0 ? `, ${storybookCases.length} storybook stories` : '' - console.log(`Report written to ${outputPath} (${cases.filter(c => c.passed).length}/${cases.length} passed${diffNote}${sbNote})`) + const sbNote = sbCards.length > 0 ? `, ${sbCards.length} storybook stories` : '' + console.log(`Report written to ${outputPath} (${e2eCards.filter(c => c.passed).length}/${e2eCards.length} passed${diffNote}${sbNote})`) } main() diff --git a/shared/tests/e2e/generate-ios-report.mts b/shared/tests/e2e/generate-ios-report.mts index 6d9edd2d99d9..e1cd99bea183 100644 --- a/shared/tests/e2e/generate-ios-report.mts +++ b/shared/tests/e2e/generate-ios-report.mts @@ -1,10 +1,7 @@ import * as fs from 'fs' import * as path from 'path' -import {createRequire} from 'module' - -const require = createRequire(import.meta.url) -// pngjs is a transitive dep (via image-diff etc.) — intentionally not in package.json -const {PNG} = require('pngjs') as {PNG: {sync: {read: (buf: Buffer) => {data: Buffer; width: number; height: number}}}} +import {computeDiff, buildReport} from './generate-report-shared.mts' +import type {CardData, Section} from './generate-report-shared.mts' const debugDir = 'tests/results/ios-debug' const prevDir = 'tests/results/ios-prev' @@ -16,20 +13,6 @@ type CommandEntry = { metadata: {status: CommandStatus; timestamp: number; duration: number; sequenceNumber: number; error?: string} } -type DiffResult = {pct: number; changed: number; total: number} - -type ScreenshotResult = { - name: string - stem: string - passed: boolean - durationMs: number - screenshotPath: string | null - prevScreenshotPath: string | null - failureScreenshotPath: string | null - diff: DiffResult | null - errorMessage: string | null -} - function readCommandsFile(name: string): CommandEntry[] | null { const filePath = path.join(debugDir, `commands-(${name}).json`) if (!fs.existsSync(filePath)) return null @@ -52,23 +35,7 @@ function findFailureScreenshot(name: string): string | null { return searchDir(debugDir) } -function computeDiff(pathA: string, pathB: string): DiffResult | null { - try { - const a = PNG.sync.read(fs.readFileSync(pathA)) - const b = PNG.sync.read(fs.readFileSync(pathB)) - if (a.width !== b.width || a.height !== b.height) return null - const total = a.width * a.height - let changed = 0 - for (let i = 0; i < a.data.length; i += 4) { - if (Math.abs(a.data[i]! - b.data[i]!) + Math.abs(a.data[i + 1]! - b.data[i + 1]!) + Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 45) changed++ - } - return {pct: (changed / total) * 100, changed, total} - } catch { - return null - } -} - -function parseFlow(name: string): ScreenshotResult[] { +function parseFlow(name: string): CardData[] { const commands = readCommandsFile(name) const failed = commands?.find(c => c.metadata.status === 'FAILED') const passed = commands != null && commands.length > 0 && !failed @@ -78,14 +45,15 @@ function parseFlow(name: string): ScreenshotResult[] { : (commands == null || commands.length === 0 ? 'No command data found' : null) const failureScreenshotPath = passed ? null : findFailureScreenshot(name) + const displayName = name.replace(/^(smoke|flow)-/, '') + const stepFiles = fs.readdirSync(debugDir) .filter(f => (f === `${name}.png` || (f.startsWith(`${name}-`) && f.endsWith('.png')))) .sort() if (stepFiles.length === 0) { return [{ - name, - stem: name, + label: displayName, passed, durationMs, screenshotPath: null, @@ -96,16 +64,15 @@ function parseFlow(name: string): ScreenshotResult[] { }] } - const results: ScreenshotResult[] = stepFiles.map((file, idx) => { + const results: CardData[] = stepFiles.map((file, idx) => { const stem = file.replace('.png', '') const screenshotPath = path.join(debugDir, file) const prevPath = path.join(prevDir, file) const prevScreenshotPath = fs.existsSync(prevPath) ? prevPath : null const diff = prevScreenshotPath ? computeDiff(screenshotPath, prevScreenshotPath) : null - const label = stem.startsWith(`${name}-`) ? stem.slice(name.length + 1) : stem + const stepLabel = stem.startsWith(`${name}-`) ? stem.slice(name.length + 1) : stem return { - name: `${name} · ${label}`, - stem, + label: `${displayName} · ${stepLabel}`, passed, durationMs: idx === 0 ? durationMs : 0, screenshotPath, @@ -116,11 +83,9 @@ function parseFlow(name: string): ScreenshotResult[] { } }) - // If the flow failed and there's a Maestro failure screenshot, append it as a separate card if (!passed && failureScreenshotPath) { results.push({ - name: `${name} · failure`, - stem: `${name}-failure`, + label: `${displayName} · failure`, passed: false, durationMs: 0, screenshotPath: null, @@ -134,333 +99,18 @@ function parseFlow(name: string): ScreenshotResult[] { return results } -function formatDuration(ms: number): string { - return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s` -} - -function escapeHtml(s: string): string { - return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') -} - -function buildHtml(results: ScreenshotResult[], timestamp: string, title: string): string { - const totalPassed = results.filter(r => r.passed).length - const totalFailed = results.length - totalPassed - const allPassed = totalFailed === 0 - const hasDiff = results.some(r => r.diff !== null) - - const cards = results.map((r, i) => { - const badge = r.passed ? 'PASS' : 'FAIL' - const error = r.errorMessage ? `
${escapeHtml(r.errorMessage)}
` : '' - const diff = r.diff - const deltaBadge = diff - ? `Δ ${diff.pct.toFixed(1)}%` - : '' - const durStr = r.durationMs > 0 ? formatDuration(r.durationMs) : '' - - const rel = (p: string) => path.relative(path.dirname(outputPath), p) - let visual: string - if (r.screenshotPath && r.prevScreenshotPath) { - visual = `
- current - baseline -
-
BASELINE
-
NOW
-
` - } else if (r.screenshotPath) { - visual = `
${escapeHtml(r.name)}
` - } else if (r.failureScreenshotPath) { - visual = `
failure
` - } else { - visual = `
No screenshot
` - } - - return `
-
${badge}${deltaBadge}${escapeHtml(r.name.replace(/^(smoke|flow)-/, ''))}${durStr ? `${durStr}` : ''}${error}
- ${visual} -
` - }).join('\n') - - return buildPage(title, allPassed, totalPassed, totalFailed, results.length, hasDiff, timestamp, cards) -} - -function saveBaseline(results: ScreenshotResult[]) { +function saveBaseline(cards: CardData[]) { fs.mkdirSync(prevDir, {recursive: true}) let saved = 0 - for (const r of results) { - if (!r.screenshotPath || !fs.existsSync(r.screenshotPath)) continue - fs.copyFileSync(r.screenshotPath, path.join(prevDir, `${r.stem}.png`)) + for (const card of cards) { + if (!card.screenshotPath || !fs.existsSync(card.screenshotPath)) continue + const stem = path.basename(card.screenshotPath, '.png') + fs.copyFileSync(card.screenshotPath, path.join(prevDir, `${stem}.png`)) saved++ } console.log(`Baseline saved: ${saved} screenshots to ${prevDir}/`) } -export function buildPage(title: string, allPassed: boolean, passed: number, failed: number, total: number, hasDiff: boolean, timestamp: string, cards: string): string { - return ` - - - -${title} - - - -
-

${title}

-
${passed} passed · ${failed} failed · ${total} total${hasDiff ? ' · vs baseline' : ''}${timestamp}
-
-
-
${cards}
-${sliderScript()} - -` -} - -export function sharedCss(allPassed: boolean): string { - return `*{box-sizing:border-box;margin:0;padding:0} -body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f0f0f0;color:#222} -header{background:${allPassed ? '#1a7a3a' : '#c0392b'};color:#fff;padding:20px 28px} -.hdr-top{display:flex;align-items:center;gap:12px} -h1{font-size:20px;font-weight:600} -#slideshow-btn{background:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.4);color:#fff;border-radius:5px;padding:4px 10px;font-size:14px;cursor:pointer;line-height:1} -#slideshow-btn:hover{background:rgba(255,255,255,.35)} -#slideshow-btn.active{background:rgba(255,255,255,.35)} -.meta{margin-top:6px;font-size:13px;opacity:.85;display:flex;gap:16px;align-items:center} -.ts{opacity:.7} -.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;padding:20px 28px} -.card{background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.12);border-top:4px solid #ccc} -.card.ok{border-top-color:#27ae60}.card.fail{border-top-color:#e74c3c} -.hdr{padding:12px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:6px} -.name{font-weight:500;font-size:13px;flex:1;text-transform:capitalize} -.dur{font-size:12px;color:#999} -.badge{font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap} -.badge.pass{background:#d4edda;color:#1a7a3a}.badge.fail{background:#fde8e8;color:#c0392b} -.badge.diff-low{background:#e8f4fd;color:#1a5fa8}.badge.diff-mid{background:#fff3cd;color:#856404}.badge.diff-high{background:#fde8e8;color:#842029} -.error{width:100%;font-size:11px;color:#c0392b;background:#fde8e8;padding:3px 8px;border-radius:4px;word-break:break-word} -.solo-wrap{position:relative;overflow:hidden;background:#000} -.solo{width:100%;display:block}.dim{opacity:.85} -.empty{padding:28px;text-align:center;color:#bbb;font-size:12px;background:#fafafa} -.compare{position:relative;overflow:hidden;cursor:ew-resize;--split:50%;user-select:none} -.compare img,.solo-wrap img{display:block;width:100%;-webkit-user-drag:none;user-drag:none} -.img-after{position:relative} -.img-before{position:absolute;top:0;left:0;width:100%;clip-path:inset(0 calc(100% - var(--split)) 0 0)} -.handle{position:absolute;top:0;bottom:0;left:var(--split);transform:translateX(-50%);width:3px;background:rgba(255,255,255,.9);pointer-events:none} -.grip{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 1px 4px rgba(0,0,0,.4)} -.lbl{position:absolute;bottom:8px;font-size:10px;font-weight:700;letter-spacing:.05em;padding:2px 7px;border-radius:3px;background:rgba(0,0,0,.55);color:#fff;pointer-events:none} -.lbl-l{left:8px}.lbl-r{right:8px} -.section-hdr{grid-column:1/-1;font-size:15px;font-weight:600;padding:10px 0 4px;border-bottom:2px solid #ddd;margin-top:8px;color:#444} -.expand-btn{position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,.55);color:#fff;border:none;border-radius:5px;padding:5px 8px;font-size:15px;line-height:1;cursor:pointer;opacity:0;transition:opacity .15s;z-index:5} -.compare:hover .expand-btn,.solo-wrap:hover .expand-btn{opacity:1} -.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:1000;align-items:center;justify-content:center} -.overlay.open{display:flex} -.ov-wrap{position:relative} -.ov-close{position:absolute;top:-38px;right:0;background:none;border:none;color:#fff;font-size:22px;line-height:1;cursor:pointer;opacity:.75;padding:4px 8px} -.ov-close:hover{opacity:1} -.ov-compare{position:relative;overflow:hidden;cursor:ew-resize;--split:50%;user-select:none} -.ov-compare .img-after{display:block;max-height:85vh;max-width:85vw;width:auto;height:auto;-webkit-user-drag:none} -.ov-compare .img-before{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 calc(100% - var(--split)) 0 0);-webkit-user-drag:none} -.ov-compare .handle{position:absolute;top:0;bottom:0;left:var(--split);transform:translateX(-50%);width:3px;background:rgba(255,255,255,.9);pointer-events:none} -.ov-compare .grip{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border-radius:50%;width:30px;height:30px;display:flex;align-items:center;justify-content:center;font-size:13px;box-shadow:0 1px 6px rgba(0,0,0,.5)} -.ov-compare .lbl{position:absolute;bottom:10px;font-size:11px;font-weight:700;letter-spacing:.05em;padding:3px 9px;border-radius:3px;background:rgba(0,0,0,.55);color:#fff;pointer-events:none} -.ov-compare .lbl-l{left:10px}.ov-compare .lbl-r{right:10px} -@keyframes ov-fadein{from{opacity:0}to{opacity:1}} -.ov-solo{display:block;max-height:90vh;max-width:90vw;width:auto;height:auto;animation:ov-fadein .25s ease} -.ov-controls{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:1002;display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.55);border-radius:24px;padding:6px 14px} -.ov-controls button{background:none;border:none;color:#fff;font-size:18px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:4px;opacity:.85} -.ov-controls button:hover{opacity:1;background:rgba(255,255,255,.15)} -.ov-counter{color:rgba(255,255,255,.8);font-size:12px;font-weight:600;letter-spacing:.04em;min-width:52px;text-align:center} -.filter-wrap{padding:10px 28px 14px} -#filter-input{width:100%;max-width:480px;padding:6px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.3);background:rgba(255,255,255,.15);color:#fff;font-size:13px;outline:none} -#filter-input::placeholder{color:rgba(255,255,255,.6)} -#filter-input:focus{background:rgba(255,255,255,.25);border-color:rgba(255,255,255,.6)} -.card.hidden{display:none} -.section-hdr.hidden{display:none}` -} - -export function sliderScript(): string { - return `` -} - function main() { const isSaveBaseline = process.argv.includes('--save-baseline') @@ -471,21 +121,22 @@ function main() { .filter(name => name !== 'setup') .sort() if (testNames.length === 0) { console.error('No test results found in', debugDir); process.exit(1) } - const results = testNames.flatMap(parseFlow) + const cards = testNames.flatMap(parseFlow) if (isSaveBaseline) { - saveBaseline(results) + saveBaseline(cards) return } + const sections: Section[] = [{cards}] const timestamp = new Date().toLocaleString() - const html = buildHtml(results, timestamp, 'Keybase iOS E2E Tests') + const html = buildReport('Keybase iOS E2E Tests', sections, timestamp, outputPath) const outDir = path.dirname(outputPath) if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, {recursive: true}) fs.writeFileSync(outputPath, html) - const withDiff = results.filter(r => r.diff !== null).length + const withDiff = cards.filter(c => c.diff !== null).length const diffNote = withDiff > 0 ? `, ${withDiff} vs baseline` : '' - console.log(`Report written to ${outputPath} (${results.filter(r => r.passed).length}/${results.length} passed${diffNote})`) + console.log(`Report written to ${outputPath} (${cards.filter(c => c.passed).length}/${cards.length} passed${diffNote})`) } if (import.meta.url === `file://${process.argv[1]}`) { diff --git a/shared/tests/e2e/generate-report-shared.mts b/shared/tests/e2e/generate-report-shared.mts new file mode 100644 index 000000000000..efeeb0ab6e31 --- /dev/null +++ b/shared/tests/e2e/generate-report-shared.mts @@ -0,0 +1,380 @@ +import * as fs from 'fs' +import * as path from 'path' +import {createRequire} from 'module' + +const require = createRequire(import.meta.url) +// pngjs is a transitive dep (via image-diff etc.) — intentionally not in package.json +const {PNG} = require('pngjs') as {PNG: {sync: {read: (buf: Buffer) => {data: Buffer; width: number; height: number}}}} + +export type DiffResult = {pct: number; changed: number; total: number} + +export type CardData = { + label: string + passed: boolean + durationMs: number + screenshotPath: string | null + prevScreenshotPath: string | null + failureScreenshotPath?: string | null + diff: DiffResult | null + errorMessage: string | null +} + +export type Section = { + header?: string + cards: CardData[] + excludeFromStats?: boolean +} + +export function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') +} + +export function formatDuration(ms: number): string { + return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s` +} + +export function computeDiff(pathA: string, pathB: string): DiffResult | null { + try { + const a = PNG.sync.read(fs.readFileSync(pathA)) + const b = PNG.sync.read(fs.readFileSync(pathB)) + if (a.width !== b.width || a.height !== b.height) return null + const total = a.width * a.height + let changed = 0 + for (let i = 0; i < a.data.length; i += 4) { + if (Math.abs(a.data[i]! - b.data[i]!) + Math.abs(a.data[i + 1]! - b.data[i + 1]!) + Math.abs(a.data[i + 2]! - b.data[i + 2]!) > 45) changed++ + } + return {pct: (changed / total) * 100, changed, total} + } catch { + return null + } +} + +function buildCard(card: CardData, idx: number, relFn: (p: string) => string): string { + const badge = card.passed + ? 'PASS' + : 'FAIL' + const error = card.errorMessage ? `
${escapeHtml(card.errorMessage)}
` : '' + const diff = card.diff + const deltaBadge = diff + ? `Δ ${diff.pct.toFixed(1)}%` + : '' + const durStr = card.durationMs > 0 ? formatDuration(card.durationMs) : '' + + let visual: string + if (card.screenshotPath && card.prevScreenshotPath) { + visual = `
+ current + baseline +
+
BASELINE
+
NOW
+
` + } else if (card.screenshotPath) { + visual = `
${escapeHtml(card.label)}
` + } else if (card.failureScreenshotPath) { + visual = `
failure
` + } else { + visual = `
No screenshot
` + } + + return `
+
${badge}${deltaBadge}${escapeHtml(card.label)}${durStr ? `${durStr}` : ''}${error}
+ ${visual} +
` +} + +export function buildReport(title: string, sections: Section[], timestamp: string, outputPath: string): string { + const statsCards = sections.filter(s => !s.excludeFromStats).flatMap(s => s.cards) + const totalPassed = statsCards.filter(c => c.passed).length + const totalFailed = statsCards.length - totalPassed + const allPassed = totalFailed === 0 + const allCards = sections.flatMap(s => s.cards) + const hasDiff = allCards.some(c => c.diff !== null) + + const relFn = (p: string) => path.relative(path.dirname(outputPath), p) + + let idx = 0 + const cardHtml = sections.map(section => { + const sectionHeader = section.header + ? `
${escapeHtml(section.header)}
` + : '' + const cards = section.cards.map(card => buildCard(card, idx++, relFn)).join('\n') + return [sectionHeader, cards].filter(Boolean).join('\n') + }).join('\n') + + return buildPage(title, allPassed, totalPassed, totalFailed, statsCards.length, hasDiff, timestamp, cardHtml) +} + +export function buildPage(title: string, allPassed: boolean, passed: number, failed: number, total: number, hasDiff: boolean, timestamp: string, cards: string): string { + return ` + + + +${title} + + + +
+

${title}

+
${passed} passed · ${failed} failed · ${total} total${hasDiff ? ' · vs baseline' : ''}${timestamp}
+
${hasDiff ? '' : ''}
+
+
${cards}
+${sliderScript()} + +` +} + +export function sharedCss(allPassed: boolean): string { + return `*{box-sizing:border-box;margin:0;padding:0} +body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#f0f0f0;color:#222} +header{background:${allPassed ? '#1a7a3a' : '#c0392b'};color:#fff;padding:20px 28px} +.hdr-top{display:flex;align-items:center;gap:12px} +h1{font-size:20px;font-weight:600} +#slideshow-btn{background:rgba(255,255,255,.2);border:1px solid rgba(255,255,255,.4);color:#fff;border-radius:5px;padding:4px 10px;font-size:14px;cursor:pointer;line-height:1} +#slideshow-btn:hover{background:rgba(255,255,255,.35)} +#slideshow-btn.active{background:rgba(255,255,255,.35)} +.meta{margin-top:6px;font-size:13px;opacity:.85;display:flex;gap:16px;align-items:center} +.ts{opacity:.7} +.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;padding:20px 28px} +.card{background:#fff;border-radius:8px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.12);border-top:4px solid #ccc} +.card.ok{border-top-color:#27ae60}.card.fail{border-top-color:#e74c3c} +.hdr{padding:12px 14px;display:flex;flex-wrap:wrap;align-items:center;gap:6px} +.name{font-weight:500;font-size:13px;flex:1;text-transform:capitalize} +.dur{font-size:12px;color:#999} +.badge{font-size:10px;font-weight:700;padding:2px 6px;border-radius:3px;white-space:nowrap} +.badge.pass{background:#d4edda;color:#1a7a3a}.badge.fail{background:#fde8e8;color:#c0392b} +.badge.diff-low{background:#e8f4fd;color:#1a5fa8}.badge.diff-mid{background:#fff3cd;color:#856404}.badge.diff-high{background:#fde8e8;color:#842029} +.error{width:100%;font-size:11px;color:#c0392b;background:#fde8e8;padding:3px 8px;border-radius:4px;word-break:break-word} +.solo-wrap{position:relative;overflow:hidden;background:#000} +.solo{width:100%;display:block}.dim{opacity:.85} +.empty{padding:28px;text-align:center;color:#bbb;font-size:12px;background:#fafafa} +.compare{position:relative;overflow:hidden;cursor:ew-resize;--split:50%;user-select:none} +.compare img,.solo-wrap img{display:block;width:100%;-webkit-user-drag:none;user-drag:none} +.img-after{position:relative} +.img-before{position:absolute;top:0;left:0;width:100%;clip-path:inset(0 calc(100% - var(--split)) 0 0)} +.handle{position:absolute;top:0;bottom:0;left:var(--split);transform:translateX(-50%);width:3px;background:rgba(255,255,255,.9);pointer-events:none} +.grip{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border-radius:50%;width:26px;height:26px;display:flex;align-items:center;justify-content:center;font-size:11px;box-shadow:0 1px 4px rgba(0,0,0,.4)} +.lbl{position:absolute;bottom:8px;font-size:10px;font-weight:700;letter-spacing:.05em;padding:2px 7px;border-radius:3px;background:rgba(0,0,0,.55);color:#fff;pointer-events:none} +.lbl-l{left:8px}.lbl-r{right:8px} +.section-hdr{grid-column:1/-1;font-size:15px;font-weight:600;padding:10px 0 4px;border-bottom:2px solid #ddd;margin-top:8px;color:#444} +.expand-btn{position:absolute;bottom:10px;right:10px;background:rgba(0,0,0,.55);color:#fff;border:none;border-radius:5px;padding:5px 8px;font-size:15px;line-height:1;cursor:pointer;opacity:0;transition:opacity .15s;z-index:5} +.compare:hover .expand-btn,.solo-wrap:hover .expand-btn{opacity:1} +.overlay{display:none;position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:1000;align-items:center;justify-content:center} +.overlay.open{display:flex} +.ov-wrap{position:relative} +.ov-close{position:absolute;top:-38px;right:0;background:none;border:none;color:#fff;font-size:22px;line-height:1;cursor:pointer;opacity:.75;padding:4px 8px} +.ov-close:hover{opacity:1} +.ov-compare{position:relative;overflow:hidden;cursor:ew-resize;--split:50%;user-select:none} +.ov-compare .img-after{display:block;max-height:85vh;max-width:85vw;width:auto;height:auto;-webkit-user-drag:none} +.ov-compare .img-before{position:absolute;top:0;left:0;width:100%;height:100%;clip-path:inset(0 calc(100% - var(--split)) 0 0);-webkit-user-drag:none} +.ov-compare .handle{position:absolute;top:0;bottom:0;left:var(--split);transform:translateX(-50%);width:3px;background:rgba(255,255,255,.9);pointer-events:none} +.ov-compare .grip{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);background:#fff;border-radius:50%;width:30px;height:30px;display:flex;align-items:center;justify-content:center;font-size:13px;box-shadow:0 1px 6px rgba(0,0,0,.5)} +.ov-compare .lbl{position:absolute;bottom:10px;font-size:11px;font-weight:700;letter-spacing:.05em;padding:3px 9px;border-radius:3px;background:rgba(0,0,0,.55);color:#fff;pointer-events:none} +.ov-compare .lbl-l{left:10px}.ov-compare .lbl-r{right:10px} +@keyframes ov-fadein{from{opacity:0}to{opacity:1}} +.ov-solo{display:block;max-height:90vh;max-width:90vw;width:auto;height:auto;animation:ov-fadein .25s ease} +.ov-controls{position:fixed;bottom:20px;left:50%;transform:translateX(-50%);z-index:1002;display:flex;align-items:center;gap:8px;background:rgba(0,0,0,.55);border-radius:24px;padding:6px 14px} +.ov-controls button{background:none;border:none;color:#fff;font-size:18px;line-height:1;cursor:pointer;padding:2px 6px;border-radius:4px;opacity:.85} +.ov-controls button:hover{opacity:1;background:rgba(255,255,255,.15)} +.ov-counter{color:rgba(255,255,255,.8);font-size:12px;font-weight:600;letter-spacing:.04em;min-width:52px;text-align:center} +.filter-wrap{padding:10px 28px 14px;display:flex;align-items:center;gap:16px;flex-wrap:wrap} +#filter-input{width:100%;max-width:480px;padding:6px 12px;border-radius:6px;border:1px solid rgba(255,255,255,.3);background:rgba(255,255,255,.15);color:#fff;font-size:13px;outline:none} +#filter-input::placeholder{color:rgba(255,255,255,.6)} +#filter-input:focus{background:rgba(255,255,255,.25);border-color:rgba(255,255,255,.6)} +.diff-only-label{display:flex;align-items:center;gap:6px;font-size:13px;color:#fff;cursor:pointer;white-space:nowrap;user-select:none} +.diff-only-label input{cursor:pointer;accent-color:#fff;width:14px;height:14px} +.card.hidden{display:none} +.section-hdr.hidden{display:none}` +} + +export function sliderScript(): string { + return `` +}