diff --git a/.env.sample b/.env.sample index 4bfc763..e88aa75 100644 --- a/.env.sample +++ b/.env.sample @@ -12,3 +12,5 @@ LOG_BROWSER_CONSOLE= # Enable log of the browser console (set to 1 to ena LOG_NODE_CONSOLE= # Enable log of the node console (set to 1 to enable), needs PRINT_ONGOING_TEST_LOGS SESSION_DEBUG= # Enable debug mode on session-desktop (set to 1 to enable), needs PRINT_ONGOING_TEST_LOGS # DEBUG="pw:*" # warning: this is very verbose and needs `PRINT_ONGOING_TEST_LOGS=1` set too +NO_TUI= # Disable TUI reporter (set to 1 to disable) +USE_XVFB= # Use Xvfb to run tests on headless systems (set to 1 to enable, only linux) \ No newline at end of file diff --git a/.github/workflows/pull.yml b/.github/workflows/pull.yml index cb4c9af..4ecb812 100644 --- a/.github/workflows/pull.yml +++ b/.github/workflows/pull.yml @@ -86,4 +86,4 @@ jobs: - name: Run the only test we can without a browser shell: bash - run: PRINT_FAILED_TEST_LOGS=1 pnpm playwright test -g "Enforce localized strings return expected values" + run: PRINT_FAILED_TEST_LOGS=1 pnpm playwright test -g "Enforce localized strings" diff --git a/.gitignore b/.gitignore index 80bf8e8..22c5312 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ dist/ *-difference.png *-current*.png *-diff*.png +**/.DS_Store + +temp-*.jpeg diff --git a/__screenshots__/Change-avatar/avatar-updated-blue-darwin.jpeg b/__screenshots__/Change-avatar/avatar-updated-blue-darwin.jpeg deleted file mode 100644 index b37f10f..0000000 --- a/__screenshots__/Change-avatar/avatar-updated-blue-darwin.jpeg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2f966cb63def91347061c97508d9e3ac3b12693126154a0625345e06d2200985 -size 934 diff --git a/playwright.config.ts b/playwright.config.ts index 87f9e78..7f05f1b 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,7 +9,14 @@ dotenv.config({ quiet: true }); export default defineConfig({ timeout: 350000, globalTimeout: 6000000, - reporter: [['./sessionReporter.ts'], ['allure-playwright']], + reporter: [ + [ + process.stdout.isTTY && process.env.NO_TUI !== '1' + ? './tuiReporter.ts' + : './sessionReporter.ts', + ], + // ['allure-playwright'], // enabling starts generating reports to the allure-results folder + ], testDir: './tests/automation', testIgnore: '*.js', outputDir: './tests/automation/test-results', diff --git a/screenshots/Add avatar/avatar-updated-blue-darwin.jpeg b/screenshots/Add avatar/avatar-updated-blue-darwin.jpeg deleted file mode 100644 index cba87b7..0000000 --- a/screenshots/Add avatar/avatar-updated-blue-darwin.jpeg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e9069447a34a67f2695f041f31e4902b72651c4162d733086635ea689fb7492b -size 1262 diff --git a/screenshots/Add avatar/avatar-updated-blue-linux.jpeg b/screenshots/Add avatar/avatar-updated-blue-linux.jpeg deleted file mode 100644 index 3f21393..0000000 --- a/screenshots/Add avatar/avatar-updated-blue-linux.jpeg +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd339a84c915a8d2fb312166f96ce4c833ee36206b552b5d83430da53355d0bb -size 1255 diff --git a/screenshots/Add-avatar/avatar-updated-blue-darwin.jpeg b/screenshots/Add-avatar/avatar-updated-blue-darwin.jpeg new file mode 100644 index 0000000..48f5a98 --- /dev/null +++ b/screenshots/Add-avatar/avatar-updated-blue-darwin.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8b410f1c4180f2b6cf1371d71b34722ccef14203e04744bcc26fbe3104e1a2a9 +size 1271 diff --git a/screenshots/Add-avatar/avatar-updated-blue-linux.jpeg b/screenshots/Add-avatar/avatar-updated-blue-linux.jpeg new file mode 100644 index 0000000..cb4eab5 --- /dev/null +++ b/screenshots/Add-avatar/avatar-updated-blue-linux.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:83958d0b31ded55c79f1e4f2840e86bf947cf11b655c91ac4c2dbf1e017638f2 +size 1271 diff --git a/screenshots/Avatar-syncs/avatar-updated-blue-darwin.jpeg b/screenshots/Avatar-syncs/avatar-updated-blue-darwin.jpeg index cba87b7..48f5a98 100644 --- a/screenshots/Avatar-syncs/avatar-updated-blue-darwin.jpeg +++ b/screenshots/Avatar-syncs/avatar-updated-blue-darwin.jpeg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9069447a34a67f2695f041f31e4902b72651c4162d733086635ea689fb7492b -size 1262 +oid sha256:8b410f1c4180f2b6cf1371d71b34722ccef14203e04744bcc26fbe3104e1a2a9 +size 1271 diff --git a/screenshots/Avatar-syncs/avatar-updated-blue-linux.jpeg b/screenshots/Avatar-syncs/avatar-updated-blue-linux.jpeg index 3f21393..cb4eab5 100644 --- a/screenshots/Avatar-syncs/avatar-updated-blue-linux.jpeg +++ b/screenshots/Avatar-syncs/avatar-updated-blue-linux.jpeg @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cd339a84c915a8d2fb312166f96ce4c833ee36206b552b5d83430da53355d0bb -size 1255 +oid sha256:83958d0b31ded55c79f1e4f2840e86bf947cf11b655c91ac4c2dbf1e017638f2 +size 1271 diff --git a/screenshots/Network-page-node-count-1---dark-theme/swarm-1-node-dark-darwin.jpeg b/screenshots/Network-page-with-1-dark/swarm-1-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-1---dark-theme/swarm-1-node-dark-darwin.jpeg rename to screenshots/Network-page-with-1-dark/swarm-1-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-1---dark-theme/swarm-1-node-dark-linux.jpeg b/screenshots/Network-page-with-1-dark/swarm-1-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-1---dark-theme/swarm-1-node-dark-linux.jpeg rename to screenshots/Network-page-with-1-dark/swarm-1-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-10---dark-theme/swarm-10-node-dark-darwin.jpeg b/screenshots/Network-page-with-10-dark/swarm-10-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-10---dark-theme/swarm-10-node-dark-darwin.jpeg rename to screenshots/Network-page-with-10-dark/swarm-10-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-10---dark-theme/swarm-10-node-dark-linux.jpeg b/screenshots/Network-page-with-10-dark/swarm-10-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-10---dark-theme/swarm-10-node-dark-linux.jpeg rename to screenshots/Network-page-with-10-dark/swarm-10-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-2---dark-theme/swarm-2-node-dark-darwin.jpeg b/screenshots/Network-page-with-2-dark/swarm-2-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-2---dark-theme/swarm-2-node-dark-darwin.jpeg rename to screenshots/Network-page-with-2-dark/swarm-2-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-2---dark-theme/swarm-2-node-dark-linux.jpeg b/screenshots/Network-page-with-2-dark/swarm-2-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-2---dark-theme/swarm-2-node-dark-linux.jpeg rename to screenshots/Network-page-with-2-dark/swarm-2-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-3---dark-theme/swarm-3-node-dark-darwin.jpeg b/screenshots/Network-page-with-3-dark/swarm-3-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-3---dark-theme/swarm-3-node-dark-darwin.jpeg rename to screenshots/Network-page-with-3-dark/swarm-3-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-3---dark-theme/swarm-3-node-dark-linux.jpeg b/screenshots/Network-page-with-3-dark/swarm-3-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-3---dark-theme/swarm-3-node-dark-linux.jpeg rename to screenshots/Network-page-with-3-dark/swarm-3-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-4---dark-theme/swarm-4-node-dark-darwin.jpeg b/screenshots/Network-page-with-4-dark/swarm-4-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-4---dark-theme/swarm-4-node-dark-darwin.jpeg rename to screenshots/Network-page-with-4-dark/swarm-4-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-4---dark-theme/swarm-4-node-dark-linux.jpeg b/screenshots/Network-page-with-4-dark/swarm-4-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-4---dark-theme/swarm-4-node-dark-linux.jpeg rename to screenshots/Network-page-with-4-dark/swarm-4-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-5---dark-theme/swarm-5-node-dark-darwin.jpeg b/screenshots/Network-page-with-5-dark/swarm-5-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-5---dark-theme/swarm-5-node-dark-darwin.jpeg rename to screenshots/Network-page-with-5-dark/swarm-5-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-5---dark-theme/swarm-5-node-dark-linux.jpeg b/screenshots/Network-page-with-5-dark/swarm-5-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-5---dark-theme/swarm-5-node-dark-linux.jpeg rename to screenshots/Network-page-with-5-dark/swarm-5-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-6---dark-theme/swarm-6-node-dark-darwin.jpeg b/screenshots/Network-page-with-6-dark/swarm-6-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-6---dark-theme/swarm-6-node-dark-darwin.jpeg rename to screenshots/Network-page-with-6-dark/swarm-6-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-6---dark-theme/swarm-6-node-dark-linux.jpeg b/screenshots/Network-page-with-6-dark/swarm-6-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-6---dark-theme/swarm-6-node-dark-linux.jpeg rename to screenshots/Network-page-with-6-dark/swarm-6-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-7---dark-theme/swarm-7-node-dark-darwin.jpeg b/screenshots/Network-page-with-7-dark/swarm-7-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-7---dark-theme/swarm-7-node-dark-darwin.jpeg rename to screenshots/Network-page-with-7-dark/swarm-7-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-7---dark-theme/swarm-7-node-dark-linux.jpeg b/screenshots/Network-page-with-7-dark/swarm-7-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-7---dark-theme/swarm-7-node-dark-linux.jpeg rename to screenshots/Network-page-with-7-dark/swarm-7-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-7---light-theme/swarm-7-node-light-darwin.jpeg b/screenshots/Network-page-with-7-light/swarm-7-node-light-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-7---light-theme/swarm-7-node-light-darwin.jpeg rename to screenshots/Network-page-with-7-light/swarm-7-node-light-darwin.jpeg diff --git a/screenshots/Network-page-node-count-7---light-theme/swarm-7-node-light-linux.jpeg b/screenshots/Network-page-with-7-light/swarm-7-node-light-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-7---light-theme/swarm-7-node-light-linux.jpeg rename to screenshots/Network-page-with-7-light/swarm-7-node-light-linux.jpeg diff --git a/screenshots/Network-page-node-count-8---dark-theme/swarm-8-node-dark-darwin.jpeg b/screenshots/Network-page-with-8-dark/swarm-8-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-8---dark-theme/swarm-8-node-dark-darwin.jpeg rename to screenshots/Network-page-with-8-dark/swarm-8-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-8---dark-theme/swarm-8-node-dark-linux.jpeg b/screenshots/Network-page-with-8-dark/swarm-8-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-8---dark-theme/swarm-8-node-dark-linux.jpeg rename to screenshots/Network-page-with-8-dark/swarm-8-node-dark-linux.jpeg diff --git a/screenshots/Network-page-node-count-9---dark-theme/swarm-9-node-dark-darwin.jpeg b/screenshots/Network-page-with-9-dark/swarm-9-node-dark-darwin.jpeg similarity index 100% rename from screenshots/Network-page-node-count-9---dark-theme/swarm-9-node-dark-darwin.jpeg rename to screenshots/Network-page-with-9-dark/swarm-9-node-dark-darwin.jpeg diff --git a/screenshots/Network-page-node-count-9---dark-theme/swarm-9-node-dark-linux.jpeg b/screenshots/Network-page-with-9-dark/swarm-9-node-dark-linux.jpeg similarity index 100% rename from screenshots/Network-page-node-count-9---dark-theme/swarm-9-node-dark-linux.jpeg rename to screenshots/Network-page-with-9-dark/swarm-9-node-dark-linux.jpeg diff --git a/terminalTui.ts b/terminalTui.ts new file mode 100644 index 0000000..665fb31 --- /dev/null +++ b/terminalTui.ts @@ -0,0 +1,902 @@ +import chalk from 'chalk'; +import { spawn } from 'child_process'; + +// ANSI escape sequences +const ESC = '\x1b'; +const ALT_SCREEN_ON = `${ESC}[?1049h`; +const ALT_SCREEN_OFF = `${ESC}[?1049l`; +const MOUSE_ON = `${ESC}[?1002h${ESC}[?1006h`; // Button-event tracking + SGR extended mouse +const MOUSE_OFF = `${ESC}[?1002l${ESC}[?1006l`; +const CURSOR_HIDE = `${ESC}[?25l`; +const CURSOR_SHOW = `${ESC}[?25h`; +const MOVE_TO = (row: number, col: number) => `${ESC}[${row};${col}H`; +const CLEAR_LINE = `${ESC}[2K`; +const RESET = `${ESC}[0m`; + +const MAX_OUTPUT_LINES = 5000; + +type TestStatus = + | 'failed' + | 'interrupted' + | 'passed' + | 'pending' + | 'retrying' + | 'running' + | 'skipped' + | 'timedOut'; + +interface TuiTestEntry { + duration: number | null; + errors: Array<{ message?: string; snippet?: string; stack?: string }>; + id: string; + output: string[]; + retry: number; + status: TestStatus; + title: string; +} + +interface TuiProgress { + completed: number; + estimatedMinsLeft: number; + total: number; +} + +type StopCallback = () => void; + +// --- Helpers --- + +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); +} + +function visibleLength(str: string): number { + return stripAnsi(str).length; +} + +function truncate(str: string, maxLen: number): string { + const stripped = stripAnsi(str); + if (stripped.length <= maxLen) return str; + return stripped.slice(0, maxLen - 1) + '\u2026'; +} + +function padRight(str: string, len: number): string { + const padding = Math.max(0, len - visibleLength(str)); + return str + ' '.repeat(padding); +} + +function formatDuration(ms: number): string { + const secs = Math.floor(ms / 1000); + if (secs < 60) return `${secs}s`; + const mins = Math.floor(secs / 60); + const remainSecs = secs % 60; + return `${mins}m${remainSecs.toString().padStart(2, '0')}s`; +} + +function statusLabel(status: TestStatus): string { + switch (status) { + case 'passed': + return chalk.green(' OK '); + case 'failed': + return chalk.red('FAIL'); + case 'timedOut': + return chalk.red('TIME'); + case 'running': + return chalk.yellow(' RUN'); + case 'retrying': + return chalk.magenta('RTRY'); + case 'skipped': + return chalk.blue('SKIP'); + case 'interrupted': + return chalk.yellow(' INT'); + case 'pending': + return chalk.dim(' -- '); + } +} + +function wrapLine(line: string, width: number): string[] { + if (width <= 0) return [line]; + const stripped = stripAnsi(line); + if (stripped.length <= width) return [line]; + + // Walk the original string preserving ANSI codes, splitting at visible width + // eslint-disable-next-line no-control-regex + const tokenRegex = /(\x1b\[[0-9;]*[a-zA-Z])|(.)/g; + const result: string[] = []; + let current = ''; + let activeAnsi = ''; // accumulated ANSI state to prepend on new lines + let visCount = 0; + let match; + + while ((match = tokenRegex.exec(line)) !== null) { + if (match[1]) { + // ANSI escape — append to current line and track for continuation + current += match[1]; + activeAnsi += match[1]; + } else { + // Visible character + if (visCount >= width) { + result.push(current + RESET); + current = activeAnsi; // re-apply active ANSI state on new line + visCount = 0; + } + current += match[2]; + visCount++; + } + } + + if (visCount > 0) { + result.push(current + RESET); + } + + return result; +} + +// --- Main class --- + +export class TerminalTui { + private tests: Map = new Map(); + private testOrder: string[] = []; + private selectedIndex = 0; + private outputScrollOffset = 0; + private activePaneFocus: 'list' | 'output' = 'list'; + private progress: TuiProgress = { + completed: 0, + estimatedMinsLeft: 0, + total: 0, + }; + private isActive = false; + private renderScheduled = false; + private originalStdinRawMode: boolean | undefined; + private keyHandler: ((data: Buffer) => void) | null = null; + private resizeHandler: (() => void) | null = null; + private exitHandler: (() => void) | null = null; + private flashMessage: string | null = null; + private flashTimeout: ReturnType | null = null; + private onStopCallback: StopCallback | null = null; + private lastListStart = 0; // saved from render() for mouse click mapping + private lastLeftWidth = 30; // saved from render() for mouse click mapping + private autoFollow = true; + private lastUserInteractionTime = 0; + private selection: { startRow: number; endRow: number } | null = null; + private lastOutputLines: string[] = []; // cached from last render for selection copy + + start(): void { + if (!process.stdout.isTTY) return; + + this.isActive = true; + this.originalStdinRawMode = process.stdin.isRaw; + + process.stdout.write(ALT_SCREEN_ON + CURSOR_HIDE + MOUSE_ON); + + if (process.stdin.isTTY) { + process.stdin.setRawMode(true); + } + process.stdin.resume(); + process.stdin.ref(); + + this.keyHandler = (data: Buffer) => { + this.handleKey(data); + }; + process.stdin.on('data', this.keyHandler); + + this.resizeHandler = () => { + this.scheduleRender(); + }; + process.stdout.on('resize', this.resizeHandler); + + this.exitHandler = () => { + this.restoreTerminal(); + }; + process.on('exit', this.exitHandler); + + this.scheduleRender(); + } + + stop(): void { + if (!this.isActive) return; + this.isActive = false; + + if (this.flashTimeout) { + clearTimeout(this.flashTimeout); + this.flashTimeout = null; + } + + this.removeListeners(); + this.restoreTerminal(); + } + + onStop(cb: StopCallback): void { + this.onStopCallback = cb; + } + + addTest(id: string, title: string): void { + this.tests.set(id, { + duration: null, + errors: [], + id, + output: [], + retry: 0, + status: 'pending', + title, + }); + this.testOrder.push(id); + this.scheduleRender(); + } + + updateTest( + id: string, + status: TestStatus, + duration?: number, + retry?: number, + ): void { + const entry = this.tests.get(id); + if (!entry) return; + entry.status = status; + if (duration !== undefined) entry.duration = duration; + if (retry !== undefined) entry.retry = retry; + + // Auto-follow: scroll to the test that just started running + if ( + this.autoFollow && + (status === 'running' || status === 'retrying') && + Date.now() - this.lastUserInteractionTime > 30_000 + ) { + const idx = this.testOrder.indexOf(id); + if (idx >= 0) { + this.selectedIndex = idx; + this.outputScrollOffset = Number.MAX_SAFE_INTEGER; // clamped in render + // Don't update lastUserInteractionTime — this is auto-follow, not user action + } + } + + this.scheduleRender(); + } + + appendOutput(id: string, text: string): void { + const entry = this.tests.get(id); + if (!entry) return; + + const lines = text.split(/\r?\n/); + if (lines.length > 0 && entry.output.length > 0 && !text.startsWith('\n')) { + entry.output[entry.output.length - 1] += lines.shift()!; + } + entry.output.push(...lines); + + // Cap output buffer + if (entry.output.length > MAX_OUTPUT_LINES) { + entry.output = entry.output.slice(-MAX_OUTPUT_LINES); + } + + if (this.testOrder[this.selectedIndex] === id) { + // Auto-scroll output to bottom for the selected test + this.outputScrollOffset = Number.MAX_SAFE_INTEGER; // clamped in render + this.scheduleRender(); + } + } + + clearOutput(id: string): void { + const entry = this.tests.get(id); + if (!entry) return; + entry.output = []; + entry.errors = []; + if (this.testOrder[this.selectedIndex] === id) { + this.outputScrollOffset = 0; + this.scheduleRender(); + } + } + + setError( + id: string, + errors: Array<{ message?: string; snippet?: string; stack?: string }>, + ): void { + const entry = this.tests.get(id); + if (!entry) return; + entry.errors = errors; + this.scheduleRender(); + } + + setProgress( + completed: number, + total: number, + estimatedMinsLeft: number, + ): void { + this.progress = { completed, estimatedMinsLeft, total }; + this.scheduleRender(); + } + + /** Re-sort the test list for summary view: passed → flaky → failed, each sorted by title */ + reorderForSummary(): void { + const statusPriority = (entry: TuiTestEntry): number => { + if (entry.status === 'passed' && entry.retry === 0) return 0; // passed first try + if (entry.status === 'passed') return 1; // flaky + if (entry.status === 'skipped') return 2; + return 3; // failed / timedOut / interrupted + }; + + this.testOrder.sort((a, b) => { + const ea = this.tests.get(a)!; + const eb = this.tests.get(b)!; + const pa = statusPriority(ea); + const pb = statusPriority(eb); + if (pa !== pb) return pa - pb; + return ea.title.localeCompare(eb.title); + }); + + this.selectedIndex = 0; + this.outputScrollOffset = 0; + this.autoFollow = false; + this.scheduleRender(); + } + + /** Returns a promise that resolves when the user closes the TUI (q / Ctrl+C) */ + waitForClose(): Promise { + return new Promise((resolve) => { + this.onStopCallback = () => { + resolve(); + }; + }); + } + + // --- Private --- + + /** Select a test by index (clamped), scroll output to bottom, mark as user interaction */ + private selectTest(index: number): void { + this.selectedIndex = Math.max( + 0, + Math.min(index, this.testOrder.length - 1), + ); + this.outputScrollOffset = Number.MAX_SAFE_INTEGER; // clamped in render + this.lastUserInteractionTime = Date.now(); + this.scheduleRender(); + } + + /** Adjust output scroll offset (clamped in render) */ + private scrollOutput(offset: number): void { + this.outputScrollOffset = Math.max(0, offset); + this.scheduleRender(); + } + + private scheduleRender(): void { + if (!this.isActive || this.renderScheduled) return; + this.renderScheduled = true; + process.nextTick(() => { + this.renderScheduled = false; + if (this.isActive) this.render(); + }); + } + + private render(): void { + const cols = process.stdout.columns || 80; + const rows = process.stdout.rows || 24; + + if (cols < 60 || rows < 10) { + const msg = 'Terminal too small (min 60x10)'; + const r = Math.floor(rows / 2); + const c = Math.max(1, Math.floor((cols - msg.length) / 2)); + process.stdout.write( + MOVE_TO(1, 1) + ESC + '[2J' + MOVE_TO(r, c) + chalk.yellow(msg), + ); + return; + } + + const leftWidth = Math.min(Math.max(30, Math.floor(cols * 0.4)), cols - 22); + const rightWidth = cols - leftWidth - 3; // 3 = left border + divider + right border + const contentHeight = rows - 3; // header + bottom divider + status bar + + let buf = MOVE_TO(1, 1); + + // --- Header --- + const leftHeader = ` Tests (${this.progress.completed}/${this.progress.total}) `; + const selectedTest = this.tests.get( + this.testOrder[this.selectedIndex] ?? '', + ); + const rightHeaderLabel = selectedTest + ? ` Output: ${truncate(selectedTest.title, rightWidth - 12)} ` + : ' Output '; + + const leftFill = Math.max(0, leftWidth - leftHeader.length - 1); + const rightFill = Math.max(0, rightWidth - rightHeaderLabel.length - 1); + + buf += CLEAR_LINE; + buf += + chalk.dim('\u250c') + + chalk.dim('\u2500') + + chalk.bold(leftHeader) + + chalk.dim('\u2500'.repeat(leftFill)); + buf += + chalk.dim('\u252c') + + chalk.dim('\u2500') + + chalk.bold(rightHeaderLabel) + + chalk.dim('\u2500'.repeat(rightFill)); + buf += chalk.dim('\u2510'); + + // --- Content rows --- + // Left pane: scrolling window around selectedIndex + const listLen = this.testOrder.length; + let listStart = 0; + if (listLen > contentHeight) { + listStart = Math.max( + 0, + Math.min( + this.selectedIndex - Math.floor(contentHeight / 2), + listLen - contentHeight, + ), + ); + } + this.lastListStart = listStart; + this.lastLeftWidth = leftWidth; + + // Right pane: build wrapped output lines + const outputLines = this.buildOutputLines(selectedTest, rightWidth - 2); + this.lastOutputLines = outputLines; + const maxScroll = Math.max(0, outputLines.length - contentHeight); + this.outputScrollOffset = Math.min(this.outputScrollOffset, maxScroll); + + // Selection range (normalized) + const selLo = this.selection + ? Math.min(this.selection.startRow, this.selection.endRow) + : -1; + const selHi = this.selection + ? Math.max(this.selection.startRow, this.selection.endRow) + : -1; + + for (let row = 0; row < contentHeight; row++) { + const screenRow = row + 2; + buf += MOVE_TO(screenRow, 1) + CLEAR_LINE; + + // Left cell + const testIdx = listStart + row; + let leftCell; + if (testIdx < listLen) { + const entry = this.tests.get(this.testOrder[testIdx])!; + const isSelected = testIdx === this.selectedIndex; + const label = statusLabel(entry.status); + const retryStr = + entry.retry > 0 ? chalk.dim(`r:${entry.retry}`) + ' ' : ''; + const durStr = + entry.duration !== null + ? chalk.dim(formatDuration(entry.duration)) + : chalk.dim('--'); + const maxTitleLen = + leftWidth - + 4 - + 5 - + (entry.retry > 0 ? 4 + String(entry.retry).length : 0) - + 5; + const title = truncate(entry.title, Math.max(5, maxTitleLen)); + + const line = ` ${label} ${retryStr}${title}`; + const lineWithDur = + padRight(line, leftWidth - visibleLength(durStr) - 2) + durStr + ' '; + + leftCell = isSelected + ? this.activePaneFocus === 'list' + ? chalk.inverse(padRight(lineWithDur, leftWidth)) + : chalk.bgGray(padRight(lineWithDur, leftWidth)) + : padRight(lineWithDur, leftWidth); + } else { + leftCell = ' '.repeat(leftWidth); + } + + buf += chalk.dim('\u2502') + leftCell; + + // Divider + buf += chalk.dim('\u2502'); + + // Right cell + const outIdx = this.outputScrollOffset + row; + let rightCell = ''; + if (outIdx < outputLines.length) { + rightCell = ' ' + truncate(outputLines[outIdx], rightWidth - 2) + RESET; + } + const isSelected = outIdx >= selLo && outIdx <= selHi; + buf += isSelected + ? chalk.inverse(padRight(rightCell, rightWidth)) + : padRight(rightCell, rightWidth); + } + + // --- Bottom divider --- + const bottomRow = contentHeight + 2; + buf += MOVE_TO(bottomRow, 1) + CLEAR_LINE; + buf += chalk.dim( + '\u2514' + + '\u2500'.repeat(leftWidth) + + '\u2534' + + '\u2500'.repeat(rightWidth) + + '\u2518', + ); + + // --- Status bar --- + const statusRow = bottomRow + 1; + buf += MOVE_TO(statusRow, 1) + CLEAR_LINE; + + const listHint = + this.activePaneFocus === 'list' + ? chalk.bold('\u2191\u2193 navigate') + : chalk.dim('\u2191\u2193 scroll'); + const tabHint = chalk.dim('Tab') + ' switch'; + const qHint = chalk.dim('q') + ' quit'; + const cHint = chalk.dim('c') + ' copy'; + const isFollowing = + this.autoFollow && Date.now() - this.lastUserInteractionTime > 30_000; + const fHint = isFollowing + ? chalk.green('f') + chalk.green(' follow') + : chalk.dim('f') + ' follow'; + const progressStr = chalk.dim( + `${this.progress.completed}/${this.progress.total} done`, + ); + const estStr = + this.progress.estimatedMinsLeft > 0 + ? chalk.dim(`, ~${this.progress.estimatedMinsLeft}min left`) + : ''; + const flash = this.flashMessage ? chalk.green(` ${this.flashMessage}`) : ''; + + buf += ` ${listHint} ${tabHint} ${qHint} ${cHint} ${fHint} ${chalk.dim( + '|', + )} ${progressStr}${estStr}${flash}`; + + process.stdout.write(buf); + } + + private buildOutputLines( + entry: TuiTestEntry | undefined, + width: number, + ): string[] { + if (!entry) return [chalk.dim(' No test selected')]; + + const lines: string[] = []; + + if (entry.output.length === 0 && entry.errors.length === 0) { + if (entry.status === 'pending') { + lines.push(chalk.dim('Waiting to start...')); + } else if (entry.status === 'running' || entry.status === 'retrying') { + lines.push(chalk.dim('Running... (no output yet)')); + } else { + lines.push(chalk.dim('No output')); + } + return lines; + } + + // stdout/stderr output + for (const line of entry.output) { + lines.push(...wrapLine(line, width)); + } + + // errors + if (entry.errors.length > 0) { + lines.push(''); + lines.push(chalk.red.bold('\u2500\u2500 Errors \u2500\u2500')); + for (const err of entry.errors) { + if (err.message) { + for (const msgLine of err.message.split('\n')) { + lines.push(...wrapLine(chalk.red(msgLine), width)); + } + } + if (err.snippet) { + lines.push(''); + for (const snipLine of err.snippet.split('\n')) { + lines.push(...wrapLine(snipLine, width)); + } + } + if (err.stack) { + lines.push(''); + for (const stackLine of err.stack.split('\n').slice(0, 10)) { + lines.push(...wrapLine(chalk.dim(stackLine), width)); + } + } + lines.push(''); + } + } + + return lines; + } + + private handleKey(data: Buffer): void { + const key = data.toString('utf-8'); + + // Ctrl+C or q to quit + if (data[0] === 0x03 || key === 'q' || key === 'Q') { + this.stop(); + this.onStopCallback?.(); + return; + } + + // SGR mouse: ESC [ < Cb ; Cx ; Cy M (press) or m (release) + // Use matchAll to handle batched events (fast drag can concatenate multiple events) + // eslint-disable-next-line no-control-regex + const sgrMatches = [...key.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)]; + if (sgrMatches.length > 0) { + for (const sgrMatch of sgrMatches) { + this.handleMouseEvent( + parseInt(sgrMatch[1], 10), + parseInt(sgrMatch[2], 10), + parseInt(sgrMatch[3], 10), + sgrMatch[4] === 'M', + ); + } + return; + } + + // Tab to switch panes + if (key === '\t') { + this.activePaneFocus = + this.activePaneFocus === 'list' ? 'output' : 'list'; + this.scheduleRender(); + return; + } + + // c to copy + if (key === 'c' || key === 'C') { + this.copySelectedOutput(); + return; + } + + // f to toggle auto-follow + if (key === 'f' || key === 'F') { + this.autoFollow = !this.autoFollow; + if (this.autoFollow) { + this.lastUserInteractionTime = 0; + } + this.showFlash(this.autoFollow ? 'Auto-follow ON' : 'Auto-follow OFF'); + return; + } + + // Escape sequences (arrows, page up/down, home/end) + if (data[0] === 0x1b && data[1] === 0x5b) { + const contentHeight = (process.stdout.rows || 24) - 3; + + // Map key codes to [listDelta, outputDelta] actions + // listDelta: new selectedIndex value or delta for selectTest + // outputDelta: adjustment for scrollOutput + type NavAction = { + listIndex: number; + outputOffset: number; + }; + + const navAction = ((): NavAction | null => { + switch (data[2]) { + case 0x41: // Up + return { + listIndex: this.selectedIndex - 1, + outputOffset: this.outputScrollOffset - 1, + }; + case 0x42: // Down + return { + listIndex: this.selectedIndex + 1, + outputOffset: this.outputScrollOffset + 1, + }; + case 0x48: // Home + return { listIndex: 0, outputOffset: 0 }; + case 0x46: // End + return { + listIndex: this.testOrder.length - 1, + outputOffset: Number.MAX_SAFE_INTEGER, + }; + } + // Page Up / Page Down (ESC [ 5~ / ESC [ 6~) + if (data[3] === 0x7e) { + if (data[2] === 0x35) + return { + listIndex: this.selectedIndex - contentHeight, + outputOffset: this.outputScrollOffset - contentHeight, + }; + if (data[2] === 0x36) + return { + listIndex: this.selectedIndex + contentHeight, + outputOffset: this.outputScrollOffset + contentHeight, + }; + } + return null; + })(); + + if (navAction) { + if (this.activePaneFocus === 'list') { + this.selectTest(navAction.listIndex); + } else { + this.scrollOutput(navAction.outputOffset); + } + return; + } + } + } + + private handleMouseEvent( + button: number, + col: number, + row: number, + isPress: boolean, + ): void { + const isMotion = button >= 32; // motion events have +32 on button code + const realButton = isMotion ? button - 32 : button; + + const rightPaneStart = this.lastLeftWidth + 3; // left border + left width + divider + const isInRightPanel = col >= rightPaneStart; + const isInContentArea = row >= 2; + const contentRow = row - 2; + const outputLineIdx = this.outputScrollOffset + contentRow; + + // Scroll wheel: 64 = scroll up, 65 = scroll down + if (button === 64 || button === 65) { + const scrollDelta = button === 64 ? -3 : 3; + if (isInRightPanel || this.activePaneFocus === 'output') { + this.scrollOutput(this.outputScrollOffset + scrollDelta); + } else { + this.selectTest(this.selectedIndex + scrollDelta); + } + return; + } + + if (realButton !== 0) return; + + if (isPress && !isMotion) { + // Left-click press + if (isInContentArea && isInRightPanel) { + this.selection = { startRow: outputLineIdx, endRow: outputLineIdx }; + this.scheduleRender(); + } else if (row === 1 && isInRightPanel) { + // Click on header right panel — copy test title + const entry = this.tests.get(this.testOrder[this.selectedIndex] ?? ''); + if (entry) { + this.copyToClipboard(entry.title); + } + } else if (isInContentArea && !isInRightPanel) { + // Click in left panel — select test + const testIdx = this.lastListStart + contentRow; + if (testIdx >= 0 && testIdx < this.testOrder.length) { + this.selection = null; + this.activePaneFocus = 'list'; + this.selectTest(testIdx); + } + } + } else if (isMotion && this.selection) { + // Drag — extend selection + if (isInContentArea) { + this.selection.endRow = outputLineIdx; + this.scheduleRender(); + } + } + + if (!isPress && !isMotion && this.selection) { + // Release — copy selected lines + const { startRow, endRow } = this.selection; + const lo = Math.max(0, Math.min(startRow, endRow)); + const hi = Math.min( + this.lastOutputLines.length - 1, + Math.max(startRow, endRow), + ); + if (lo <= hi) { + const selectedText = this.lastOutputLines + .slice(lo, hi + 1) + .map(stripAnsi) + .join('\n'); + this.copyToClipboard(selectedText); + } + this.selection = null; + this.scheduleRender(); + } + } + + private copySelectedOutput(): void { + const entry = this.tests.get(this.testOrder[this.selectedIndex] ?? ''); + if (!entry) return; + + let text = `Test: ${entry.title}\nStatus: ${entry.status}`; + if (entry.duration !== null) { + text += ` (${formatDuration(entry.duration)})`; + } + if (entry.retry > 0) { + text += ` retry #${entry.retry}`; + } + text += '\n\n'; + + if (entry.output.length > 0) { + text += entry.output.map(stripAnsi).join('\n') + '\n'; + } + + for (const err of entry.errors) { + text += '\n--- Error ---\n'; + if (err.message) text += err.message + '\n'; + if (err.snippet) text += err.snippet + '\n'; + if (err.stack) text += err.stack + '\n'; + } + + this.copyToClipboard(text); + } + + private copyToClipboard(text: string): void { + const candidates: Array<{ cmd: string; args: string[] }> = []; + if (process.platform === 'darwin') { + candidates.push({ cmd: 'pbcopy', args: [] }); + } else if (process.platform === 'win32') { + candidates.push({ cmd: 'clip', args: [] }); + } else { + if (process.env.WAYLAND_DISPLAY) { + candidates.push({ cmd: 'wl-copy', args: [] }); + } + candidates.push({ cmd: 'xclip', args: ['-selection', 'clipboard'] }); + candidates.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }); + } + this.tryCopyWithCandidates(text, candidates, 0); + } + + private tryCopyWithCandidates( + text: string, + candidates: Array<{ cmd: string; args: string[] }>, + index: number, + ): void { + if (index >= candidates.length) { + // All clipboard tools failed — try OSC 52 escape sequence as last resort + this.copyViaOsc52(text); + return; + } + + const { cmd, args } = candidates[index]; + try { + const proc = spawn(cmd, args, { stdio: ['pipe', 'ignore', 'ignore'] }); + proc.stdin.write(text); + proc.stdin.end(); + proc.on('error', () => { + this.tryCopyWithCandidates(text, candidates, index + 1); + }); + proc.on('close', (code) => { + if (code === 0) { + this.showFlash('Copied!'); + } else { + this.tryCopyWithCandidates(text, candidates, index + 1); + } + }); + } catch { + this.tryCopyWithCandidates(text, candidates, index + 1); + } + } + + private copyViaOsc52(text: string): void { + try { + const encoded = Buffer.from(text).toString('base64'); + process.stdout.write(`\x1b]52;c;${encoded}\x07`); + this.showFlash('Copied (OSC 52)'); + } catch { + this.showFlash('Copy failed'); + } + } + + private showFlash(msg: string): void { + this.flashMessage = msg; + this.scheduleRender(); + if (this.flashTimeout) clearTimeout(this.flashTimeout); + this.flashTimeout = setTimeout(() => { + this.flashMessage = null; + this.flashTimeout = null; + this.scheduleRender(); + }, 1500); + } + + private removeListeners(): void { + if (this.keyHandler) { + process.stdin.removeListener('data', this.keyHandler); + this.keyHandler = null; + } + if (this.resizeHandler) { + process.stdout.removeListener('resize', this.resizeHandler); + this.resizeHandler = null; + } + if (this.exitHandler) { + process.removeListener('exit', this.exitHandler); + this.exitHandler = null; + } + } + + private restoreTerminal(): void { + try { + if (process.stdin.isTTY) { + process.stdin.setRawMode(this.originalStdinRawMode ?? false); + } + process.stdin.pause(); + process.stdin.unref(); + process.stdout.write(MOUSE_OFF + ALT_SCREEN_OFF + CURSOR_SHOW); + } catch { + // Terminal may already be gone + } + } +} diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index 2dc1edb..ffb1692 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -21,8 +21,8 @@ import { clickOnWithText, hasElementBeenDeleted, hasElementPoppedUpThatShouldnt, + pasteIntoInput, scrollToBottomIfNecessary, - typeIntoInput, waitForTestIdWithText, } from './utilities/utils'; @@ -33,7 +33,7 @@ test_Alice_2W( 'Join community and sync', async ({ aliceWindow1, aliceWindow2 }) => { await joinCommunity(aliceWindow1); - await clickOn(aliceWindow1, Conversation.scrollToBottomButton); + await scrollToBottomIfNecessary(aliceWindow1); await sendMessage(aliceWindow1, 'Hello, community!'); // Check linked device for community await clickOnWithText( @@ -57,7 +57,7 @@ test_Alice_1W_Bob_1W( // ]); await Promise.all( [aliceWindow1, bobWindow1].map((window) => - clickOn(window, Conversation.scrollToBottomButton), + scrollToBottomIfNecessary(window), ), ); await sendMedia(aliceWindow1, mediaPath, testImageMessage, true); @@ -93,7 +93,7 @@ sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { strictMode: false, }); await clickOn(windowA, Conversation.banUserButton); - await typeIntoInput(windowB, Conversation.messageInput.selector, msg2); + await pasteIntoInput(windowB, Conversation.messageInput.selector, msg2); await clickOn(windowB, Conversation.sendMessageButton); await waitForMessageStatus(windowB, msg2, 'failed'); await clickOnWithText(windowA, Conversation.messageContent, msg1, { @@ -139,7 +139,7 @@ sessionTestTwoWindows('Ban And delete all', async ([windowA, windowB]) => { 10_000, msg1, ); - await typeIntoInput(windowB, Conversation.messageInput.selector, msg2); + await pasteIntoInput(windowB, Conversation.messageInput.selector, msg2); await clickOn(windowB, Conversation.sendMessageButton); await waitForMessageStatus(windowB, msg2, 'failed'); await hasElementPoppedUpThatShouldnt( diff --git a/tests/automation/cta_donations.spec.ts b/tests/automation/cta_donations.spec.ts index 3db3df6..4a2dcf9 100644 --- a/tests/automation/cta_donations.spec.ts +++ b/tests/automation/cta_donations.spec.ts @@ -50,7 +50,7 @@ const urlModalButtons = [ urlModalButtons.forEach(({ button, name }) => { test_Alice_1W( - `Donate CTA, never shows after clicking ${name} in URL modal`, + `Donate CTA, never shows after ${name}`, async ({ aliceWindow1 }) => { const url = 'https://getsession.org/donate'; diff --git a/tests/automation/delete_account.spec.ts b/tests/automation/delete_account.spec.ts index 630ecdb..ce732ad 100644 --- a/tests/automation/delete_account.spec.ts +++ b/tests/automation/delete_account.spec.ts @@ -15,7 +15,7 @@ import { clickOnMatchingText, clickOnWithText, hasElementBeenDeleted, - typeIntoInput, + pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, } from './utilities/utils'; @@ -67,7 +67,7 @@ sessionTestTwoWindows( // Sign in with deleted account and check that nothing restores await clickOn(restoringWindow, Onboarding.iHaveAnAccountButton); // Fill in recovery phrase - await typeIntoInput( + await pasteIntoInput( restoringWindow, Onboarding.recoveryPhraseInput.selector, userA.recoveryPassword, @@ -79,7 +79,7 @@ sessionTestTwoWindows( 'loading-animation', ); - await typeIntoInput( + await pasteIntoInput( restoringWindow, Onboarding.displayNameInput.selector, userA.userName, @@ -94,6 +94,7 @@ sessionTestTwoWindows( restoringWindow, 'data-testid', HomeScreen.conversationItemName.selector, + 5_000, ); await clickOn(restoringWindow, HomeScreen.plusButton); // Expect contacts list to be empty diff --git a/tests/automation/disappearing_message_checks.spec.ts b/tests/automation/disappearing_message_checks.spec.ts index ee33f76..e8d0e27 100644 --- a/tests/automation/disappearing_message_checks.spec.ts +++ b/tests/automation/disappearing_message_checks.spec.ts @@ -30,7 +30,7 @@ import { formatTimeOption, hasElementBeenDeleted, hasTextMessageBeenDeleted, - typeIntoInput, + pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, waitForTestIdWithText, @@ -97,6 +97,7 @@ mediaArray.forEach( bobWindow1, 'data-testid', 'audio-player', + 1_000, ); } else { await waitForTextMessage(bobWindow1, testMessage); @@ -139,7 +140,7 @@ test_Alice_1W_Bob_1W( }), ), ]); - await typeIntoInput(aliceWindow1, 'message-input-text-area', longText); + await pasteIntoInput(aliceWindow1, 'message-input-text-area', longText); await sleepFor(100); await clickOn(aliceWindow1, Conversation.sendMessageButton); await waitForMessageStatus(aliceWindow1, longText, 'sent'); @@ -183,18 +184,18 @@ test_Alice_1W_Bob_1W( await sendLinkPreview(aliceWindow1, testLink); await waitForElement( bobWindow1, - 'class', - 'module-message__link-preview__title', - undefined, + 'data-testid', + 'msg-link-preview-title', + 3_000, 'Session | Send Messages, Not Metadata. | Private Messenger', ); // Wait 30 seconds for link preview to disappear - await sleepFor(30000); + await sleepFor(30_000); await hasElementBeenDeleted( bobWindow1, - 'class', - 'module-message__link-preview__title', - undefined, + 'data-testid', + 'msg-link-preview-title', + 1_000, // no need to wait too long here, it should have disappeared already 'Session | Send Messages, Not Metadata. | Private Messenger', ); }, @@ -271,22 +272,17 @@ test_Alice_1W_Bob_1W( ]); // Wait 30 seconds for community invite to disappear await sleepFor(30000); - await Promise.all([ - hasElementBeenDeleted( - bobWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, - ), - hasElementBeenDeleted( - aliceWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, + await Promise.all( + [bobWindow1, aliceWindow1].map((w) => + hasElementBeenDeleted( + w, + 'class', + 'group-name', + 1_000, + testCommunityName, + ), ), - ]); + ); }, ); @@ -328,7 +324,7 @@ test_Alice_1W_Bob_1W( 'call-notification-answered-a-call', tStripped('callsInProgress'), ), - // In the callers window, the message is 'You called {reciverName}' + // In the callers window, the message is 'You called {receiverName}' waitForTestIdWithText( aliceWindow1, 'call-notification-started-call', @@ -337,19 +333,20 @@ test_Alice_1W_Bob_1W( ]); // Wait 30 seconds for call message to disappear await sleepFor(30000); + await Promise.all([ hasElementBeenDeleted( bobWindow1, 'data-testid', 'call-notification-answered-a-call', - undefined, + 1_000, tStripped('callsInProgress'), ), hasElementBeenDeleted( aliceWindow1, 'data-testid', 'call-notification-started-call', - undefined, + 1_000, tStripped('callsYouCalled', { name: bob.userName }), ), ]); diff --git a/tests/automation/disappearing_messages.spec.ts b/tests/automation/disappearing_messages.spec.ts index 9d5abff..fad880d 100644 --- a/tests/automation/disappearing_messages.spec.ts +++ b/tests/automation/disappearing_messages.spec.ts @@ -20,7 +20,7 @@ import { formatTimeOption, hasElementBeenDeleted, hasTextMessageBeenDeleted, - typeIntoInput, + pasteIntoInput, waitForTestIdWithText, waitForTextMessage, } from './utilities/utils'; @@ -72,7 +72,7 @@ test_Alice_2W_Bob_1W( // Check window B (need to refocus window) console.log(`Bring window B to front`); const message = 'Forcing window to front'; - await typeIntoInput(bobWindow1, 'message-input-text-area', message); + await pasteIntoInput(bobWindow1, 'message-input-text-area', message); // click up arrow (send) await clickOn(bobWindow1, Conversation.sendMessageButton); await sleepFor(10000); @@ -338,22 +338,15 @@ test_Alice_2W_Bob_1W( tStripped('disappearingMessagesTurnedOffYou'), ), ]); - await Promise.all([ - hasElementBeenDeleted( - aliceWindow1, - 'data-testid', - 'disappear-messages-type-and-time', - ), - hasElementBeenDeleted( - aliceWindow2, - 'data-testid', - 'disappear-messages-type-and-time', + await Promise.all( + [aliceWindow1, aliceWindow2, bobWindow1].map((w) => + hasElementBeenDeleted( + w, + 'data-testid', + 'disappear-messages-type-and-time', + 1_000, + ), ), - hasElementBeenDeleted( - bobWindow1, - 'data-testid', - 'disappear-messages-type-and-time', - ), - ]); + ); }, ); diff --git a/tests/automation/enforce_localized_str.spec.ts b/tests/automation/enforce_localized_str.spec.ts index 187d98e..523a3df 100644 --- a/tests/automation/enforce_localized_str.spec.ts +++ b/tests/automation/enforce_localized_str.spec.ts @@ -322,7 +322,7 @@ function getExpectedStringFromKey( } } -test('Enforce localized strings return expected values', () => { +test('Enforce localized strings', () => { // Example usage const tsFiles = readTsFiles('.'); diff --git a/tests/automation/group_disappearing_messages.spec.ts b/tests/automation/group_disappearing_messages.spec.ts index 483e617..20e5fbc 100644 --- a/tests/automation/group_disappearing_messages.spec.ts +++ b/tests/automation/group_disappearing_messages.spec.ts @@ -62,10 +62,11 @@ mediaArray.forEach(({ mediaType, path, shouldCheckMediaPreview }) => { waitForTestIdWithText(charlieWindow1, 'audio-player'), ]); await sleepFor(10000); - await Promise.all([ - hasElementBeenDeleted(bobWindow1, 'data-testid', 'audio-player'), - hasElementBeenDeleted(charlieWindow1, 'data-testid', 'audio-player'), - ]); + await Promise.all( + [bobWindow1, charlieWindow1].map((w) => + hasElementBeenDeleted(w, 'data-testid', 'audio-player', 1_000), + ), + ); } else { await Promise.all([ waitForLoadingAnimationToFinish(bobWindow1, 'loading-animation'), @@ -119,35 +120,30 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await Promise.all([ waitForElement( bobWindow1, - 'class', - 'module-message__link-preview__title', + 'data-testid', + 'msg-link-preview-title', undefined, 'Session | Send Messages, Not Metadata. | Private Messenger', ), waitForElement( charlieWindow1, - 'class', - 'module-message__link-preview__title', + 'data-testid', + 'msg-link-preview-title', undefined, 'Session | Send Messages, Not Metadata. | Private Messenger', ), ]); await sleepFor(30000); - await Promise.all([ - hasElementBeenDeleted( - bobWindow1, - 'class', - 'module-message__link-preview__title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ), - hasElementBeenDeleted( - charlieWindow1, - 'class', - 'module-message__link-preview__title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', + await Promise.all( + [bobWindow1, charlieWindow1].map((w) => + hasElementBeenDeleted( + w, + 'data-testid', + 'msg-link-preview-title', + 1_000, + 'Session | Send Messages, Not Metadata. | Private Messenger', + ), ), - ]); + ); }, ); diff --git a/tests/automation/group_testing.spec.ts b/tests/automation/group_testing.spec.ts index 9a21278..7de72a8 100644 --- a/tests/automation/group_testing.spec.ts +++ b/tests/automation/group_testing.spec.ts @@ -22,7 +22,7 @@ import { clickOnMatchingText, clickOnWithText, grabTextFromElement, - typeIntoInput, + pasteIntoInput, waitForMatchingText, waitForTestIdWithText, waitForTextMessage, @@ -176,7 +176,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( // All users type @ to open mentions await Promise.all( members.map((m) => - typeIntoInput(m.window, 'message-input-text-area', '@'), + pasteIntoInput(m.window, 'message-input-text-area', '@'), ), ); diff --git a/tests/automation/input_validations.spec.ts b/tests/automation/input_validations.spec.ts index 4eae49e..90676fb 100644 --- a/tests/automation/input_validations.spec.ts +++ b/tests/automation/input_validations.spec.ts @@ -4,7 +4,7 @@ import { sessionTestOneWindow } from './setup/sessionTest'; import { clickOn, grabTextFromElement, - typeIntoInput, + pasteIntoInput, waitForTestIdWithText, } from './utilities/utils'; @@ -31,7 +31,7 @@ import { ].forEach(({ testName, incorrectSeed, expectedError }) => { sessionTestOneWindow(`Seed validation: "${testName}"`, async ([window]) => { await clickOn(window, Onboarding.iHaveAnAccountButton); - await typeIntoInput(window, 'recovery-phrase-input', incorrectSeed); + await pasteIntoInput(window, 'recovery-phrase-input', incorrectSeed); await clickOn(window, Global.continueButton); await waitForTestIdWithText(window, 'error-message'); const actualError = await grabTextFromElement( @@ -65,7 +65,7 @@ import { `Display name validation: "${testName}"`, async ([window]) => { await clickOn(window, Onboarding.createAccountButton); - await typeIntoInput(window, 'display-name-input', displayName); + await pasteIntoInput(window, 'display-name-input', displayName); await clickOn(window, Global.continueButton); await waitForTestIdWithText(window, Global.errorMessage.selector); const actualError = await grabTextFromElement( diff --git a/tests/automation/linked_device_requests.spec.ts b/tests/automation/linked_device_requests.spec.ts index 43e6418..f238a87 100644 --- a/tests/automation/linked_device_requests.spec.ts +++ b/tests/automation/linked_device_requests.spec.ts @@ -138,15 +138,26 @@ test_Alice_2W_Bob_1W( ); // Check that the blocked contacts is on alicewindow2 // Check blocked status in blocked contacts list - await sleepFor(5000); await clickOn(aliceWindow2, LeftPane.settingsButton); await clickOn(aliceWindow2, Settings.conversationsMenuItem); - await clickOn(aliceWindow2, Settings.blockedContactsButton); - await waitForTestIdWithText( - aliceWindow2, - Global.contactItem.selector, - bob.userName, - ); - await waitForMatchingText(aliceWindow2, bob.userName); + // the blocked conversation list UI does not refresh automatically + // so we need to refresh it manually + const maxAttempts = 10; + for (let i = 0; i < maxAttempts; i++) { + try { + await clickOn(aliceWindow2, Settings.blockedContactsButton); + await waitForMatchingText(aliceWindow2, bob.userName, 1_000); + break; + } catch (e) { + console.info( + `failed to find blocked contact "${bob.userName}", attempt: ${i}`, + ); + if (i === maxAttempts - 1) { + throw e; + } + await sleepFor(1000); + await clickOn(aliceWindow2, Global.modalBackButton); + } + } }, ); diff --git a/tests/automation/linked_device_user.spec.ts b/tests/automation/linked_device_user.spec.ts index de81d27..f1aa10d 100644 --- a/tests/automation/linked_device_user.spec.ts +++ b/tests/automation/linked_device_user.spec.ts @@ -30,7 +30,7 @@ import { doWhileWithMax, hasElementBeenDeleted, hasTextMessageBeenDeleted, - typeIntoInput, + pasteIntoInput, waitForLoadingAnimationToFinish, waitForMatchingPlaceholder, waitForMatchingText, @@ -91,7 +91,7 @@ test_Alice_2W( // Click on pencil icon await clickOn(aliceWindow1, Settings.displayName); // Replace old username with new username - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.displayNameInput.selector, newUsername, @@ -391,7 +391,7 @@ test_Alice_2W( async ({ alice, aliceWindow1, aliceWindow2 }) => { await clickOn(aliceWindow1, HomeScreen.plusButton); await clickOn(aliceWindow1, HomeScreen.newMessageOption); - await typeIntoInput( + await pasteIntoInput( aliceWindow1, HomeScreen.newMessageAccountIDInput.selector, alice.accountid, diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index 1df7595..8cde5d8 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -100,6 +100,7 @@ export class Conversation extends Locator { static readonly disappearingControlMessage = this.testId( 'disappear-control-message', ); + static readonly quoteText = this.testId('quote-text'); static readonly endCallButton = this.testId('end-call'); static readonly endVoiceMessageButton = this.testId('end-voice-message'); static readonly mentionsContainer = this.testId('mentions-container'); // This is also the locator for emojis diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index 88f330c..fa17517 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -36,7 +36,7 @@ import { hasElementPoppedUpThatShouldnt, hasTextMessageBeenDeleted, measureSendingTime, - typeIntoInput, + pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, waitForMatchingText, @@ -95,7 +95,7 @@ test_Alice_1W_Bob_1W( async ({ alice, aliceWindow1, bob, bobWindow1 }) => { const testReply = `${bob.userName} replying to long text message from ${alice.userName}`; await createContact(aliceWindow1, bobWindow1, alice, bob); - await typeIntoInput(aliceWindow1, 'message-input-text-area', longText); + await pasteIntoInput(aliceWindow1, 'message-input-text-area', longText); await sleepFor(100); await clickOnElement({ window: aliceWindow1, @@ -122,8 +122,8 @@ test_Alice_1W_Bob_1W( await sendLinkPreview(aliceWindow1, testLink); await waitForElement( bobWindow1, - 'class', - 'module-message__link-preview__title', + 'data-testid', + 'msg-link-preview-title', undefined, 'Session | Send Messages, Not Metadata. | Private Messenger', ); @@ -296,12 +296,7 @@ messageLengthTestCases.forEach((testCase) => { : (maxChars - testCase.length).toString(); const message = testCase.char.repeat(testCase.length); // Type the message - await typeIntoInput( - aliceWindow1, - 'message-input-text-area', - message, - true, // Paste because otherwise Playwright times out - ); + await pasteIntoInput(aliceWindow1, 'message-input-text-area', message); // Check countdown behavior if (expectedCount) { @@ -380,18 +375,21 @@ messageLengthTestCases.forEach((testCase) => { }); test_Alice_1W( - 'Emoji container does not show for links', + 'Emoji does not show for links', async ({ aliceWindow1, alice }) => { await clickOn(aliceWindow1, HomeScreen.plusButton); await clickOn(aliceWindow1, HomeScreen.newMessageOption); - await typeIntoInput( + await pasteIntoInput( aliceWindow1, HomeScreen.newMessageAccountIDInput.selector, alice.accountid, - true, ); await clickOn(aliceWindow1, HomeScreen.newMessageNextButton); - await typeIntoInput(aliceWindow1, Conversation.messageInput.selector, ':a'); + await pasteIntoInput( + aliceWindow1, + Conversation.messageInput.selector, + ':a', + ); await waitForTestIdWithText( aliceWindow1, Conversation.mentionsContainer.selector, @@ -401,7 +399,7 @@ test_Alice_1W( Conversation.mentionsItem.selector, ':a:', ); - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Conversation.messageInput.selector, 'https:/', @@ -411,7 +409,7 @@ test_Alice_1W( Conversation.mentionsContainer.strategy, Conversation.mentionsContainer.selector, ); - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Conversation.messageInput.selector, 'check this out https:/', @@ -425,18 +423,17 @@ test_Alice_1W( ); test_Alice_1W( - 'Emoji container closes when clicking away', + 'Emoji closes when clicking away', async ({ aliceWindow1, alice }) => { await clickOn(aliceWindow1, HomeScreen.plusButton); await clickOn(aliceWindow1, HomeScreen.newMessageOption); - await typeIntoInput( + await pasteIntoInput( aliceWindow1, HomeScreen.newMessageAccountIDInput.selector, alice.accountid, - true, ); await clickOn(aliceWindow1, HomeScreen.newMessageNextButton); - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Conversation.messageInput.selector, 'hey check this out :a', diff --git a/tests/automation/message_checks_groups.spec.ts b/tests/automation/message_checks_groups.spec.ts index bf07a93..be28226 100644 --- a/tests/automation/message_checks_groups.spec.ts +++ b/tests/automation/message_checks_groups.spec.ts @@ -14,7 +14,7 @@ import { clickOnMatchingText, clickOnTextMessage, hasTextMessageBeenDeleted, - typeIntoInput, + pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, waitForMatchingText, @@ -100,7 +100,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated, }) => { const testReply = `${bob.userName} replying to long text message from ${alice.userName} in ${groupCreated.userName}`; - await typeIntoInput(aliceWindow1, 'message-input-text-area', longText); + await pasteIntoInput(aliceWindow1, 'message-input-text-area', longText); await sleepFor(100); await clickOnElement({ window: aliceWindow1, @@ -133,15 +133,15 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await Promise.all([ waitForElement( bobWindow1, - 'class', - 'module-message__link-preview__title', + 'data-testid', + 'msg-link-preview-title', undefined, 'Session | Send Messages, Not Metadata. | Private Messenger', ), waitForElement( charlieWindow1, - 'class', - 'module-message__link-preview__title', + 'data-testid', + 'msg-link-preview-title', undefined, 'Session | Send Messages, Not Metadata. | Private Messenger', ), diff --git a/tests/automation/message_requests.spec.ts b/tests/automation/message_requests.spec.ts index f180168..f68f1e9 100644 --- a/tests/automation/message_requests.spec.ts +++ b/tests/automation/message_requests.spec.ts @@ -18,6 +18,7 @@ import { clickOnMatchingText, clickOnWithText, grabTextFromElement, + scrollToBottomIfNecessary, waitForMatchingText, waitForTestIdWithText, } from './utilities/utils'; @@ -168,7 +169,7 @@ test_Alice_1W_Bob_1W( await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); const communityMsg = `I accept message requests + ${Date.now()}`; await sendMessage(bobWindow1, communityMsg); - await clickOn(aliceWindow1, Conversation.scrollToBottomButton); + await scrollToBottomIfNecessary(aliceWindow1); // Using native methods to locate the author corresponding to the sent message await aliceWindow1 .locator('.module-message__container', { hasText: communityMsg }) @@ -214,7 +215,7 @@ test_Alice_1W_Bob_1W( await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); const communityMsg = `I do not accept message requests + ${Date.now()}`; await sendMessage(bobWindow1, communityMsg); - await clickOn(aliceWindow1, Conversation.scrollToBottomButton); + await scrollToBottomIfNecessary(aliceWindow1); // Using native methods to locate the author corresponding to the sent message await aliceWindow1 .locator('.module-message__container', { hasText: communityMsg }) diff --git a/tests/automation/network_page.spec.ts b/tests/automation/network_page.spec.ts index 283353c..c1e1e2a 100644 --- a/tests/automation/network_page.spec.ts +++ b/tests/automation/network_page.spec.ts @@ -111,7 +111,7 @@ test_Alice_1W('Network page refresh', async ({ aliceWindow1 }) => { // Cycle through all valid node counts and check count + graph for (let nodeCount = 1; nodeCount <= 10; nodeCount++) { test_Alice_1W( - `Network page node count: ${nodeCount} - dark theme`, + `Network page with ${nodeCount}/dark`, async ({ aliceWindow1 }, testInfo) => { await clickOn(aliceWindow1, LeftPane.settingsButton); await clickOn(aliceWindow1, Settings.networkPageMenuItem); @@ -147,7 +147,7 @@ for (let nodeCount = 1; nodeCount <= 10; nodeCount++) { // Single check to verify light mode svg also renders correctly const LIGHT_THEME_TEST_NODE_COUNT = 7; test_Alice_1W( - `Network page node count: ${LIGHT_THEME_TEST_NODE_COUNT} - light theme`, + `Network page with ${LIGHT_THEME_TEST_NODE_COUNT}/light`, async ({ aliceWindow1 }, testInfo) => { await clickOn(aliceWindow1, LeftPane.settingsButton); await clickOn(aliceWindow1, Settings.appearanceMenuItem); diff --git a/tests/automation/onboarding.spec.ts b/tests/automation/onboarding.spec.ts index bfdba95..b840ae6 100644 --- a/tests/automation/onboarding.spec.ts +++ b/tests/automation/onboarding.spec.ts @@ -5,7 +5,7 @@ import { checkModalStrings, clickOn, clickOnWithText, - typeIntoInput, + pasteIntoInput, } from './utilities/utils'; sessionTestOneWindow('Warning modal new account', async ([aliceWindow1]) => { @@ -36,7 +36,7 @@ sessionTestOneWindow( const seedPhrase = 'eldest fazed hybrid buzzer nasty domestic digit pager unusual purged makeup assorted domestic'; await clickOn(aliceWindow1, Onboarding.iHaveAnAccountButton); - await typeIntoInput(aliceWindow1, 'recovery-phrase-input', seedPhrase); + await pasteIntoInput(aliceWindow1, 'recovery-phrase-input', seedPhrase); await clickOn(aliceWindow1, Global.continueButton); await clickOn(aliceWindow1, Global.backButton); await checkModalStrings( diff --git a/tests/automation/password.spec.ts b/tests/automation/password.spec.ts index cc1990b..9849d71 100644 --- a/tests/automation/password.spec.ts +++ b/tests/automation/password.spec.ts @@ -8,7 +8,7 @@ import { clickOn, clickOnMatchingText, hasElementPoppedUpThatShouldnt, - typeIntoInput, + pasteIntoInput, waitForTestIdWithText, } from './utilities/utils'; @@ -35,13 +35,13 @@ test_Alice_1W_no_network('Set Password', async ({ alice, aliceWindow1 }) => { // Click set password await clickOn(aliceWindow1, Settings.setPasswordSettingsButton); // Enter password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.passwordInput.selector, testPassword, ); // Confirm password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.confirmPasswordInput.selector, testPassword, @@ -61,7 +61,7 @@ test_Alice_1W_no_network('Set Password', async ({ alice, aliceWindow1 }) => { await sleepFor(300, true); // Type password into input field and validate it - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.passwordInput.selector, testPassword, @@ -78,19 +78,19 @@ test_Alice_1W_no_network('Set Password', async ({ alice, aliceWindow1 }) => { await clickOn(aliceWindow1, Settings.changePasswordSettingsButton); // Enter old password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.passwordInput.selector, testPassword, ); // Enter new password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.confirmPasswordInput.selector, newTestPassword, ); // Confirm new password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.reConfirmPasswordInput.selector, newTestPassword, @@ -116,13 +116,13 @@ test_Alice_1W_no_network( // Click set password await clickOn(aliceWindow1, Settings.setPasswordSettingsButton); // Enter password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.passwordInput.selector, testPassword, ); // Confirm password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.confirmPasswordInput.selector, testPassword, @@ -133,7 +133,7 @@ test_Alice_1W_no_network( await clickOn(aliceWindow1, Global.modalBackButton); await clickOn(aliceWindow1, Settings.recoveryPasswordMenuItem); // Type password into input field - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.passwordInput.selector, testPassword, @@ -148,7 +148,7 @@ test_Alice_1W_no_network( // Click on recovery phrase tab await clickOn(aliceWindow1, Settings.recoveryPasswordMenuItem); // Try with incorrect password - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.passwordInput.selector, newTestPassword, diff --git a/tests/automation/recovery_phrase_banner.spec.ts b/tests/automation/recovery_phrase_banner.spec.ts index 2bb1ad8..4f9e267 100644 --- a/tests/automation/recovery_phrase_banner.spec.ts +++ b/tests/automation/recovery_phrase_banner.spec.ts @@ -33,33 +33,27 @@ async function bannerShouldAppear(window: Page) { console.log('On home screen, banner is visible'); } -test_Alice_1W( - 'Recovery password banner appears after >2 conversations', - async ({ aliceWindow1 }) => { - await bannerShouldNotAppear(aliceWindow1); - await joinDefaultCommunity(aliceWindow1, 'Lokinet Updates'); - await bannerShouldNotAppear(aliceWindow1); - await joinDefaultCommunity(aliceWindow1, 'Session Network Updates'); - await bannerShouldNotAppear(aliceWindow1); - await joinDefaultCommunity(aliceWindow1, 'Session Updates'); - await bannerShouldAppear(aliceWindow1); - }, -); +test_Alice_1W('Recovery banner shows with >2', async ({ aliceWindow1 }) => { + await bannerShouldNotAppear(aliceWindow1); + await joinDefaultCommunity(aliceWindow1, 'Lokinet Updates'); + await bannerShouldNotAppear(aliceWindow1); + await joinDefaultCommunity(aliceWindow1, 'Session Network Updates'); + await bannerShouldNotAppear(aliceWindow1); + await joinDefaultCommunity(aliceWindow1, 'Session Updates'); + await bannerShouldAppear(aliceWindow1); +}); -test_Alice_1W( - 'Recovery password banner 2 windows', - async ({ aliceWindow1, alice }) => { - await joinDefaultCommunity(aliceWindow1, 'Lokinet Updates'); - await joinDefaultCommunity(aliceWindow1, 'Session Network Updates'); - await joinDefaultCommunity(aliceWindow1, 'Session Updates'); - const aliceWindow2 = await linkedDevice(alice.recoveryPassword); - await sleepFor(2_000); - await bannerShouldNotAppear(aliceWindow2); - }, -); +test_Alice_1W('Recovery banner 2 windows', async ({ aliceWindow1, alice }) => { + await joinDefaultCommunity(aliceWindow1, 'Lokinet Updates'); + await joinDefaultCommunity(aliceWindow1, 'Session Network Updates'); + await joinDefaultCommunity(aliceWindow1, 'Session Updates'); + const aliceWindow2 = await linkedDevice(alice.recoveryPassword); + await sleepFor(2_000); + await bannerShouldNotAppear(aliceWindow2); +}); test_Alice_1W( - 'Recovery password banner persists when conversation count drops below 3', + 'Recovery banner persists with drop', async ({ aliceWindow1 }) => { await joinDefaultCommunity(aliceWindow1, 'Lokinet Updates'); await joinDefaultCommunity(aliceWindow1, 'Session Network Updates'); @@ -72,7 +66,7 @@ test_Alice_1W( ); test_Alice_1W( - 'Recovery password banner disappears after being opened', + 'Recovery banner closes once opened', async ({ aliceWindow1 }) => { await joinDefaultCommunity(aliceWindow1, 'Lokinet Updates'); await joinDefaultCommunity(aliceWindow1, 'Session Network Updates'); diff --git a/tests/automation/setup/create_group.ts b/tests/automation/setup/create_group.ts index 581fed7..866079b 100644 --- a/tests/automation/setup/create_group.ts +++ b/tests/automation/setup/create_group.ts @@ -10,7 +10,7 @@ import { clickOn, clickOnMatchingText, clickOnWithText, - typeIntoInput, + pasteIntoInput, waitForTestIdWithText, waitForTextMessages, } from '../utilities/utils'; @@ -57,7 +57,7 @@ export const createGroup = async ( await clickOn(windowA, HomeScreen.plusButton); await clickOn(windowA, HomeScreen.createGroupOption); // Enter group name - await typeIntoInput( + await pasteIntoInput( windowA, HomeScreen.createGroupGroupName.selector, group.userName, diff --git a/tests/automation/setup/new_user.ts b/tests/automation/setup/new_user.ts index 46520f4..0bc0077 100644 --- a/tests/automation/setup/new_user.ts +++ b/tests/automation/setup/new_user.ts @@ -7,7 +7,7 @@ import { checkPathLight, clickOn, grabTextFromElement, - typeIntoInput, + pasteIntoInput, waitForTestIdWithText, } from '../utilities/utils'; @@ -19,7 +19,7 @@ export const newUser = async ( // Create User await clickOn(window, Onboarding.createAccountButton); // Input username = testuser - await typeIntoInput(window, Onboarding.displayNameInput.selector, userName); + await pasteIntoInput(window, Onboarding.displayNameInput.selector, userName); await clickOn(window, Global.continueButton); // save recovery phrase await clickOn(window, LeftPane.profileButton); @@ -45,9 +45,9 @@ export const newUser = async ( accountid = accountid.replace(/[^0-9a-fA-F]/g, ''); // keep only hex characters console.log( - `${userName}: Account ID: "${chalk.blue( + `${userName}: \n\tAccount ID: "${chalk.bgBlue( accountid, - )}" and Recovery password: "${chalk.green(recoveryPassword)}"`, + )}" \n\tRecovery password: "${chalk.bgGreen(recoveryPassword)}"`, ); await clickOn(window, Global.modalCloseButton); if (awaitOnionPath) { diff --git a/tests/automation/setup/open.ts b/tests/automation/setup/open.ts index 10e5080..4df9544 100644 --- a/tests/automation/setup/open.ts +++ b/tests/automation/setup/open.ts @@ -7,7 +7,7 @@ import { v4 } from 'uuid'; const logNodeConsole = process.env.LOG_NODE_CONSOLE === '1'; export const NODE_ENV = 'production'; -export const MULTI_PREFIX = 'test-integration-'; +export const MULTI_PREFIX = 'test-integration'; const multisAvailable = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; let electronPids: Array = []; @@ -76,19 +76,24 @@ const openElectronAppOnly = async (multi: string, context?: TestContext) => { console.info(' NODE_APP_INSTANCE', process.env.NODE_APP_INSTANCE); try { + const start = Date.now(); + const useXvfb = process.env.USE_XVFB === '1'; const electronApp = await electron.launch({ args: [ join(getAppRootPath(), 'app', 'ts', 'mains', 'main_node.js'), '--disable-gpu', '--force-device-scale-factor=1', // Normalizes Retina and non-Retina mac screens + ...(useXvfb ? ['--ozone-platform=x11'] : []), ], env: { ...process.env, ELECTRON_ENABLE_LOGGING: '1', // Optional: control log level ELECTRON_LOG_LEVEL: 'verbose', // 'verbose', 'info', 'warn', 'error' + ...(useXvfb && { WAYLAND_DISPLAY: '' }), }, }); + console.info(` Electron app launched in ${Date.now() - start}ms`); if (logNodeConsole) { electronApp.on('console', (msg) => { @@ -127,7 +132,9 @@ const logBrowserConsole = process.env.LOG_BROWSER_CONSOLE === '1'; const openAppAndWait = async (multi: string, context?: TestContext) => { const electronApp = await openElectronAppOnly(multi, context); // Get the first window that the app opens, wait if necessary. + const start = Date.now(); const window = await electronApp.firstWindow(); + console.info(` Browser window opened in ${Date.now() - start}ms`); window.on('console', (msg) => { if (!logBrowserConsole) { return; @@ -170,3 +177,7 @@ export function getTrackedElectronPids(): Array { export function resetTrackedElectronPids() { electronPids = []; } + +export function isRunningOnDevNet() { + return !!process.env.LOCAL_DEVNET_SEED_URL; +} diff --git a/tests/automation/setup/recovery_using_seed.ts b/tests/automation/setup/recovery_using_seed.ts index a6047a3..3187e24 100644 --- a/tests/automation/setup/recovery_using_seed.ts +++ b/tests/automation/setup/recovery_using_seed.ts @@ -4,7 +4,7 @@ import { Global, Onboarding } from '../locators'; import { clickOn, doesElementExist, - typeIntoInput, + pasteIntoInput, waitForLoadingAnimationToFinish, } from '../utilities/utils'; @@ -14,7 +14,7 @@ export async function recoverFromSeed( options?: { fallbackName?: string }, ) { await clickOn(window, Onboarding.iHaveAnAccountButton); - await typeIntoInput(window, 'recovery-phrase-input', recoveryPhrase); + await pasteIntoInput(window, 'recovery-phrase-input', recoveryPhrase); await clickOn(window, Global.continueButton); await waitForLoadingAnimationToFinish(window, 'loading-animation'); const displayNameInput = await doesElementExist( @@ -27,7 +27,7 @@ export async function recoverFromSeed( throw new Error(`Display name was not found when restoring from seed`); } // Fallback for when name might be missing (but it's okay) - await typeIntoInput( + await pasteIntoInput( window, Onboarding.displayNameInput.selector, options.fallbackName, diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index 370f1ee..a2321e3 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -190,6 +190,7 @@ export type DataTestId = | 'path-light-container' | 'privacy-settings-menu-item' | 'profile-name-input' + | 'quote-text' | 'recovery-password-seed-modal' | 'recovery-password-settings-menu-item' | 'recovery-phrase-input' @@ -198,6 +199,7 @@ export type DataTestId = | 'reveal-recovery-phrase' | 'save-button-profile-update' | 'scroll-to-bottom-button' + | 'search-input' | 'send-message-button' | 'sent-price' | 'session-confirm-cancel-button' @@ -210,6 +212,7 @@ export type DataTestId = | 'set-password-button' | 'set-password-settings-button' | 'settings-section' + | 'staged-attachments-container' | 'staking-reward-pool-amount' | 'swarm-image' | 'theme-section' diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index eab030d..789f61c 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -25,9 +25,10 @@ import { clickOnElement, clickOnMatchingText, clickOnWithText, + controlOrMetaFor, doesElementExist, hasElementBeenDeleted, - typeIntoInput, + pasteIntoInput, waitForLoadingAnimationToFinish, waitForMatchingText, waitForTestIdWithText, @@ -146,7 +147,7 @@ test_Alice_1W_no_network('Change username', async ({ aliceWindow1 }) => { // Click on current username to open edit field await clickOn(aliceWindow1, Settings.displayName); // Type in new username - await typeIntoInput( + await pasteIntoInput( aliceWindow1, Settings.displayNameInput.selector, newUsername, @@ -189,6 +190,7 @@ test_Alice_1W_no_network('Add avatar', async ({ aliceWindow1 }, testInfo) => { element: leftpaneAvatarContainer, snapshotName: 'avatar-updated-blue.jpeg', testInfo, + maxRetryDurationMs: 4_000, }); }); @@ -249,7 +251,7 @@ test_Alice_1W_Bob_1W( await clickOnMatchingText(aliceWindow1, tStripped('nicknameSet')); await sleepFor(1000); - await typeIntoInput(aliceWindow1, 'nickname-input', nickname); + await pasteIntoInput(aliceWindow1, 'nickname-input', nickname); await sleepFor(100); await clickOnWithText( aliceWindow1, @@ -448,8 +450,8 @@ test_Alice_1W_no_network('Invite a friend', async ({ aliceWindow1, alice }) => { // New message await clickOn(aliceWindow1, HomeScreen.newMessageOption); await clickOn(aliceWindow1, HomeScreen.newMessageAccountIDInput); - const isMac = process.platform === 'darwin'; - await aliceWindow1.keyboard.press(`${isMac ? 'Meta' : 'Control'}+V`); + + await aliceWindow1.keyboard.press(`${controlOrMetaFor()}+V`); await clickOn(aliceWindow1, HomeScreen.newMessageNextButton); // Did the copied text create note to self? await waitForTestIdWithText( @@ -464,7 +466,7 @@ test_Alice_1W_no_network( async ({ aliceWindow1, alice }) => { await clickOn(aliceWindow1, HomeScreen.plusButton); await clickOn(aliceWindow1, HomeScreen.newMessageOption); - await typeIntoInput( + await pasteIntoInput( aliceWindow1, 'new-session-conversation', alice.accountid, diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index 092ae5f..2624a10 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -12,7 +12,7 @@ import { clickOnMatchingText, clickOnWithText, hasElementBeenDeleted, - typeIntoInput, + pasteIntoInput, waitForLoadingAnimationToFinish, waitForMatchingText, waitForTestIdWithText, @@ -22,7 +22,7 @@ export const joinCommunity = async (window: Page) => { await clickOn(window, HomeScreen.plusButton); await clickOn(window, HomeScreen.joinCommunityOption); // The follow two test tags are pending implementation - await typeIntoInput( + await pasteIntoInput( window, HomeScreen.joinCommunityInput.selector, testCommunityLink, diff --git a/tests/automation/utilities/leave_group.ts b/tests/automation/utilities/leave_group.ts index 2ec3d07..38d3159 100644 --- a/tests/automation/utilities/leave_group.ts +++ b/tests/automation/utilities/leave_group.ts @@ -22,7 +22,7 @@ export const leaveGroup = async (window: Page, group: Group) => { window, 'data-testid', 'module-conversation__user__profile-name', - undefined, + 5_000, group.userName, ); }; diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index a1120f0..c897243 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; import { MessageStatus } from '../types/testing'; -import { clickOnElement, typeIntoInput } from './utils'; +import { clickOnElement, pasteIntoInput } from './utils'; export const waitForMessageStatus = async ( window: Page, @@ -20,7 +20,7 @@ export const waitForMessageStatus = async ( export const sendMessage = async (window: Page, message: string) => { // type into message input box - await typeIntoInput(window, 'message-input-text-area', message); + await pasteIntoInput(window, 'message-input-text-area', message); // click up arrow (send) await clickOnElement({ window, diff --git a/tests/automation/utilities/rename_group.ts b/tests/automation/utilities/rename_group.ts index 1e778d8..3501296 100644 --- a/tests/automation/utilities/rename_group.ts +++ b/tests/automation/utilities/rename_group.ts @@ -5,7 +5,7 @@ import { Conversation, ConversationSettings, Global } from '../locators'; import { clickOn, clickOnMatchingText, - typeIntoInput, + pasteIntoInput, waitForMatchingText, waitForTestIdWithText, } from './utils'; @@ -18,7 +18,7 @@ export const renameGroup = async ( await clickOnMatchingText(window, oldGroupName); await clickOn(window, Conversation.conversationSettingsIcon); await clickOn(window, ConversationSettings.editGroupButton); - await typeIntoInput(window, 'update-group-info-name-input', newGroupName); + await pasteIntoInput(window, 'update-group-info-name-input', newGroupName); await window.keyboard.press('Enter'); await clickOnMatchingText(window, tStripped('save')); await waitForTestIdWithText(window, 'group-name', newGroupName); diff --git a/tests/automation/utilities/reply_message.ts b/tests/automation/utilities/reply_message.ts index 19cb3aa..1591b61 100644 --- a/tests/automation/utilities/reply_message.ts +++ b/tests/automation/utilities/reply_message.ts @@ -2,6 +2,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; import { sleepFor } from '../../promise_utils'; +import { Conversation } from '../locators'; import { Strategy } from '../types/testing'; import { sendMessage } from './message'; import { verifyMediaPreviewLoaded } from './send_media'; @@ -9,6 +10,7 @@ import { clickOnMatchingText, clickOnTextMessage, waitForElement, + waitForTestIdWithText, waitForTextMessage, } from './utils'; @@ -62,8 +64,18 @@ export const replyTo = async ({ } } await sendMessage(senderWindow, replyText); + await waitForTestIdWithText( + senderWindow, + Conversation.quoteText.selector, + textMessage, + ); if (receiverWindow) { await waitForTextMessage(receiverWindow, replyText); + await waitForTestIdWithText( + receiverWindow, + Conversation.quoteText.selector, + textMessage, + ); } }; diff --git a/tests/automation/utilities/screenshot.ts b/tests/automation/utilities/screenshot.ts index 019e727..13e17f6 100644 --- a/tests/automation/utilities/screenshot.ts +++ b/tests/automation/utilities/screenshot.ts @@ -53,13 +53,15 @@ export async function compareElementScreenshot( let tryNumber = 0; let lastError: Error | undefined; + let lastScreenshot: Buffer | undefined; + while (Date.now() - start <= maxRetryDurationMs) { try { - const screenshot = await element.screenshot({ + lastScreenshot = await element.screenshot({ type: imageType, }); - expect(screenshot).toMatchSnapshot({ + expect(lastScreenshot).toMatchSnapshot({ name: snapshotName, maxDiffPixelRatio, }); @@ -76,6 +78,16 @@ export async function compareElementScreenshot( } } + if (lastScreenshot) { + // save the snapshot to a temp folder for inspection + const tempPath = testInfo.snapshotPath(`temp-${snapshotName}`); + fs.writeFileSync(tempPath, lastScreenshot); + console.error( + `Screenshot matching of "${snapshotName}" failed after ${tryNumber} attempt(s) (${maxRetryDurationMs}ms)`, + ); + console.warn(`\n\texpected:${snapshotPath}\n\treceived: ${tempPath}`); + } + // Only reach here if we timed out console.error( `Screenshot matching of "${snapshotName}" failed after ${tryNumber} attempt(s) (${maxRetryDurationMs}ms)`, diff --git a/tests/automation/utilities/send_media.ts b/tests/automation/utilities/send_media.ts index d416d15..bc3447b 100644 --- a/tests/automation/utilities/send_media.ts +++ b/tests/automation/utilities/send_media.ts @@ -3,6 +3,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; import { sleepFor } from '../../promise_utils'; import { Conversation, Global, Settings } from '../locators'; +import { isRunningOnDevNet } from '../setup/open'; import { MediaType } from '../types/testing'; import { waitForMessageStatus } from './message'; import { @@ -11,7 +12,8 @@ import { clickOnElement, clickOnMatchingText, clickOnWithText, - typeIntoInput, + controlOrMetaFor, + pasteIntoInput, waitForLoadingAnimationToFinish, waitForTestIdWithText, waitForTextMessage, @@ -74,8 +76,18 @@ export const sendMedia = async ( shouldCheckMediaPreview: boolean = false, ) => { // Send media - await window.setInputFiles("input[type='file']", `${path}`); - await typeIntoInput(window, 'message-input-text-area', testMessage); + await window.setInputFiles("input[type='file']", path); + await pasteIntoInput(window, 'message-input-text-area', testMessage); + // make sure that both the staged attachment container and message content we expect are there before we hit "send" + await Promise.all([ + waitForTestIdWithText(window, 'message-input-text-area', testMessage, 1000), + waitForTestIdWithText( + window, + 'staged-attachments-container', + undefined, + 1000, + ), + ]); await clickOnElement({ window, strategy: 'data-testid', @@ -108,39 +120,34 @@ export const sendVoiceMessage = async (window: Page) => { }; export const sendLinkPreview = async (window: Page, testLink: string) => { - await typeIntoInput(window, 'message-input-text-area', testLink); + // The clipboard is shared across the system so multiple tests can write to it and break each others. + // I have made the copy and paste as fast as possible here so that this happens as little as possible. + await pasteIntoInput(window, 'search-input', testLink); await clickOnElement({ window, strategy: 'data-testid', - selector: 'send-message-button', - }); - await clickOnWithText(window, Conversation.messageContent, testLink, { - rightButton: true, + selector: 'search-input', }); + // Need to copy link to clipboard, as the enable link preview modal // doesn't pop up if manually typing link (needs to be pasted) - // Need to have a nth(0) here to account for Copy Account ID, Appium was getting confused - // Tried to use englishStripped here but Playwright doesn't like it - // const copyText = tStripped('copy'); - - const firstCopyBtn = window - .locator(`[data-testid=context-menu-item]:has-text("Copy")`) - .nth(0); - await firstCopyBtn.click(); - await waitForTestIdWithText(window, 'session-toast', tStripped('copied')); - // click on the toast and wait for it to be closed to avoid the layout shift - await clickOn(window, Global.toast); - await sleepFor(1000); + await window.keyboard.press(`${controlOrMetaFor()}+A`); + await window.keyboard.press(`${controlOrMetaFor()}+X`); await clickOn(window, Conversation.messageInput); - const isMac = process.platform === 'darwin'; - await window.keyboard.press(`${isMac ? 'Meta' : 'Control'}+V`); + await window.keyboard.press(`${controlOrMetaFor()}+V`); await checkModalStrings( window, tStripped('linkPreviewsEnable'), tStripped('linkPreviewsFirstDescription'), ); await clickOnWithText(window, Global.confirmButton, tStripped('enable')); - await waitForLoadingAnimationToFinish(window, Global.loadingSpinner.selector); + if (!isRunningOnDevNet()) { + // when on devnet, often we don't even see the loading spinner + await waitForLoadingAnimationToFinish( + window, + Global.loadingSpinner.selector, + ); + } await waitForTestIdWithText(window, 'link-preview-image'); await waitForTestIdWithText( window, diff --git a/tests/automation/utilities/send_message.ts b/tests/automation/utilities/send_message.ts index a3769b2..ba1e194 100644 --- a/tests/automation/utilities/send_message.ts +++ b/tests/automation/utilities/send_message.ts @@ -2,7 +2,7 @@ import { Page } from '@playwright/test'; import { HomeScreen } from '../locators'; import { sendMessage } from './message'; -import { clickOn, typeIntoInput } from './utils'; +import { clickOn, pasteIntoInput } from './utils'; export const sendNewMessage = async ( window: Page, @@ -12,7 +12,7 @@ export const sendNewMessage = async ( await clickOn(window, HomeScreen.plusButton); await clickOn(window, HomeScreen.newMessageOption); // Enter session ID of USER B - await typeIntoInput(window, 'new-session-conversation', sessionid); + await pasteIntoInput(window, 'new-session-conversation', sessionid); // click next await clickOn(window, HomeScreen.newMessageNextButton); await sendMessage(window, message); diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 9ec9e0f..c35282d 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -23,7 +23,7 @@ type ElementOptions = { }; // TODO Unify element interaction functions to use locator objects the way clickOn and clickOnWithText do -// Remaining functions to migrate: waitForElement, typeIntoInput, grabTextFromElement etc. +// Remaining functions to migrate: waitForElement, pasteIntoInput, grabTextFromElement etc. // WAIT FOR FUNCTIONS @@ -385,13 +385,12 @@ export function getMessageTextContentNow() { return `Test message timestamp: ${Date.now()}`; } -export async function typeIntoInput( +export async function pasteIntoInput( window: Page, dataTestId: DataTestId, text: string, - paste?: boolean, // typing long messages hits the runner timeout ) { - console.info(`typeIntoInput testId: ${dataTestId} : "${text}"`); + console.info(`pasteIntoInput testId: ${dataTestId} : "${text}"`); const builtSelector = `css=[data-testid=${dataTestId}]`; // the new input made with onboarding element needs a click to reveal the input in the DOM // Convert DataTestId to locator object for clickOn @@ -399,11 +398,7 @@ export async function typeIntoInput( await clickOn(window, locator); // reset the content to be empty before typing into the input await window.fill(builtSelector, ''); - if (paste) { - await window.fill(builtSelector, text); - } else { - await window.type(builtSelector, text); - } + await window.fill(builtSelector, text); } export async function doesTextIncludeString( @@ -436,7 +431,7 @@ export async function hasElementBeenDeleted( window: Page, strategy: Strategy, selector: string, - maxWait: number = 30000, + maxWait: number, text?: string, ) { const start = Date.now(); @@ -731,3 +726,7 @@ export async function scrollToBottomIfNecessary(window: Page): Promise { await clickOn(window, Conversation.scrollToBottomButton); } } + +export function controlOrMetaFor() { + return process.platform === 'darwin' ? 'Meta' : 'Control'; +} diff --git a/tuiReporter.ts b/tuiReporter.ts new file mode 100644 index 0000000..c5c3a68 --- /dev/null +++ b/tuiReporter.ts @@ -0,0 +1,240 @@ +import type { + FullConfig, + FullResult, + Reporter, + Suite, + TestCase, + TestError, + TestResult, +} from '@playwright/test/reporter'; + +import chalk from 'chalk'; +import { groupBy, isString, mean, sortBy } from 'lodash'; + +import { TerminalTui } from './terminalTui'; + +type TestAndResult = { test: TestCase; result: TestResult }; + +class TuiReporter implements Reporter { + private tui = new TerminalTui(); + private allResults: Array = []; + private allTestsCount = 0; + private countWorkers = 1; + private startTime = 0; + + printsToStdio(): boolean { + return true; + } + + onBegin(config: FullConfig, suite: Suite) { + this.allTestsCount = suite.allTests().length; + this.countWorkers = config.workers; + this.startTime = Date.now(); + + this.tui.start(); + this.tui.onStop(() => { + // User pressed q or Ctrl+C during test run — print summary and terminate + // (after onEnd, waitForClose() replaces this callback for graceful exit) + this.printSummary(); + process.exit(1); + }); + + for (const test of suite.allTests()) { + this.tui.addTest(test.id, test.title); + } + + this.tui.setProgress(0, this.allTestsCount, 0); + } + + onTestBegin(test: TestCase, result: TestResult) { + const status = result.retry > 0 ? 'retrying' : 'running'; + if (result.retry > 0) { + this.tui.clearOutput(test.id); + } + this.tui.updateTest(test.id, status, undefined, result.retry); + } + + onTestEnd(test: TestCase, result: TestResult) { + const status = result.status === 'timedOut' ? 'timedOut' : result.status; + this.tui.updateTest(test.id, status, result.duration, result.retry); + + if (result.errors.length > 0) { + this.tui.setError( + test.id, + result.errors.map((e) => ({ + message: e.message, + snippet: e.snippet, + stack: e.stack, + })), + ); + } + + this.allResults.push({ test, result }); + + // Calculate progress + const completedCount = this.allResults.length; + const avgDuration = mean(this.allResults.map((m) => m.result.duration)); + const remainingTests = this.allTestsCount - completedCount; + const estimatedMsLeft = (remainingTests * avgDuration) / this.countWorkers; + const estimatedMinsLeft = Math.ceil(estimatedMsLeft / 60000); + + this.tui.setProgress(completedCount, this.allTestsCount, estimatedMinsLeft); + } + + onStdOut( + chunk: Buffer | string, + test: TestCase | void, + _result: TestResult | void, + ) { + if (test) { + const text = isString(chunk) ? chunk : chunk.toString('utf-8'); + this.tui.appendOutput(test.id, text); + } + } + + onStdErr( + chunk: Buffer | string, + test: TestCase | void, + _result: TestResult | void, + ) { + if (test) { + const text = isString(chunk) ? chunk : chunk.toString('utf-8'); + this.tui.appendOutput(test.id, text); + } + } + + onError(error: TestError) { + // Global errors: show in a pseudo-test entry + const globalId = '__global_errors__'; + const existing = this.allResults.find((r) => r.test.id === globalId); + if (!existing) { + this.tui.addTest(globalId, '[Global Errors]'); + } + const msg = error.message || 'Unknown error'; + this.tui.appendOutput(globalId, `${chalk.red('Error:')} ${msg}\n`); + if (error.stack) { + this.tui.appendOutput(globalId, chalk.dim(error.stack) + '\n'); + } + this.tui.updateTest(globalId, 'failed'); + } + + async onEnd(_result: FullResult) { + this.tui.reorderForSummary(); + // Workers are already cleaned up by the time onEnd is called. + // Block here to keep the TUI open for browsing results. + await this.tui.waitForClose(); + this.tui.stop(); + this.printSummary(); + } + + private printSummary() { + const divider = chalk.dim('\u2550'.repeat(50)); + console.log(`\n${divider}`); + console.log(chalk.bold(' Test Results')); + console.log(divider); + + const grouped = groupBy( + this.allResults.filter((r) => r.test.id !== '__global_errors__'), + (a) => a.test.title, + ); + + let passedCount = 0; + let failedCount = 0; + let flakyCount = 0; + let skippedCount = 0; + let interruptedCount = 0; + const failedTests: Array<{ results: TestAndResult[]; title: string }> = []; + const flakyTests: Array<{ results: TestAndResult[]; title: string }> = []; + + for (const [title, results] of Object.entries(grouped)) { + const allPassed = results.every((r) => r.result.status === 'passed'); + const anyPassed = results.some((r) => r.result.status === 'passed'); + const allSkipped = results.every((r) => r.result.status === 'skipped'); + const allInterrupted = results.every( + (r) => r.result.status === 'interrupted', + ); + + if (allSkipped) { + skippedCount++; + } else if (allInterrupted) { + interruptedCount++; + } else if (allPassed) { + passedCount++; + } else if (anyPassed) { + flakyCount++; + flakyTests.push({ results, title }); + } else { + failedCount++; + failedTests.push({ results, title }); + } + } + + // Tests that never finished (still running/pending when stopped) + const finishedTitles = new Set(Object.keys(grouped)); + + const cancelledCount = this.allTestsCount - finishedTitles.size; + // Summary line + const parts: string[] = []; + if (passedCount > 0) + parts.push(chalk.green(`\u2713 ${passedCount} passed`)); + if (failedCount > 0) parts.push(chalk.red(`\u2717 ${failedCount} failed`)); + if (flakyCount > 0) parts.push(chalk.yellow(`\u21bb ${flakyCount} flaky`)); + if (interruptedCount > 0) + parts.push(chalk.yellow(`\u2716 ${interruptedCount} interrupted`)); + if (cancelledCount > 0) + parts.push(chalk.dim(`\u25a0 ${cancelledCount} cancelled`)); + if (skippedCount > 0) + parts.push(chalk.blue(`\u25cb ${skippedCount} skipped`)); + console.log(` ${parts.join(' ')}`); + console.log(''); + + // Failed details + if (failedTests.length > 0) { + console.log(chalk.red.bold(' Failed:')); + for (const { results, title } of sortBy(failedTests, (t) => t.title)) { + const attempts = results.length; + console.log( + chalk.red( + ` \u2717 ${title} (${attempts} attempt${ + attempts > 1 ? 's' : '' + })`, + ), + ); + const lastResult = results[results.length - 1]; + const lastError = + lastResult.result.errors[lastResult.result.errors.length - 1]; + if (lastError?.message) { + console.log( + chalk.dim(` Error: ${lastError.message.split('\n')[0]}`), + ); + } + } + console.log(''); + } + + // Flaky details + if (flakyTests.length > 0) { + console.log(chalk.yellow.bold(' Flaky (passed on retry):')); + for (const { results, title } of sortBy(flakyTests, (t) => t.title)) { + const passedResult = results.find((r) => r.result.status === 'passed'); + const retryNum = passedResult ? passedResult.result.retry + 1 : '?'; + console.log( + chalk.yellow( + ` \u21bb ${title} (passed on attempt ${retryNum}/${results.length})`, + ), + ); + } + console.log(''); + } + + // Duration + const totalMs = Date.now() - this.startTime; + const mins = Math.floor(totalMs / 60000); + const secs = Math.floor((totalMs % 60000) / 1000); + console.log(chalk.dim(` Duration: ${mins}m ${secs}s`)); + console.log(divider); + console.log(''); + } +} + +export default TuiReporter;