Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion shared/common-adapters/tabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ const Tabs = <TitleT extends string>(props: Props<TitleT>) => (
style={Styles.collapseStyles([styles.tabContainer, props.clickableBoxStyle, props.clickableTabStyle])}
fullWidth={true}
>
<Kb.Box2 direction="horizontal" fullWidth={true} alignItems="center" style={Styles.collapseStyles([styles.tab, selected && styles.selected, props.tabStyle])}>
<Kb.Box2 direction="horizontal" fullWidth={true} alignItems="center" justifyContent="center" style={Styles.collapseStyles([styles.tab, selected && styles.selected, props.tabStyle])}>
{tab.icon ? (
<Kb.IconAuto type={tab.icon} style={selected ? styles.iconSelected : styles.icon} />
) : (
Expand Down
2 changes: 1 addition & 1 deletion shared/crypto/output.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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'",
Expand Down
193 changes: 39 additions & 154 deletions shared/tests/e2e/generate-electron-report.mts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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, '')
}
Expand All @@ -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<string, PlaywrightTest[]>()
for (const t of spec.tests) {
const proj = t.projectName
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}

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 ? '<span class="badge pass">PASS</span>' : '<span class="badge fail">FAIL</span>'
const error = c.errorMessage ? `<div class="error">${escapeHtml(c.errorMessage)}</div>` : ''
const deltaBadge = c.diff
? `<span class="badge ${c.diff.pct < 1 ? 'diff-low' : c.diff.pct < 5 ? 'diff-mid' : 'diff-high'}" title="${c.diff.changed.toLocaleString()} of ${c.diff.total.toLocaleString()} pixels changed">Δ ${c.diff.pct.toFixed(1)}%</span>`
: ''

let visual: string
if (c.screenshotPath && c.prevScreenshotPath) {
visual = `<div class="compare" id="cmp${i}">
<img class="img-after" src="${rel(c.screenshotPath)}" alt="current" loading="lazy">
<img class="img-before" src="${rel(c.prevScreenshotPath)}" alt="baseline" loading="lazy">
<div class="handle"><div class="grip">⇔</div></div>
<div class="lbl lbl-l">BASELINE</div>
<div class="lbl lbl-r">NOW</div>
</div>`
} else if (c.screenshotPath) {
visual = `<div class="solo-wrap"><img class="solo" src="${rel(c.screenshotPath)}" alt="${escapeHtml(c.label)}" loading="lazy"></div>`
} else {
visual = `<div class="empty">No screenshot</div>`
}

return `<div class="card ${c.passed ? 'ok' : 'fail'}">
<div class="hdr">${badge}${deltaBadge}<span class="name">${escapeHtml(c.label)}</span><span class="dur">${formatDuration(c.durationMs)}</span>${error}</div>
${visual}
</div>`
}).join('\n')

const sbOffset = cases.length
const sbCards = storybookCases.map((c, i) => {
const deltaBadge = c.diff
? `<span class="badge ${c.diff.pct < 1 ? 'diff-low' : c.diff.pct < 5 ? 'diff-mid' : 'diff-high'}" title="${c.diff.changed.toLocaleString()} of ${c.diff.total.toLocaleString()} pixels changed">Δ ${c.diff.pct.toFixed(1)}%</span>`
: ''

let visual: string
if (c.prevScreenshotPath) {
visual = `<div class="compare" id="cmp${sbOffset + i}">
<img class="img-after" src="${rel(c.screenshotPath)}" alt="current" loading="lazy">
<img class="img-before" src="${rel(c.prevScreenshotPath)}" alt="baseline" loading="lazy">
<div class="handle"><div class="grip">⇔</div></div>
<div class="lbl lbl-l">BASELINE</div>
<div class="lbl lbl-r">NOW</div>
</div>`
} else {
visual = `<div class="solo-wrap"><img class="solo" src="${rel(c.screenshotPath)}" alt="${escapeHtml(c.label)}" loading="lazy"></div>`
}

return `<div class="card ok">
<div class="hdr">${deltaBadge}<span class="name">${escapeHtml(c.label)}</span></div>
${visual}
</div>`
}).join('\n')

const storybookSection = storybookCases.length > 0
? `<div class="section-hdr">Storybook · ${storybookCases.length} stories</div>\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() {
Expand All @@ -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()
Loading