diff --git a/eslint.config.mjs b/eslint.config.mjs index 7fb26fa..0d74201 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -81,6 +81,12 @@ export default tseslint.config( ], }, ], + 'perfectionist/sort-classes': [ + 'error', + { + partitionByComment: true, + }, + ], }, }, ); diff --git a/playwright.config.ts b/playwright.config.ts index 7f05f1b..2640751 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,6 +6,24 @@ import { screenshotFolder } from './tests/automation/constants/variables'; dotenv.config({ quiet: true }); +function repeatEach() { + return process.env.PLAYWRIGHT_REPEAT_COUNT + ? toNumber(process.env.PLAYWRIGHT_REPEAT_COUNT) + : 0; +} + +function retryEach() { + return process.env.PLAYWRIGHT_RETRIES_COUNT + ? toNumber(process.env.PLAYWRIGHT_RETRIES_COUNT) + : 0; +} + +function workersCount() { + return process.env.PLAYWRIGHT_WORKERS_COUNT + ? toNumber(process.env.PLAYWRIGHT_WORKERS_COUNT) + : 1; +} + export default defineConfig({ timeout: 350000, globalTimeout: 6000000, @@ -20,15 +38,30 @@ export default defineConfig({ testDir: './tests/automation', testIgnore: '*.js', outputDir: './tests/automation/test-results', - retries: process.env.PLAYWRIGHT_RETRIES_COUNT - ? toNumber(process.env.PLAYWRIGHT_RETRIES_COUNT) - : 0, - repeatEach: process.env.PLAYWRIGHT_REPEAT_COUNT - ? toNumber(process.env.PLAYWRIGHT_REPEAT_COUNT) - : 0, - workers: toNumber(process.env.PLAYWRIGHT_WORKERS_COUNT) || 1, + retries: retryEach(), + repeatEach: repeatEach(), reportSlowTests: null, - fullyParallel: true, // otherwise, tests in the same file are not run in parallel globalSetup: './global.setup', // clean leftovers of previous test runs on start, runs only once snapshotPathTemplate: `${screenshotFolder}/{testName}/{arg}-{platform}{ext}`, + projects: [ + /** + * The community tests relying on sending/receiving messages are unreliable when run in parallel. + * I think it comes down to the jump that happens when a new message is received, and also + * because receiving a new message closes an open context menu. + */ + { + name: 'Community tests', + // Those needs to be run sequentially as they are making each others unreliable + // (they all are using the same community) + testMatch: '**/*community*tests.spec.ts', + fullyParallel: false, + workers: 1, // those community tests need to be run sequentially + }, + { + name: 'All other tests', + testMatch: '**/!(*community*tests).spec.ts', + fullyParallel: true, // set this to true so that tests in the same file are not run in parallel + workers: workersCount(), + }, + ], }); diff --git a/terminalTui.ts b/terminalTui.ts index 665fb31..17632cc 100644 --- a/terminalTui.ts +++ b/terminalTui.ts @@ -135,31 +135,104 @@ function wrapLine(line: string, width: number): string[] { // --- 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 autoFollow = true; + private elapsedTimer: ReturnType | null = null; + private exitHandler: (() => void) | null = null; + private flashMessage: string | null = null; + private flashTimeout: ReturnType | null = null; + private frozenElapsedMs: number | null = null; // set when suite finishes + private isActive = false; + private keyHandler: ((data: Buffer) => void) | null = null; + private lastLeftWidth = 30; // saved from render() for mouse click mapping + private lastListStart = 0; // saved from render() for mouse click mapping + private lastOutputLines: string[] = []; // cached from last render for selection copy + private lastUserInteractionTime = 0; + private onStopCallback: StopCallback | null = null; + private originalStdinRawMode: boolean | undefined; + private outputScrollOffset = 0; 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 selectedIndex = 0; private selection: { startRow: number; endRow: number } | null = null; - private lastOutputLines: string[] = []; // cached from last render for selection copy + private startTime: number | null = null; + private testOrder: string[] = []; + private tests: Map = new Map(); + + 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(); + } + + 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(); + } + } + + onStop(cb: StopCallback): void { + this.onStopCallback = cb; + } + + 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(); + } start(): void { if (!process.stdout.isTTY) return; @@ -190,6 +263,8 @@ export class TerminalTui { }; process.on('exit', this.exitHandler); + this.startTime = Date.now(); + this.elapsedTimer = setInterval(() => this.scheduleRender(), 1000); this.scheduleRender(); } @@ -197,6 +272,11 @@ export class TerminalTui { if (!this.isActive) return; this.isActive = false; + if (this.elapsedTimer) { + clearInterval(this.elapsedTimer); + this.elapsedTimer = null; + } + if (this.flashTimeout) { clearTimeout(this.flashTimeout); this.flashTimeout = null; @@ -206,24 +286,6 @@ export class TerminalTui { 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, @@ -253,58 +315,6 @@ export class TerminalTui { 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 => { @@ -326,6 +336,16 @@ export class TerminalTui { this.selectedIndex = 0; this.outputScrollOffset = 0; this.autoFollow = false; + + // Freeze elapsed time and stop the live ticker + if (this.startTime !== null) { + this.frozenElapsedMs = Date.now() - this.startTime; + } + if (this.elapsedTimer) { + clearInterval(this.elapsedTimer); + this.elapsedTimer = null; + } + this.scheduleRender(); } @@ -352,11 +372,6 @@ export class TerminalTui { } /** 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; @@ -366,230 +381,139 @@ export class TerminalTui { }); } - private render(): void { - const cols = process.stdout.columns || 80; - const rows = process.stdout.rows || 24; + private scrollOutput(offset: number): void { + this.outputScrollOffset = Math.max(0, offset); + this.scheduleRender(); + } - 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), - ); + /** Returns a 1-char scrollbar indicator for a given row in the track. */ + 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 copyLeftPane(): void { + if (this.testOrder.length === 0) { + this.showFlash('No tests'); 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 + const lines: string[] = [ + `Tests: ${this.progress.completed}/${this.progress.total}`, + '', + ]; - let buf = MOVE_TO(1, 1); + for (const id of this.testOrder) { + const entry = this.tests.get(id)!; + const status = entry.status.toUpperCase().padEnd(10); + const retry = entry.retry > 0 ? ` (retry #${entry.retry})` : ''; + const dur = + entry.duration !== null ? ` [${formatDuration(entry.duration)}]` : ''; + lines.push(`${status} ${entry.title}${retry}${dur}`); + } - // --- 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); + this.copyToClipboard(lines.join('\n')); + } - 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'); + private copySelectedOutput(): void { + const entry = this.tests.get(this.testOrder[this.selectedIndex] ?? ''); + if (!entry) return; - // --- 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, - ), - ); + let text = `Test: ${entry.title}\nStatus: ${entry.status}`; + if (entry.duration !== null) { + text += ` (${formatDuration(entry.duration)})`; } - 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); + if (entry.retry > 0) { + text += ` retry #${entry.retry}`; } + text += '\n\n'; - // --- 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}`) : ''; + if (entry.output.length > 0) { + text += entry.output.map(stripAnsi).join('\n') + '\n'; + } - buf += ` ${listHint} ${tabHint} ${qHint} ${cHint} ${fHint} ${chalk.dim( - '|', - )} ${progressStr}${estStr}${flash}`; + 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'; + } - process.stdout.write(buf); + this.copyToClipboard(text); } - 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')); + 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: [] }); } - return lines; - } - - // stdout/stderr output - for (const line of entry.output) { - lines.push(...wrapLine(line, width)); + candidates.push({ cmd: 'xclip', args: ['-selection', 'clipboard'] }); + candidates.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }); } + this.tryCopyWithCandidates(text, candidates, 0); + } - // 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(''); - } + 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'); } - - return lines; } private handleKey(data: Buffer): void { @@ -626,11 +550,15 @@ export class TerminalTui { return; } - // c to copy - if (key === 'c' || key === 'C') { + // c to copy output, C to copy left pane + if (key === 'c') { this.copySelectedOutput(); return; } + if (key === 'C') { + this.copyLeftPane(); + return; + } // f to toggle auto-follow if (key === 'f' || key === 'F') { @@ -777,47 +705,264 @@ export class TerminalTui { } } - 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)})`; + private removeListeners(): void { + if (this.keyHandler) { + process.stdin.removeListener('data', this.keyHandler); + this.keyHandler = null; } - if (entry.retry > 0) { - text += ` retry #${entry.retry}`; + if (this.resizeHandler) { + process.stdout.removeListener('resize', this.resizeHandler); + this.resizeHandler = null; } - text += '\n\n'; + if (this.exitHandler) { + process.removeListener('exit', this.exitHandler); + this.exitHandler = null; + } + } - if (entry.output.length > 0) { - text += entry.output.map(stripAnsi).join('\n') + '\n'; + 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; } - 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'; + 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; - this.copyToClipboard(text); + // Right pane: build wrapped output lines (−1 for scrollbar column) + const outputLines = this.buildOutputLines(selectedTest, rightWidth - 3); + 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 - + 1 - // scrollbar column + 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 - 1 - visibleLength(durStr) - 2) + + durStr + + ' '; + + leftCell = isSelected + ? this.activePaneFocus === 'list' + ? chalk.inverse(padRight(lineWithDur, leftWidth - 1)) + : chalk.bgGray(padRight(lineWithDur, leftWidth - 1)) + : padRight(lineWithDur, leftWidth - 1); + } else { + leftCell = ' '.repeat(leftWidth - 1); + } + + const leftScrollbar = this.scrollbarChar( + listLen, + contentHeight, + listStart, + contentHeight, + row, + ); + buf += chalk.dim('\u2502') + leftCell + leftScrollbar; + + // Divider + buf += chalk.dim('\u2502'); + + // Right cell (−1 for scrollbar column) + const outIdx = this.outputScrollOffset + row; + let rightCell = ''; + if (outIdx < outputLines.length) { + rightCell = ' ' + truncate(outputLines[outIdx], rightWidth - 3) + RESET; + } + const isSelected = outIdx >= selLo && outIdx <= selHi; + const rightScrollbar = this.scrollbarChar( + outputLines.length, + contentHeight, + this.outputScrollOffset, + contentHeight, + row, + ); + buf += + (isSelected + ? chalk.inverse(padRight(rightCell, rightWidth - 1)) + : padRight(rightCell, rightWidth - 1)) + rightScrollbar; + } + + // --- 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 output ' + chalk.dim('C') + ' copy list'; + const isFollowing = + this.autoFollow && Date.now() - this.lastUserInteractionTime > 30_000; + const fHint = isFollowing + ? chalk.green('f') + chalk.green(' follow') + : chalk.dim('f') + ' follow'; + const elapsedMs = + this.frozenElapsedMs ?? + (this.startTime !== null ? Date.now() - this.startTime : null); + const elapsedStr = + elapsedMs !== null + ? chalk.dim(` ${formatDuration(elapsedMs)} elapsed`) + : ''; + 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}${elapsedStr}${estStr}${flash}`; + + process.stdout.write(buf); } - 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: [] }); + private restoreTerminal(): void { + try { + if (process.stdin.isTTY) { + process.stdin.setRawMode(this.originalStdinRawMode ?? false); } - candidates.push({ cmd: 'xclip', args: ['-selection', 'clipboard'] }); - candidates.push({ cmd: 'xsel', args: ['--clipboard', '--input'] }); + process.stdin.pause(); + process.stdin.unref(); + process.stdout.write(MOUSE_OFF + ALT_SCREEN_OFF + CURSOR_SHOW); + } catch { + // Terminal may already be gone } - this.tryCopyWithCandidates(text, candidates, 0); + } + + private scrollbarChar( + total: number, + visible: number, + offset: number, + trackHeight: number, + row: number, + ): string { + if (total <= visible) { + return chalk.dim('\u2502'); // │ — no scrolling needed + } + const thumbSize = Math.max(1, Math.round((visible / total) * trackHeight)); + const maxOffset = total - visible; + const clampedOffset = Math.min(offset, maxOffset); + const thumbStart = Math.round( + (clampedOffset / maxOffset) * (trackHeight - thumbSize), + ); + if (row >= thumbStart && row < thumbStart + thumbSize) { + return chalk.white('\u2588'); // █ thumb + } + return chalk.dim('\u2591'); // ░ track + } + + 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 tryCopyWithCandidates( @@ -850,53 +995,4 @@ export class TerminalTui { 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/call_checks.spec.ts b/tests/automation/call_checks.spec.ts index 9c77e6a..1db3118 100644 --- a/tests/automation/call_checks.spec.ts +++ b/tests/automation/call_checks.spec.ts @@ -13,8 +13,6 @@ test_Alice_1W_Bob_1W( 'Voice calls', async ({ alice, aliceWindow1, bob, bobWindow1 }) => { await createContact(aliceWindow1, bobWindow1, alice, bob); - // Unfocus current conversation on receiver's end - await clickOn(bobWindow1, Global.backButton); await clickOn(bobWindow1, HomeScreen.plusButton); await clickOnWithText(bobWindow1, Global.contactItem, 'Note to Self'); await makeVoiceCall(aliceWindow1, bobWindow1); diff --git a/tests/automation/community_admin_tests.spec.ts b/tests/automation/community_admin_tests.spec.ts new file mode 100644 index 0000000..9626811 --- /dev/null +++ b/tests/automation/community_admin_tests.spec.ts @@ -0,0 +1,127 @@ +import { tStripped } from '../localization/lib'; +import { testCommunityName } from './constants/community'; +import { Conversation, Global, HomeScreen } from './locators'; +import { newUser } from './setup/new_user'; +import { recoverFromSeed } from './setup/recovery_using_seed'; +import { sessionTestTwoWindows } from './setup/sessionTest'; +import { scrollToBottomLookingForMessage } from './utilities/conversation'; +import { + assertAdminIsKnown, + joinCommunity, + joinOrOpenCommunity, +} from './utilities/join_community'; +import { sendMessage, waitForMessageStatus } from './utilities/message'; +import { + clickOn, + clickOnWithText, + hasElementBeenDeleted, + hasElementPoppedUpThatShouldnt, + pasteIntoInput, + rightClickOnWithText, + waitForTestIdWithText, +} from './utilities/utils'; + +const banUserString = tStripped('banUser'); +const unbanUserString = tStripped('banUnbanUser'); + +const actionsToDo = ['ban_unban', 'ban_delete_all'] as const; + +actionsToDo.forEach((action) => { + sessionTestTwoWindows(`Community admin ${action}`, async ([alice1, bob1]) => { + assertAdminIsKnown(); + const firstMsgNotBanned = `${action} me! - ${Date.now()}`; + const secondMsgBanned = `I'm banned :( - ${Date.now()}`; + const thirdMsgUnbanned = `Freedom! - ${Date.now()}`; + + const [_alice, bob] = await Promise.all([ + recoverFromSeed(alice1, process.env.SOGS_ADMIN_SEED!, { + fallbackName: 'Admin', + }), + newUser(bob1, 'Bob'), + ]); + await Promise.all([joinOrOpenCommunity(alice1), joinCommunity(bob1)]); + await sendMessage(bob1, firstMsgNotBanned); + await scrollToBottomLookingForMessage({ + window: alice1, + msg: firstMsgNotBanned, + }); + await rightClickOnWithText( + alice1, + Conversation.messageContent, + firstMsgNotBanned, + ); + await clickOnWithText(alice1, Global.contextMenuItem, banUserString, { + strictMode: false, + maxWait: 1_00000, + }); + if (action === 'ban_unban') { + await clickOn(alice1, Conversation.banUserButton); + await pasteIntoInput( + bob1, + Conversation.messageInput.selector, + secondMsgBanned, + ); + await clickOn(bob1, Conversation.sendMessageButton); + await waitForMessageStatus(bob1, secondMsgBanned, 'failed'); + await rightClickOnWithText( + alice1, + Conversation.messageContent, + firstMsgNotBanned, + ); + await clickOnWithText(alice1, Global.contextMenuItem, unbanUserString, { + strictMode: false, + }); + await clickOn(alice1, Conversation.unbanUserButton); + } else { + await clickOn(alice1, Conversation.banAndDeleteAllButton); + await hasElementBeenDeleted(alice1, Conversation.messageContent, { + maxWait: 10_000, + text: firstMsgNotBanned, + }); + // Bob was banned, so he can't send a message + await pasteIntoInput( + bob1, + Conversation.messageInput.selector, + secondMsgBanned, + ); + await clickOn(bob1, Conversation.sendMessageButton); + await waitForMessageStatus(bob1, secondMsgBanned, 'failed'); + await hasElementPoppedUpThatShouldnt( + alice1, + Conversation.messageContent, + secondMsgBanned, + ); + // Alice unban Bob via the convo right click modal (as all messages from Bob have been removed) + await rightClickOnWithText( + alice1, + HomeScreen.conversationItemName, + testCommunityName, + ); + await clickOnWithText(alice1, Global.contextMenuItem, unbanUserString, { + strictMode: false, + }); + await pasteIntoInput( + alice1, + Conversation.unbanUserInput.selector, + bob.accountid, + ); + await clickOn(alice1, Conversation.unbanUserButton); + await waitForTestIdWithText( + alice1, + Global.toast.selector, + tStripped('banUnbanUserUnbanned'), + ); + } + + // here the user has been either + // - ban & unbanned or + // - banned_delete_all & unbanned + // So he should be able to send a message again + await sendMessage(bob1, thirdMsgUnbanned); + await waitForTestIdWithText( + alice1, + Conversation.messageContent.selector, + thirdMsgUnbanned, + ); + }); +}); diff --git a/tests/automation/community_tests.spec.ts b/tests/automation/community_tests.spec.ts index ffb1692..373ad3a 100644 --- a/tests/automation/community_tests.spec.ts +++ b/tests/automation/community_tests.spec.ts @@ -1,34 +1,21 @@ +import { expect } from '@playwright/test'; + import { tStripped } from '../localization/lib'; import { testCommunityName } from './constants/community'; -import { Conversation, Global, HomeScreen } from './locators'; -import { newUser } from './setup/new_user'; -import { recoverFromSeed } from './setup/recovery_using_seed'; -import { - sessionTestTwoWindows, - test_Alice_1W_Bob_1W, - test_Alice_2W, -} from './setup/sessionTest'; -import { - assertAdminIsKnown, - joinCommunity, - joinOrOpenCommunity, -} from './utilities/join_community'; -import { sendMessage, waitForMessageStatus } from './utilities/message'; +import { Global, HomeScreen, LeftPane, Settings } from './locators'; +import { test_Alice_1W_Bob_1W, test_Alice_2W } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; +import { joinCommunity } from './utilities/join_community'; +import { sendMessage } from './utilities/message'; import { replyTo } from './utilities/reply_message'; import { sendMedia } from './utilities/send_media'; import { clickOn, - clickOnWithText, - hasElementBeenDeleted, - hasElementPoppedUpThatShouldnt, - pasteIntoInput, + grabTextFromElement, scrollToBottomIfNecessary, waitForTestIdWithText, } from './utilities/utils'; -const banUserString = tStripped('banUser'); -const unbanUserString = tStripped('banUnbanUser'); - test_Alice_2W( 'Join community and sync', async ({ aliceWindow1, aliceWindow2 }) => { @@ -36,11 +23,8 @@ test_Alice_2W( await scrollToBottomIfNecessary(aliceWindow1); await sendMessage(aliceWindow1, 'Hello, community!'); // Check linked device for community - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - testCommunityName, - ); + + await openConversationWith(aliceWindow2, testCommunityName); }, ); @@ -71,81 +55,74 @@ test_Alice_1W_Bob_1W( }, ); -sessionTestTwoWindows('Ban and unban user', async ([windowA, windowB]) => { - assertAdminIsKnown(); - const msg1 = `Ban me but unban me later! - ${Date.now()}`; - const msg2 = `I'm banned :( - ${Date.now()}`; - const msg3 = `Freedom! - ${Date.now()}`; - await Promise.all([ - recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { - fallbackName: 'Admin', - }), - newUser(windowB, 'Bob'), - ]); - await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); - await sendMessage(windowB, msg1); - await windowA.bringToFront(); - await scrollToBottomIfNecessary(windowA); - await clickOnWithText(windowA, Conversation.messageContent, msg1, { - rightButton: true, - }); - await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { - strictMode: false, - }); - await clickOn(windowA, Conversation.banUserButton); - await pasteIntoInput(windowB, Conversation.messageInput.selector, msg2); - await clickOn(windowB, Conversation.sendMessageButton); - await waitForMessageStatus(windowB, msg2, 'failed'); - await clickOnWithText(windowA, Conversation.messageContent, msg1, { - rightButton: true, - }); - await clickOnWithText(windowA, Global.contextMenuItem, unbanUserString, { - strictMode: false, - }); - await clickOn(windowA, Conversation.unbanUserButton); - await sendMessage(windowB, msg3); - await waitForTestIdWithText( - windowA, - Conversation.messageContent.selector, - msg3, - ); -}); - -sessionTestTwoWindows('Ban And delete all', async ([windowA, windowB]) => { - assertAdminIsKnown(); - const msg1 = `Ban and delete! - ${Date.now()}`; - const msg2 = `Did that work? - ${Date.now()}`; - await Promise.all([ - recoverFromSeed(windowA, process.env.SOGS_ADMIN_SEED!, { - fallbackName: 'Admin', - }), - newUser(windowB, 'Bob'), - ]); - await Promise.all([joinOrOpenCommunity(windowA), joinCommunity(windowB)]); - await sendMessage(windowB, msg1); - await windowA.bringToFront(); - await scrollToBottomIfNecessary(windowA); - await clickOnWithText(windowA, Conversation.messageContent, msg1, { - rightButton: true, - }); - await clickOnWithText(windowA, Global.contextMenuItem, banUserString, { - strictMode: false, - }); - await clickOn(windowA, Conversation.banAndDeleteAllButton); - await hasElementBeenDeleted( - windowA, - Conversation.messageContent.strategy, - Conversation.messageContent.selector, - 10_000, - msg1, - ); - await pasteIntoInput(windowB, Conversation.messageInput.selector, msg2); - await clickOn(windowB, Conversation.sendMessageButton); - await waitForMessageStatus(windowB, msg2, 'failed'); - await hasElementPoppedUpThatShouldnt( - windowA, - Conversation.messageContent.strategy, - Conversation.messageContent.selector, - msg2, - ); -}); +test_Alice_1W_Bob_1W( + 'Community message requests on', + async ({ alice, aliceWindow1, bob, bobWindow1 }) => { + await clickOn(bobWindow1, LeftPane.settingsButton); + await clickOn(bobWindow1, Settings.privacyMenuItem); + await clickOn(bobWindow1, Settings.enableCommunityMessageRequests); + await clickOn(bobWindow1, Global.modalCloseButton); + await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); + const communityMsg = `I accept message requests + ${Date.now()}`; + await sendMessage(bobWindow1, communityMsg); + await scrollToBottomIfNecessary(aliceWindow1); + // Using native methods to locate the author corresponding to the sent message + await aliceWindow1 + .locator('.module-message__container', { hasText: communityMsg }) + .locator('..') // Go up to parent + .locator('svg') + .click(); + const elText = await grabTextFromElement( + aliceWindow1, + 'data-testid', + 'account-id', + ); + expect(elText).toMatch(/^15/); + await clickOn(aliceWindow1, HomeScreen.newMessageAccountIDInput); // yes this is the actual locator for the 'Message' button + await waitForTestIdWithText( + aliceWindow1, + 'header-conversation-name', + bob.userName, + ); + const messageRequestMsg = `${alice.userName} to ${bob.userName}`; + const messageRequestResponse = `${bob.userName} accepts message request`; + await sendMessage(aliceWindow1, messageRequestMsg); + await clickOn(bobWindow1, HomeScreen.messageRequestBanner); + // Select message request from User A + await openConversationWith(bobWindow1, alice.userName); + await sendMessage(bobWindow1, messageRequestResponse); + // Check config message of message request acceptance + await waitForTestIdWithText( + bobWindow1, + 'message-request-response-message', + tStripped('messageRequestYouHaveAccepted', { + name: alice.userName, + }), + ); + }, +); +test_Alice_1W_Bob_1W( + 'Community message requests off', + async ({ aliceWindow1, bobWindow1 }) => { + await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); + const communityMsg = `I do not accept message requests + ${Date.now()}`; + await sendMessage(bobWindow1, communityMsg); + await scrollToBottomIfNecessary(aliceWindow1); + // Using native methods to locate the author corresponding to the sent message + await aliceWindow1 + .locator('.module-message__container', { hasText: communityMsg }) + .locator('..') // Go up to parent + .locator('svg') + .click(); + const elText = await grabTextFromElement( + aliceWindow1, + 'data-testid', + 'account-id', + ); + expect(elText).toMatch(/^15/); + const messageButton = aliceWindow1.getByTestId( + HomeScreen.newMessageAccountIDInput.selector, + ); + await expect(messageButton).toHaveClass(/disabled/); + }, +); diff --git a/tests/automation/constants/variables.ts b/tests/automation/constants/variables.ts index 2151a27..3282c4f 100644 --- a/tests/automation/constants/variables.ts +++ b/tests/automation/constants/variables.ts @@ -10,6 +10,8 @@ export const longText = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum quis lacinia mi. Praesent fermentum vehicula rhoncus. Aliquam ac purus lobortis, convallis nisi quis, pulvinar elit. Nam commodo eros in molestie lobortis. Donec at mattis est. In tempor ex nec velit mattis, vitae feugiat augue maximus. Nullam risus libero, bibendum et enim et, viverra viverra est. Suspendisse potenti. Sed ut nibh in sem rhoncus suscipit. Etiam tristique leo sit amet ullamcorper dictum. Suspendisse sollicitudin, lectus et suscipit eleifend, libero dui ultricies neque, non elementum nulla orci bibendum lorem. Suspendisse potenti. Aenean a tellus imperdiet, iaculis metus quis, pretium diam. Nunc varius vitae enim vestibulum interdum. In hac habitasse platea dictumst. Donec auctor sem quis eleifend fermentum. Vestibulum neque nulla, maximus non arcu gravida, condimentum euismod turpis. Cras ac mattis orci. Quisque ac enim pharetra felis sodales eleifend. Aliquam erat volutpat. Donec sit amet mollis nibh, eget feugiat ipsum. Integer vestibulum purus ac suscipit egestas. Duis vitae aliquet ligula.'; export const screenshotFolder = 'screenshots'; export const testLink = 'https://getsession.org/'; +export const testLinkTitle = + 'Session | Send Messages, Not Metadata. | Private Messenger'; export const mediaArray = [ { diff --git a/tests/automation/delete_account.spec.ts b/tests/automation/delete_account.spec.ts index ce732ad..0e8a661 100644 --- a/tests/automation/delete_account.spec.ts +++ b/tests/automation/delete_account.spec.ts @@ -5,7 +5,7 @@ import { sleepFor } from '../promise_utils'; import { Global, HomeScreen, LeftPane, Onboarding, Settings } from './locators'; import { forceCloseAllWindows } from './setup/closeWindows'; import { newUser } from './setup/new_user'; -import { openApp } from './setup/open'; +import { openAppsAndWaitWindows } from './setup/open'; import { recoverFromSeed } from './setup/recovery_using_seed'; import { sessionTestTwoWindows } from './setup/sessionTest'; import { createContact } from './utilities/create_contact'; @@ -62,7 +62,7 @@ sessionTestTwoWindows( // Wait for window to close and reopen // await windowA.close(); - restoringWindows = await openApp(1); // not using sessionTest here as we need to close and reopen one of the window + restoringWindows = await openAppsAndWaitWindows(1); // not using sessionTest here as we need to close and reopen one of the window const [restoringWindow] = restoringWindows; // Sign in with deleted account and check that nothing restores await clickOn(restoringWindow, Onboarding.iHaveAnAccountButton); @@ -92,18 +92,19 @@ sessionTestTwoWindows( await hasElementBeenDeleted( restoringWindow, - 'data-testid', - HomeScreen.conversationItemName.selector, - 5_000, + HomeScreen.conversationItemName, + { maxWait: 5_000 }, ); await clickOn(restoringWindow, HomeScreen.plusButton); // Expect contacts list to be empty await hasElementBeenDeleted( restoringWindow, - 'data-testid', - Global.contactItem.selector, - 10000, + + Global.contactItem, + { + maxWait: 10_000, + }, ); } finally { if (restoringWindows) { @@ -124,6 +125,8 @@ sessionTestTwoWindows( ]); // Create contact and send new message await createContact(windowA, windowB, userA, userB); + // Allow some time so that Alice gets to push her first config message to the network + await sleepFor(5000, true); // Delete all data from device // Click on settings tab await clickOn(windowA, LeftPane.settingsButton); @@ -134,31 +137,38 @@ sessionTestTwoWindows( tStripped('sessionClearData'), ); // Keep 'Clear Device only' selection + + await clickOnMatchingText(windowA, tStripped('clearDeviceOnly')); // Confirm deletion by clicking Clear, twice await clickOnMatchingText(windowA, tStripped('clear')); await clickOnMatchingText(windowA, tStripped('clear')); - restoringWindows = await openApp(1); + restoringWindows = await openAppsAndWaitWindows(1); const [restoringWindow] = restoringWindows; // Sign in with deleted account and check that nothing restores await recoverFromSeed(restoringWindow, userA.recoveryPassword); await sleepFor(5000, true); // just to allow any messages from our swarm to show up // Check if message from user B is restored - await waitForElement( - restoringWindow, - 'data-testid', - HomeScreen.conversationItemName.selector, - 10000, - userB.userName, - ); + + await waitForElement({ + window: restoringWindow, + locator: HomeScreen.conversationItemName, + options: { + maxWaitMs: 10_000, + shouldLog: true, + text: userB.userName, + }, + }); // Check if contact is available in contacts section await clickOn(restoringWindow, HomeScreen.plusButton); - await waitForElement( - restoringWindow, - 'data-testid', - Global.contactItem.selector, - 1000, - userB.userName, - ); + await waitForElement({ + window: restoringWindow, + locator: Global.contactItem, + options: { + maxWaitMs: 1000, + shouldLog: true, + text: userB.userName, + }, + }); console.log('Contacts have been restored'); } finally { if (restoringWindows) { diff --git a/tests/automation/disappearing_message_checks.spec.ts b/tests/automation/disappearing_message_checks.spec.ts index e8d0e27..865017e 100644 --- a/tests/automation/disappearing_message_checks.spec.ts +++ b/tests/automation/disappearing_message_checks.spec.ts @@ -6,14 +6,11 @@ import { longText, mediaArray, testLink, + testLinkTitle, } from './constants/variables'; -import { - Conversation, - ConversationSettings, - Global, - HomeScreen, -} from './locators'; +import { Conversation, ConversationSettings, Global } from './locators'; import { test_Alice_1W_Bob_1W } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { joinCommunity } from './utilities/join_community'; import { waitForMessageStatus } from './utilities/message'; @@ -93,12 +90,9 @@ mediaArray.forEach( if (mediaType === 'voice') { await waitForTestIdWithText(bobWindow1, 'audio-player'); await sleepFor(30000); - await hasElementBeenDeleted( - bobWindow1, - 'data-testid', - 'audio-player', - 1_000, - ); + await hasElementBeenDeleted(bobWindow1, Conversation.audioPlayer, { + maxWait: 1_000, + }); } else { await waitForTextMessage(bobWindow1, testMessage); // Wait 30 seconds for image to disappear @@ -182,22 +176,21 @@ test_Alice_1W_Bob_1W( ), ]); await sendLinkPreview(aliceWindow1, testLink); - await waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - 3_000, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ); + await waitForElement({ + window: bobWindow1, + locator: Conversation.linkPreviewTitle, + options: { + maxWaitMs: 10_000, + shouldLog: true, + text: testLinkTitle, + }, + }); // Wait 30 seconds for link preview to disappear await sleepFor(30_000); - await hasElementBeenDeleted( - bobWindow1, - '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', - ); + await hasElementBeenDeleted(bobWindow1, Conversation.linkPreviewTitle, { + maxWait: 1_000, // no need to wait too long here, it should have disappeared already + text: testLinkTitle, + }); }, ); @@ -249,38 +242,29 @@ test_Alice_1W_Bob_1W( .getByTestId('modal-close-button') .click(); await clickOn(aliceWindow1, Global.modalCloseButton); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); - await Promise.all([ - waitForElement( - aliceWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, - ), - waitForElement( - bobWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, + + await openConversationWith(aliceWindow1, bob.userName); + await Promise.all( + [aliceWindow1, bobWindow1].map((w) => + waitForElement({ + window: w, + locator: Conversation.communityInvitationDetails, + options: { + maxWaitMs: 15_000, + shouldLog: true, + text: testCommunityName, + }, + }), ), - ]); + ); // Wait 30 seconds for community invite to disappear await sleepFor(30000); await Promise.all( [bobWindow1, aliceWindow1].map((w) => - hasElementBeenDeleted( - w, - 'class', - 'group-name', - 1_000, - testCommunityName, - ), + hasElementBeenDeleted(w, Conversation.communityInvitationDetails, { + maxWait: 1_000, + text: testCommunityName, + }), ), ); }, @@ -319,11 +303,15 @@ test_Alice_1W_Bob_1W( await makeVoiceCall(aliceWindow1, bobWindow1); // In the receivers window, the message is 'Call in progress' await Promise.all([ - waitForTestIdWithText( - bobWindow1, - 'call-notification-answered-a-call', - tStripped('callsInProgress'), - ), + waitForElement({ + window: bobWindow1, + locator: Conversation.callNotificationAnswered, + options: { + text: tStripped('callsInProgress'), + shouldLog: true, + maxWaitMs: 15_000, + }, + }), // In the callers window, the message is 'You called {receiverName}' waitForTestIdWithText( aliceWindow1, @@ -335,19 +323,17 @@ test_Alice_1W_Bob_1W( await sleepFor(30000); await Promise.all([ - hasElementBeenDeleted( - bobWindow1, - 'data-testid', - 'call-notification-answered-a-call', - 1_000, - tStripped('callsInProgress'), - ), + hasElementBeenDeleted(bobWindow1, Conversation.callNotificationAnswered, { + maxWait: 1_000, + text: tStripped('callsInProgress'), + }), hasElementBeenDeleted( aliceWindow1, - 'data-testid', - 'call-notification-started-call', - 1_000, - tStripped('callsYouCalled', { name: bob.userName }), + Conversation.callNotificationStarted, + { + maxWait: 1_000, + text: tStripped('callsYouCalled', { name: bob.userName }), + }, ), ]); }, diff --git a/tests/automation/disappearing_messages.spec.ts b/tests/automation/disappearing_messages.spec.ts index fad880d..e1121ae 100644 --- a/tests/automation/disappearing_messages.spec.ts +++ b/tests/automation/disappearing_messages.spec.ts @@ -1,12 +1,13 @@ import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { defaultDisappearingOptions } from './constants/variables'; -import { Conversation, HomeScreen } from './locators'; +import { Conversation, Global } from './locators'; import { test_Alice_2W, test_Alice_2W_Bob_1W, test_group_Alice_2W_Bob_1W_Charlie_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { sendMessage } from './utilities/message'; import { sendNewMessage } from './utilities/send_message'; @@ -15,7 +16,6 @@ import { clickOn, clickOnElement, clickOnMatchingText, - clickOnWithText, doesTextIncludeString, formatTimeOption, hasElementBeenDeleted, @@ -40,11 +40,7 @@ test_Alice_2W_Bob_1W( // Create Contact await createContact(aliceWindow1, bobWindow1, alice, bob); // Click on conversation in linked device - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow2, bob.userName); await setDisappearingMessages( aliceWindow1, @@ -96,11 +92,7 @@ test_Alice_2W_Bob_1W( await createContact(aliceWindow1, bobWindow1, alice, bob); // Click on conversation in linked device - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow2, bob.userName); await setDisappearingMessages( aliceWindow1, ['1:1', disappearingMessagesType, timeOption, disappearAction], @@ -146,11 +138,7 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( }); const testMessage = 'Testing disappearing messages in groups'; - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); await setDisappearingMessages(aliceWindow1, [ 'group', disappearingMessagesType, @@ -195,11 +183,7 @@ test_Alice_2W( // Open Note to self conversation await sendNewMessage(aliceWindow1, alice.accountid, testMessage); // Check messages are syncing across linked devices - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - tStripped('noteToSelf'), - ); + await openConversationWith(aliceWindow2, tStripped('noteToSelf')); await waitForTextMessage(aliceWindow2, testMessage); // Enable disappearing messages await setDisappearingMessages(aliceWindow1, [ @@ -232,11 +216,7 @@ test_Alice_2W_Bob_1W( const formattedTime = formatTimeOption(timeOption); await createContact(aliceWindow1, bobWindow1, alice, bob); // Click on conversation on linked device - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow2, bob.userName); // Set disappearing messages to on await setDisappearingMessages( aliceWindow1, @@ -299,11 +279,9 @@ test_Alice_2W_Bob_1W( bobWindow1, tStripped('disappearingMessagesFollowSetting'), ); - await clickOnElement({ - window: bobWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); + + await clickOn(bobWindow1, Global.confirmButton); + // Check control message are visible and correct // Each window has two control messages: You turned off and other user turned off (because we're following settings) await Promise.all([ @@ -340,12 +318,9 @@ test_Alice_2W_Bob_1W( ]); await Promise.all( [aliceWindow1, aliceWindow2, bobWindow1].map((w) => - hasElementBeenDeleted( - w, - 'data-testid', - 'disappear-messages-type-and-time', - 1_000, - ), + hasElementBeenDeleted(w, Conversation.DisappearMessagesTypeAndTime, { + maxWait: 1_000, + }), ), ); }, diff --git a/tests/automation/enforce_localized_str.spec.ts b/tests/automation/enforce_localized_str.spec.ts index c8566dd..f51474a 100644 --- a/tests/automation/enforce_localized_str.spec.ts +++ b/tests/automation/enforce_localized_str.spec.ts @@ -61,10 +61,7 @@ function getExpectedStringFromKey( return count === 1 ? 'Message deleted' : 'Messages deleted'; case 'deleteMessage': return count === 1 ? 'Delete Message' : 'Delete Messages'; - case 'deleteMessageConfirm': - return count === 1 - ? 'Are you sure you want to delete this message?' - : 'Are you sure you want to delete these messages?'; + default: return null; } @@ -106,14 +103,16 @@ function getExpectedStringFromKey( return 'Delete'; case 'copy': return 'Copy'; - case 'clearMessagesForEveryone': - return 'Clear for everyone'; + case 'deleteMessageEveryone': + return 'Delete for everyone'; case 'block': return 'Block'; case 'blockBlockedDescription': return 'Unblock this contact to send a message'; case 'attachmentsClickToDownload': return 'Click to download {file_type}'; + case 'banUnbanUserUnbanned': + return 'User unbanned'; case 'media': return 'Media'; case 'file': @@ -126,6 +125,14 @@ function getExpectedStringFromKey( return 'Clear for me'; case 'clearAll': return 'Clear All'; + case 'deleteMessageDeviceOnly': + return 'Delete on this device only'; + case 'clearDeviceOnly': + return 'Clear device only'; + case 'deleteMessageDevicesAll': + return 'Delete on all my devices'; + case 'deleteMessageDeletedLocally': + return 'This message was deleted on this device'; case 'sessionMessageRequests': return 'Message Requests'; case 'done': diff --git a/tests/automation/group_disappearing_messages.spec.ts b/tests/automation/group_disappearing_messages.spec.ts index 20e5fbc..0a58929 100644 --- a/tests/automation/group_disappearing_messages.spec.ts +++ b/tests/automation/group_disappearing_messages.spec.ts @@ -4,7 +4,9 @@ import { longText, mediaArray, testLink, + testLinkTitle, } from './constants/variables'; +import { Conversation } from './locators'; import { test_group_Alice_1W_Bob_1W_Charlie_1W } from './setup/sessionTest'; import { sendMessage } from './utilities/message'; import { @@ -64,7 +66,9 @@ mediaArray.forEach(({ mediaType, path, shouldCheckMediaPreview }) => { await sleepFor(10000); await Promise.all( [bobWindow1, charlieWindow1].map((w) => - hasElementBeenDeleted(w, 'data-testid', 'audio-player', 1_000), + hasElementBeenDeleted(w, Conversation.audioPlayer, { + maxWait: 1_000, + }), ), ); } else { @@ -108,7 +112,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ); test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Send disappearing link to groups', + 'Send disappearing link preview to groups', async ({ aliceWindow1, bobWindow1, charlieWindow1 }) => { await setDisappearingMessages(aliceWindow1, [ 'group', @@ -117,32 +121,27 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( disappearAction, ]); await sendLinkPreview(aliceWindow1, testLink); - await Promise.all([ - waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ), - waitForElement( - charlieWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', + await Promise.all( + [bobWindow1, charlieWindow1].map((w) => + waitForElement({ + window: w, + locator: Conversation.linkPreviewTitle, + options: { + maxWaitMs: 3_000, + shouldLog: true, + text: testLinkTitle, + }, + }), ), - ]); + ); + await sleepFor(30000); await Promise.all( [bobWindow1, charlieWindow1].map((w) => - hasElementBeenDeleted( - w, - 'data-testid', - 'msg-link-preview-title', - 1_000, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ), + hasElementBeenDeleted(w, Conversation.linkPreviewTitle, { + maxWait: 1_000, + text: testLinkTitle, + }), ), ); }, diff --git a/tests/automation/group_testing.spec.ts b/tests/automation/group_testing.spec.ts index 7de72a8..762411b 100644 --- a/tests/automation/group_testing.spec.ts +++ b/tests/automation/group_testing.spec.ts @@ -1,11 +1,6 @@ import { tStripped } from '../localization/lib'; import { doForAll, sleepFor } from '../promise_utils'; -import { - Conversation, - ConversationSettings, - Global, - HomeScreen, -} from './locators'; +import { Conversation, ConversationSettings, Global } from './locators'; import { createGroup } from './setup/create_group'; import { newUser } from './setup/new_user'; import { @@ -13,6 +8,7 @@ import { test_group_Alice_1W_Bob_1W_Charlie_1W, test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { leaveGroup } from './utilities/leave_group'; import { renameGroup } from './utilities/rename_group'; @@ -63,11 +59,8 @@ test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( groupCreated, }) => { await createContact(aliceWindow1, draculaWindow1, alice, dracula); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + + await openConversationWith(aliceWindow1, groupCreated.userName); await clickOnElement({ window: aliceWindow1, strategy: 'data-testid', @@ -94,12 +87,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( }, [aliceWindow1, bobWindow1, charlieWindow1], ); - await clickOn(draculaWindow1, Global.backButton); - await clickOnWithText( - draculaWindow1, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(draculaWindow1, groupCreated.userName); }, ); @@ -117,11 +105,13 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await waitForMatchingText( bobWindow1, tStripped('groupNameNew', { group_name: newGroupName }), + 15_000, ); await clickOnMatchingText(charlieWindow1, newGroupName); await waitForMatchingText( charlieWindow1, tStripped('groupNameNew', { group_name: newGroupName }), + 15_000, ); // Click on conversation options // Check to see that you can't change group name to empty string @@ -164,13 +154,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( // All users open group conversation await Promise.all( - members.map((m) => - clickOnWithText( - m.window, - HomeScreen.conversationItemName, - groupCreated.userName, - ), - ), + members.map((m) => openConversationWith(m.window, groupCreated.userName)), ); // All users type @ to open mentions diff --git a/tests/automation/group_upkeep.spec.ts b/tests/automation/group_upkeep.spec.ts deleted file mode 100644 index 2819042..0000000 --- a/tests/automation/group_upkeep.spec.ts +++ /dev/null @@ -1,63 +0,0 @@ -// FIXME enable this test again once we fixed it -// sessionTestFiveWindows( -// 'Group upkeep - should be skipped', -// async ([windowA, windowB, windowC, windowD, windowE]) => { -// await Promise.all([ -// logIn(windowA, userA.recoveryPhrase), -// logIn(windowB, userB.recoveryPhrase), -// logIn(windowC, userC.recoveryPhrase), -// logIn(windowD, userD.recoveryPhrase), -// logIn(windowE, userE.recoveryPhrase), -// ]); -// // Send message from test users to all of it's contacts to maintain contact status - -// // Send message from user A to Whale(TC1) -// await sendNewMessage( -// windowA, -// userB.sessionid, -// `${userA.userName} -> ${userB.userName}: ${Date.now()}` -// ); -// // Send message from Whale to user A -// await sendNewMessage( -// windowB, -// userA.sessionid, -// `${userB.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// // Send message from user A to Dragon(TC2) -// await sendNewMessage( -// windowA, -// userC.sessionid, -// `${userA.userName} -> ${userC.userName}: ${Date.now()}` -// ); -// // Send message from Dragon to user A -// await sendNewMessage( -// windowC, -// userA.sessionid, -// `${userC.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// // Send message from user A to Fish(TC3) -// await sendNewMessage( -// windowA, -// userD.sessionid, -// `${userA.userName} -> ${userD.userName}: ${Date.now()}` -// ); -// // Send message from Fish to user A -// await sendNewMessage( -// windowD, -// userA.sessionid, -// `${userD.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// // Send message from user A to Gopher(TC4) -// await sendNewMessage( -// windowA, -// userE.sessionid, -// `${userA.userName} -> ${userD.userName}: ${Date.now()}` -// ); -// // Send message from Gopher to user A -// await sendNewMessage( -// windowE, -// userA.sessionid, -// `${userD.userName} -> ${userA.userName} : ${Date.now()}` -// ); -// } -// ); diff --git a/tests/automation/landing_page.spec.ts b/tests/automation/landing_page.spec.ts index a06c9bf..7defbe8 100644 --- a/tests/automation/landing_page.spec.ts +++ b/tests/automation/landing_page.spec.ts @@ -1,4 +1,5 @@ import { tStripped } from '../localization/lib'; +import { Conversation } from './locators'; import { test_Alice_2W } from './setup/sessionTest'; import { hasElementPoppedUpThatShouldnt, @@ -8,10 +9,15 @@ import { test_Alice_2W( `Landing page states`, async ({ aliceWindow1, aliceWindow2 }, _testInfo) => { - await Promise.all([ - waitForElement(aliceWindow1, 'class', 'session-conversation'), - waitForElement(aliceWindow2, 'class', 'session-conversation'), - ]); + await Promise.all( + [aliceWindow1, aliceWindow2].map((w) => + waitForElement({ + window: w, + locator: Conversation.SessionConversation, + options: { maxWaitMs: 1000, shouldLog: true }, + }), + ), + ); // Check that the account created has all the required strings displayed await Promise.all( @@ -23,13 +29,15 @@ test_Alice_2W( tStripped('conversationsNone'), tStripped('onboardingHitThePlusButton'), ].map(async (builder) => - waitForElement( - aliceWindow1, - 'data-testid', - 'empty-msg-view-account-created', - 1000, - builder.toString(), - ), + waitForElement({ + window: aliceWindow1, + locator: Conversation.EmptyMessageViewCreated, + options: { + maxWaitMs: 1_000, + shouldLog: true, + text: builder.toString(), + }, + }), ), ); @@ -39,13 +47,15 @@ test_Alice_2W( tStripped('conversationsNone'), tStripped('onboardingHitThePlusButton'), ].map(async (builder) => - waitForElement( - aliceWindow2, - 'data-testid', - 'empty-msg-view-welcome', - 1000, - builder.toString(), - ), + waitForElement({ + window: aliceWindow2, + locator: Conversation.EmptyMessageViewWelcome, + options: { + maxWaitMs: 1_000, + shouldLog: true, + text: builder.toString(), + }, + }), ), ); @@ -59,9 +69,7 @@ test_Alice_2W( ].map(async (builder) => hasElementPoppedUpThatShouldnt( aliceWindow2, - 'data-testid', - 'empty-msg-view-account-created', - + Conversation.EmptyMessageViewCreated, builder.toString(), ), ), diff --git a/tests/automation/linked_device_group.spec.ts b/tests/automation/linked_device_group.spec.ts index 44aaf96..dfe6772 100644 --- a/tests/automation/linked_device_group.spec.ts +++ b/tests/automation/linked_device_group.spec.ts @@ -9,12 +9,13 @@ import { LeftPane, Settings, } from './locators'; -import { openApp } from './setup/open'; +import { openAppsAndWaitWindows } from './setup/open'; import { recoverFromSeed } from './setup/recovery_using_seed'; import { test_group_Alice_1W_Bob_1W_Charlie_1W, test_group_Alice_2W_Bob_1W_Charlie_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { leaveGroup } from './utilities/leave_group'; import { checkModalStrings, @@ -46,11 +47,8 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( // Check for user A for control message that userC left group // await sleepFor(1000); // Click on group - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow1, groupCreated.userName); + await waitForTestIdWithText( aliceWindow1, 'group-update-message', @@ -59,11 +57,7 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( }), ); // Check for linked device (userA) - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); await waitForTestIdWithText( aliceWindow2, 'group-update-message', @@ -85,7 +79,7 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( test_group_Alice_1W_Bob_1W_Charlie_1W( 'Restore group', async ({ alice, bob, charlie, groupCreated }) => { - const [aliceWindow2] = await openApp(1); + const [aliceWindow2] = await openAppsAndWaitWindows(1); // Check group conversation is in conversation list on linked device // Restore account on a linked device await recoverFromSeed(aliceWindow2, alice.recoveryPassword); @@ -96,11 +90,8 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); + // Check header name await waitForTestIdWithText( aliceWindow2, @@ -166,7 +157,7 @@ async function clearDataOnWindow(window: Page) { test_group_Alice_1W_Bob_1W_Charlie_1W( 'Delete and restore group', async ({ alice, bob, charlie, groupCreated }) => { - const [aliceWindow2] = await openApp(1); + const [aliceWindow2] = await openAppsAndWaitWindows(1); // Check group conversation is in conversation list on linked device // Restore account on a linked device await recoverFromSeed(aliceWindow2, alice.recoveryPassword); @@ -177,11 +168,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(aliceWindow2, groupCreated.userName); // Check header name await waitForTestIdWithText( aliceWindow2, @@ -211,9 +198,9 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ]); await clickOn(aliceWindow2, Global.cancelButton); await clickOn(aliceWindow2, Global.modalCloseButton); - // Delete device data on alicewindow2 + // Delete device data on aliceWindow2 await clearDataOnWindow(aliceWindow2); - const [restoredWindow] = await openApp(1); + const [restoredWindow] = await openAppsAndWaitWindows(1); await recoverFromSeed(restoredWindow, alice.recoveryPassword); // Does group appear? await waitForTestIdWithText( @@ -222,11 +209,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - restoredWindow, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(restoredWindow, groupCreated.userName); // Check header name await waitForTestIdWithText( restoredWindow, @@ -259,7 +242,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( await clickOn(restoredWindow, Global.modalCloseButton); // Delete device data on restoredWindow await clearDataOnWindow(restoredWindow); - const [restoredWindow2] = await openApp(1); + const [restoredWindow2] = await openAppsAndWaitWindows(1); await recoverFromSeed(restoredWindow2, alice.recoveryPassword); // Does group appear? await waitForTestIdWithText( @@ -268,11 +251,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( groupCreated.userName, ); // Check group for members, conversation name and messages - await clickOnWithText( - restoredWindow2, - HomeScreen.conversationItemName, - groupCreated.userName, - ); + await openConversationWith(restoredWindow2, groupCreated.userName); // Check header name await waitForTestIdWithText( restoredWindow2, @@ -336,13 +315,10 @@ test_group_Alice_2W_Bob_1W_Charlie_1W( ); await Promise.all( [aliceWindow1, aliceWindow2].map(async (w) => { - await hasElementBeenDeleted( - w, - 'data-testid', - HomeScreen.conversationItemName.selector, - 10_000, - groupCreated.userName, - ); + await hasElementBeenDeleted(w, HomeScreen.conversationItemName, { + maxWait: 10_000, + text: groupCreated.userName, + }); }), ); }, diff --git a/tests/automation/linked_device_requests.spec.ts b/tests/automation/linked_device_requests.spec.ts index f238a87..4d5a570 100644 --- a/tests/automation/linked_device_requests.spec.ts +++ b/tests/automation/linked_device_requests.spec.ts @@ -8,6 +8,7 @@ import { Settings, } from './locators'; import { test_Alice_2W_Bob_1W } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { sendMessage } from './utilities/message'; import { sendNewMessage } from './utilities/send_message'; import { @@ -28,11 +29,8 @@ test_Alice_2W_Bob_1W( // Accept request in aliceWindow1 await clickOn(aliceWindow1, HomeScreen.messageRequestBanner); await clickOn(aliceWindow2, HomeScreen.messageRequestBanner); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); + await clickOn(aliceWindow1, Conversation.acceptMessageRequestButton); await waitForTestIdWithText( aliceWindow1, @@ -44,10 +42,12 @@ test_Alice_2W_Bob_1W( await waitForMatchingText( aliceWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); await waitForMatchingText( aliceWindow2, tStripped('messageRequestsNonePending'), + 15_000, ); await sendMessage(aliceWindow1, testReply); await waitForTextMessage(bobWindow1, testReply); @@ -68,11 +68,8 @@ test_Alice_2W_Bob_1W( await sendNewMessage(bobWindow1, alice.accountid, testMessage); // Decline request in aliceWindow1 await clickOn(aliceWindow1, HomeScreen.messageRequestBanner); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); + await clickOn(aliceWindow2, HomeScreen.messageRequestBanner); await waitForTestIdWithText( aliceWindow2, @@ -90,13 +87,11 @@ test_Alice_2W_Bob_1W( Global.confirmButton, tStripped('delete'), ); - await waitForMatchingText( - aliceWindow1, - tStripped('messageRequestsNonePending'), - ); - await waitForMatchingText( - aliceWindow2, - tStripped('messageRequestsNonePending'), + + await Promise.all( + [aliceWindow1, aliceWindow2].map((w) => + waitForMatchingText(w, tStripped('messageRequestsNonePending'), 15_000), + ), ); }, ); @@ -110,11 +105,7 @@ test_Alice_2W_Bob_1W( // Check the message request banner appears and click on it await clickOn(aliceWindow1, HomeScreen.messageRequestBanner); // Select message request from Bob - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); // Block Bob await clickOn(aliceWindow1, Conversation.blockMessageRequestButton); // Check modal strings @@ -136,7 +127,7 @@ test_Alice_2W_Bob_1W( Global.contactItem.selector, bob.userName, ); - // Check that the blocked contacts is on alicewindow2 + // Check that the blocked contacts is on aliceWindow2 // Check blocked status in blocked contacts list await clickOn(aliceWindow2, LeftPane.settingsButton); await clickOn(aliceWindow2, Settings.conversationsMenuItem); diff --git a/tests/automation/linked_device_user.spec.ts b/tests/automation/linked_device_user.spec.ts index f1aa10d..38b13f7 100644 --- a/tests/automation/linked_device_user.spec.ts +++ b/tests/automation/linked_device_user.spec.ts @@ -25,17 +25,14 @@ import { clickOn, clickOnElement, clickOnMatchingText, - clickOnTextMessage, clickOnWithText, doWhileWithMax, hasElementBeenDeleted, - hasTextMessageBeenDeleted, pasteIntoInput, + rightClickOnWithText, waitForLoadingAnimationToFinish, waitForMatchingPlaceholder, - waitForMatchingText, waitForTestIdWithText, - waitForTextMessage, } from './utilities/utils'; sessionTestOneWindow('Link a device', async ([aliceWindow1]) => { @@ -177,86 +174,6 @@ test_Alice_2W_Bob_1W( }, ); -test_Alice_2W_Bob_1W( - 'Deleted message syncs', - async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { - const messageToDelete = 'Testing deletion functionality for linked device'; - await createContact(aliceWindow1, bobWindow1, alice, bob); - await sendMessage(aliceWindow1, messageToDelete); - // Navigate to conversation on linked device and for message from user A to user B - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); - await Promise.all([ - waitForTextMessage(aliceWindow2, messageToDelete), - waitForTextMessage(bobWindow1, messageToDelete), - ]); - await clickOnTextMessage(aliceWindow1, messageToDelete, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnWithText( - aliceWindow1, - Global.confirmButton, - tStripped('delete'), - ); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); - await hasTextMessageBeenDeleted(aliceWindow1, messageToDelete, 6_000); - // linked device for deleted message - // Waiting for message to be removed - // Check for linked device - await hasTextMessageBeenDeleted(aliceWindow2, messageToDelete, 30_000); - // Still should exist for user B - await waitForMatchingText(bobWindow1, messageToDelete); - }, -); - -test_Alice_2W_Bob_1W( - 'Unsent message syncs', - async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { - const unsentMessage = 'Testing unsending functionality for linked device'; - await createContact(aliceWindow1, bobWindow1, alice, bob); - await sendMessage(aliceWindow1, unsentMessage); - // Navigate to conversation on linked device and for message from user A to user B - await clickOnWithText( - aliceWindow2, - HomeScreen.conversationItemName, - bob.userName, - ); - await Promise.all([ - waitForTextMessage(aliceWindow2, unsentMessage), - waitForTextMessage(bobWindow1, unsentMessage), - ]); - await clickOnTextMessage(aliceWindow1, unsentMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('clearMessagesForEveryone'), - ); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); - await hasTextMessageBeenDeleted(aliceWindow1, unsentMessage, 1000); - await waitForMatchingText( - bobWindow1, - tStripped('deleteMessageDeletedGlobally'), - ); - // linked device for deleted message - await hasTextMessageBeenDeleted(aliceWindow2, unsentMessage, 5_000); - }, -); - test_Alice_2W_Bob_1W( 'Blocked user syncs', async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { @@ -265,11 +182,10 @@ test_Alice_2W_Bob_1W( await createContact(aliceWindow1, bobWindow1, alice, bob); await sendMessage(aliceWindow1, testMessage); // Navigate to conversation on linked device and check for message from user A to user B - await clickOnWithText( + await rightClickOnWithText( aliceWindow2, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); // Select block await clickOnWithText( @@ -316,7 +232,6 @@ test_Alice_2W_Bob_1W( async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { // Create contact and send new message await createContact(aliceWindow1, bobWindow1, alice, bob); - await clickOn(bobWindow1, Global.backButton); await Promise.all( [aliceWindow1, aliceWindow2, bobWindow1].map((w) => clickOnElement({ @@ -349,11 +264,10 @@ test_Alice_2W_Bob_1W( ), ); // Delete contact - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); await clickOnWithText( aliceWindow1, @@ -374,13 +288,10 @@ test_Alice_2W_Bob_1W( // Need to wait for deletion to propagate to linked device await Promise.all( [aliceWindow1, aliceWindow2].map((w) => - hasElementBeenDeleted( - w, - 'data-testid', - HomeScreen.conversationItemName.selector, - 10_000, - bob.userName, - ), + hasElementBeenDeleted(w, HomeScreen.conversationItemName, { + maxWait: 10_000, + text: bob.userName, + }), ), ); }, @@ -410,11 +321,10 @@ test_Alice_2W( HomeScreen.conversationItemName.selector, tStripped('noteToSelf'), ); - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, tStripped('noteToSelf'), - { rightButton: true }, ); await clickOnWithText( aliceWindow1, @@ -434,20 +344,14 @@ test_Alice_2W( // Check linked device for hidden note to self await sleepFor(1000); await Promise.all([ - hasElementBeenDeleted( - aliceWindow1, - 'data-testid', - HomeScreen.conversationItemName.selector, - 5000, - tStripped('noteToSelf'), - ), - hasElementBeenDeleted( - aliceWindow2, - 'data-testid', - HomeScreen.conversationItemName.selector, - 15_000, - tStripped('noteToSelf'), - ), + hasElementBeenDeleted(aliceWindow1, HomeScreen.conversationItemName, { + maxWait: 5000, + text: tStripped('noteToSelf'), + }), + hasElementBeenDeleted(aliceWindow2, HomeScreen.conversationItemName, { + maxWait: 15_000, + text: tStripped('noteToSelf'), + }), ]); }, ); diff --git a/tests/automation/locators/index.ts b/tests/automation/locators/index.ts index dc19297..10cf87a 100644 --- a/tests/automation/locators/index.ts +++ b/tests/automation/locators/index.ts @@ -70,10 +70,10 @@ export class HomeScreen extends Locator { static readonly conversationItemName = this.testId( 'module-conversation__user__profile-name', ); + static readonly messageRequestBanner = this.testId('message-request-banner'); static readonly pinnedConversationIcon = this.testId( 'conversation-item-pinned', ); - static readonly messageRequestBanner = this.testId('message-request-banner'); static readonly plusButton = this.testId('new-conversation-button'); static readonly revealRecoveryPhraseButton = this.testId( 'reveal-recovery-phrase', @@ -87,6 +87,7 @@ export class Conversation extends Locator { static readonly acceptMessageRequestButton = this.testId( 'accept-message-request', ); + static readonly audioPlayer = this.testId('audio-player'); static readonly banAndDeleteAllButton = this.testId( 'ban-user-delete-all-confirm-button', ); @@ -96,6 +97,15 @@ export class Conversation extends Locator { 'decline-and-block-message-request', ); static readonly callButton = this.testId('call-button'); + static readonly callNotificationAnswered = this.testId( + 'call-notification-answered-a-call', + ); + static readonly callNotificationStarted = this.testId( + 'call-notification-started-call', + ); + static readonly communityInvitationDetails = this.testId( + 'community-invitation-details', + ); static readonly conversationHeader = this.testId('header-conversation-name'); static readonly conversationSettingsIcon = this.testId( 'conversation-options-avatar', @@ -106,20 +116,41 @@ export class Conversation extends Locator { static readonly disappearingControlMessage = this.testId( 'disappear-control-message', ); - static readonly quoteText = this.testId('quote-text'); + static readonly DisappearMessagesTypeAndTime = this.testId( + 'disappear-messages-type-and-time', + ); + static readonly EmptyMessageViewCreated = this.testId( + 'empty-msg-view-account-created', + ); + static readonly EmptyMessageViewWelcome = this.testId( + 'empty-msg-view-welcome', + ); static readonly endCallButton = this.testId('end-call'); static readonly endVoiceMessageButton = this.testId('end-voice-message'); + + static readonly groupName = this.testId('group-name'); + static readonly linkPreviewTitle = this.testId('msg-link-preview-title'); static readonly mentionsContainer = this.testId('mentions-container'); // This is also the locator for emojis static readonly mentionsItem = this.testId('mentions-container-row'); // This is also the locator for emojis static readonly messageContent = this.testId('message-content'); static readonly messageInput = this.testId('message-input-text-area'); + static readonly messageRequestAcceptControlMessage = this.testId( 'message-request-response-message', ); static readonly microphoneButton = this.testId('microphone-button'); + + static readonly quoteText = this.testId('quote-text'); static readonly scrollToBottomButton = this.testId('scroll-to-bottom-button'); static readonly sendMessageButton = this.testId('send-message-button'); + + static readonly SessionConversation = this.className('session-conversation'); + + static readonly tooltipCharacterCount = this.testId( + 'tooltip-character-count', + ); static readonly unbanUserButton = this.testId('unban-user-confirm-button'); + static readonly unbanUserInput = this.testId('unban-user-input'); } export class ConversationSettings extends Locator { diff --git a/tests/automation/message_checks.spec.ts b/tests/automation/message_checks.spec.ts index fa17517..362d98a 100644 --- a/tests/automation/message_checks.spec.ts +++ b/tests/automation/message_checks.spec.ts @@ -1,7 +1,12 @@ import { tStripped } from '../localization/lib'; import { sleepFor } from '../promise_utils'; import { testCommunityName } from './constants/community'; -import { longText, mediaArray, testLink } from './constants/variables'; +import { + longText, + mediaArray, + testLink, + testLinkTitle, +} from './constants/variables'; import { Conversation, ConversationSettings, @@ -14,10 +19,17 @@ import { sessionTestTwoWindows, test_Alice_1W, test_Alice_1W_Bob_1W, + test_Alice_2W, + test_Alice_2W_Bob_1W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { joinCommunity } from './utilities/join_community'; -import { sendMessage } from './utilities/message'; +import { + confirmMessageDeletedFor, + deleteMessageFor, + sendMessage, +} from './utilities/message'; import { replyTo, replyToMedia } from './utilities/reply_message'; import { sendLinkPreview, @@ -25,21 +37,18 @@ import { sendVoiceMessage, trustUser, } from './utilities/send_media'; +import { sendNewMessage } from './utilities/send_message'; import { checkCTAStrings, checkModalStrings, clickOn, clickOnElement, - clickOnMatchingText, - clickOnTextMessage, clickOnWithText, hasElementPoppedUpThatShouldnt, - hasTextMessageBeenDeleted, measureSendingTime, pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, - waitForMatchingText, waitForTestIdWithText, waitForTextMessage, } from './utilities/utils'; @@ -71,8 +80,7 @@ mediaArray.forEach( if (mediaType === 'voice') { await replyToMedia({ senderWindow: bobWindow1, - strategy: 'data-testid', - selector: 'audio-player', + locator: Conversation.audioPlayer, replyText: testReply, receiverWindow: aliceWindow1, }); @@ -114,19 +122,21 @@ test_Alice_1W_Bob_1W( ); test_Alice_1W_Bob_1W( - 'Send link 1:1', + 'Send link preview 1:1', async ({ alice, aliceWindow1, bob, bobWindow1 }) => { const testReply = `${bob.userName} replying to link from ${alice.userName}`; await createContact(aliceWindow1, bobWindow1, alice, bob); await sendLinkPreview(aliceWindow1, testLink); - await waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ); + await waitForElement({ + window: bobWindow1, + locator: Conversation.linkPreviewTitle, + options: { + maxWaitMs: 3_000, + shouldLog: true, + text: testLinkTitle, + }, + }); await replyTo({ senderWindow: bobWindow1, textMessage: testLink, @@ -157,86 +167,101 @@ test_Alice_1W_Bob_1W( .click(); // Close UCS modal await clickOn(aliceWindow1, Global.modalCloseButton); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); - await Promise.all([ - waitForElement( - aliceWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, - ), - waitForElement( - bobWindow1, - 'class', - 'group-name', - undefined, - testCommunityName, + await openConversationWith(aliceWindow1, bob.userName); + await Promise.all( + [aliceWindow1, bobWindow1].map((w) => + waitForElement({ + window: w, + locator: Conversation.communityInvitationDetails, + options: { + maxWaitMs: 15_000, + shouldLog: true, + text: testCommunityName, + }, + }), ), - ]); + ); }, ); -test_Alice_1W_Bob_1W( - 'Unsend message 1:1', - async ({ alice, aliceWindow1, bob, bobWindow1 }) => { - const unsendMessage = 'Testing unsend functionality'; - await createContact(aliceWindow1, bobWindow1, alice, bob); +const delete1o1TypeArray = [ + 'device_only_outgoing', + 'device_only_incoming', + 'for_everyone', +] as const; - await sendMessage(aliceWindow1, unsendMessage); - await waitForTextMessage(bobWindow1, unsendMessage); - await clickOnTextMessage(aliceWindow1, unsendMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('clearMessagesForEveryone'), - ); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); - await sleepFor(1000); - await waitForMatchingText( - bobWindow1, - tStripped('deleteMessageDeletedGlobally'), - ); - }, -); +delete1o1TypeArray.forEach((deleteType) => { + test_Alice_2W_Bob_1W( + `Delete message 1:1 ${deleteType}`, + async ({ alice, aliceWindow1, aliceWindow2, bob, bobWindow1 }) => { + const messageToDelete = `Testing deletion functionality for ${deleteType}`; + await createContact(aliceWindow1, bobWindow1, alice, bob); + await sendMessage(aliceWindow1, messageToDelete); + // Navigate to conversation on linked device and for message from user A to user B + await openConversationWith(aliceWindow2, bob.userName); -test_Alice_1W_Bob_1W( - 'Delete message 1:1', - async ({ alice, aliceWindow1, bob, bobWindow1 }) => { - const deletedMessage = 'Testing deletion functionality'; - await createContact(aliceWindow1, bobWindow1, alice, bob); - await sendMessage(aliceWindow1, deletedMessage); - await waitForTextMessage(bobWindow1, deletedMessage); - await clickOnTextMessage(aliceWindow1, deletedMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); - await hasTextMessageBeenDeleted(aliceWindow1, deletedMessage, 1000); - // Still should exist in window B - await waitForMatchingText(bobWindow1, deletedMessage); - }, -); + await Promise.all([ + waitForTextMessage(aliceWindow2, messageToDelete, 15_000), + waitForTextMessage(bobWindow1, messageToDelete, 15_000), + ]); + + // Alice sent the message, device_only_incoming means getting Bob to delete Alice's message locally. + // Otherwise, it's an action that Alice does on her own message. + + const windowInitiatingDelete = + deleteType === 'device_only_incoming' ? bobWindow1 : aliceWindow1; + const otherWindows = [aliceWindow1, aliceWindow2, bobWindow1].filter( + (w) => w !== windowInitiatingDelete, + ); + + const simplifiedDeleteType = + deleteType === 'device_only_incoming' || + deleteType === 'device_only_outgoing' + ? 'device_only' + : 'for_everyone'; + + await deleteMessageFor( + windowInitiatingDelete, + messageToDelete, + simplifiedDeleteType, + ); + + await confirmMessageDeletedFor({ + deleteType: simplifiedDeleteType, + messageToDelete, + otherWindows, + windowInitiatingDelete, + }); + }, + ); +}); + +const deleteNtsTypeArray = ['device_only', 'for_all_my_devices'] as const; + +deleteNtsTypeArray.forEach((deleteType) => { + test_Alice_2W( + `Delete message NTS ${deleteType}`, + async ({ aliceWindow1, aliceWindow2, alice }) => { + const messageToDelete = `Testing deletion functionality for NTS ${deleteType}`; + await sendNewMessage(aliceWindow1, alice.accountid, messageToDelete); + // Navigate to conversation on linked device + await openConversationWith(aliceWindow2, tStripped('noteToSelf')); + await Promise.all([ + waitForTextMessage(aliceWindow1, messageToDelete, 15_000), + waitForTextMessage(aliceWindow2, messageToDelete, 15_000), + ]); + + await deleteMessageFor(aliceWindow1, messageToDelete, deleteType); + + await confirmMessageDeletedFor({ + deleteType, + messageToDelete, + otherWindows: [aliceWindow2], + windowInitiatingDelete: aliceWindow1, + }); + }, + ); +}); sessionTestTwoWindows( 'Check performance', @@ -300,20 +325,22 @@ messageLengthTestCases.forEach((testCase) => { // Check countdown behavior if (expectedCount) { - await waitForTestIdWithText( - aliceWindow1, - 'tooltip-character-count', - expectedCount, - ); + await waitForElement({ + window: aliceWindow1, + locator: Conversation.tooltipCharacterCount, + options: { text: expectedCount }, + }); } else { // Verify countdown tooltip is not present try { - await waitForElement( - aliceWindow1, - 'data-testid', - 'tooltip-character-count', - 1000, - ); + await waitForElement({ + window: aliceWindow1, + locator: Conversation.tooltipCharacterCount, + options: { + maxWaitMs: 1_000, + shouldLog: true, + }, + }); throw new Error( `Countdown should not be visible for messages under ${countdownThreshold} chars`, ); @@ -406,8 +433,7 @@ test_Alice_1W( ); await hasElementPoppedUpThatShouldnt( aliceWindow1, - Conversation.mentionsContainer.strategy, - Conversation.mentionsContainer.selector, + Conversation.mentionsContainer, ); await pasteIntoInput( aliceWindow1, @@ -416,8 +442,7 @@ test_Alice_1W( ); await hasElementPoppedUpThatShouldnt( aliceWindow1, - Conversation.mentionsContainer.strategy, - Conversation.mentionsContainer.selector, + Conversation.mentionsContainer, ); }, ); @@ -450,8 +475,7 @@ test_Alice_1W( await clickOn(aliceWindow1, Conversation.messageInput); await hasElementPoppedUpThatShouldnt( aliceWindow1, - Conversation.mentionsContainer.strategy, - Conversation.mentionsContainer.selector, + Conversation.mentionsContainer, ); }, ); diff --git a/tests/automation/message_checks_groups.spec.ts b/tests/automation/message_checks_groups.spec.ts index be28226..eb7ffcc 100644 --- a/tests/automation/message_checks_groups.spec.ts +++ b/tests/automation/message_checks_groups.spec.ts @@ -1,8 +1,24 @@ -import { tStripped } from '../localization/lib'; +import type { Page } from '@playwright/test'; + import { sleepFor } from '../promise_utils'; -import { longText, mediaArray, testLink } from './constants/variables'; -import { test_group_Alice_1W_Bob_1W_Charlie_1W } from './setup/sessionTest'; -import { sendMessage } from './utilities/message'; +import { + longText, + mediaArray, + testLink, + testLinkTitle, +} from './constants/variables'; +import { Conversation } from './locators'; +import { + test_group_Alice_1W_Bob_1W_Charlie_1W, + test_group_Alice_2W_Bob_1W_Charlie_1W, +} from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; +import { + confirmMessageDeletedFor, + deleteMessageFor, + type MessageDeleteType, + sendMessage, +} from './utilities/message'; import { replyTo, replyToMedia } from './utilities/reply_message'; import { sendLinkPreview, @@ -10,14 +26,11 @@ import { sendVoiceMessage, } from './utilities/send_media'; import { + assertUnreachable, clickOnElement, - clickOnMatchingText, - clickOnTextMessage, - hasTextMessageBeenDeleted, pasteIntoInput, waitForElement, waitForLoadingAnimationToFinish, - waitForMatchingText, waitForTestIdWithText, waitForTextMessage, } from './utilities/utils'; @@ -68,8 +81,7 @@ mediaArray.forEach(({ mediaType, path, shouldCheckMediaPreview }) => { if (mediaType === 'voice') { await replyToMedia({ senderWindow: bobWindow1, - strategy: 'data-testid', - selector: 'audio-player', + locator: Conversation.audioPlayer, replyText: testReply, receiverWindow: aliceWindow1, }); @@ -119,7 +131,7 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( ); test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Send link to group', + 'Send link preview to group', async ({ alice, bob, @@ -130,22 +142,20 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( }) => { const testReply = `${bob.userName} replying to link from ${alice.userName} in ${groupCreated.userName}`; await sendLinkPreview(aliceWindow1, testLink); - await Promise.all([ - waitForElement( - bobWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', + await Promise.all( + [bobWindow1, charlieWindow1].map((w) => + waitForElement({ + window: w, + locator: Conversation.linkPreviewTitle, + options: { + maxWaitMs: 3_000, + shouldLog: true, + text: testLinkTitle, + }, + }), ), - waitForElement( - charlieWindow1, - 'data-testid', - 'msg-link-preview-title', - undefined, - 'Session | Send Messages, Not Metadata. | Private Messenger', - ), - ]); + ); + await replyTo({ senderWindow: bobWindow1, textMessage: testLink, @@ -155,76 +165,82 @@ test_group_Alice_1W_Bob_1W_Charlie_1W( }, ); -test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Unsend message to group', - async ({ aliceWindow1, bobWindow1, charlieWindow1, groupCreated }) => { - const unsendMessage = `Testing unsend functionality in ${groupCreated.userName}`; - await sendMessage(aliceWindow1, unsendMessage); - await Promise.all([ - waitForTextMessage(bobWindow1, unsendMessage), - waitForTextMessage(charlieWindow1, unsendMessage), - ]); - await clickOnTextMessage(aliceWindow1, unsendMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText( - aliceWindow1, - tStripped('clearMessagesForEveryone'), - ); - // To be implemented in Standardise Message Deletion feature - // await checkModalStrings( - // aliceWindow1, - // tStripped('deleteMessage', { count: 1 }), - // tStripped('deleteMessageConfirm'), - // ); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( +const deleteGroupTypeArray = [ + 'device_only_outgoing', + 'device_only_incoming', + // as normal user, delete one of our own messages + 'for_everyone', + // as an admin, delete someone else message + 'as_admin_for_everyone', +] as const; + +deleteGroupTypeArray.forEach((deleteType) => + test_group_Alice_2W_Bob_1W_Charlie_1W( + `Delete message in group ${deleteType}`, + async ({ aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); - await sleepFor(1000); - await waitForMatchingText( + aliceWindow2, bobWindow1, - tStripped('deleteMessageDeletedGlobally'), - ); - await waitForMatchingText( charlieWindow1, - tStripped('deleteMessageDeletedGlobally'), - ); - }, -); + groupCreated, + bob, + }) => { + // Note: Alice is the admin in this group, Bob is a member without admin rights + const unsendMessageFromBob = `Testing delete ${deleteType} in group from ${bob.userName}`; + // focus the conversation on aliceWindow2 (not done as restored from seed) + await openConversationWith(aliceWindow2, groupCreated.userName); -test_group_Alice_1W_Bob_1W_Charlie_1W( - 'Delete message to group', - async ({ aliceWindow1, bobWindow1, charlieWindow1, groupCreated }) => { - const deletedMessage = `Testing delete message functionality in ${groupCreated.userName}`; - await sendMessage(aliceWindow1, deletedMessage); - await waitForTextMessage(bobWindow1, deletedMessage); - await waitForTextMessage(charlieWindow1, deletedMessage); - await clickOnTextMessage(aliceWindow1, deletedMessage, true); - await clickOnMatchingText(aliceWindow1, tStripped('delete')); - await clickOnMatchingText(aliceWindow1, tStripped('clearMessagesForMe')); - await clickOnElement({ - window: aliceWindow1, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', - }); - await waitForTestIdWithText( - aliceWindow1, - 'session-toast', - tStripped('deleteMessageDeleted', { count: 1 }), - ); - await hasTextMessageBeenDeleted(aliceWindow1, deletedMessage, 5000); - await waitForMatchingText( - aliceWindow1, - tStripped('deleteMessageDeletedGlobally'), - ); - // Should still be there for user B and C - await waitForMatchingText(bobWindow1, deletedMessage); - await waitForMatchingText(charlieWindow1, deletedMessage); - }, + await sendMessage(bobWindow1, unsendMessageFromBob); + await waitForTextMessage( + [aliceWindow1, aliceWindow2, bobWindow1, charlieWindow1], + unsendMessageFromBob, + 15_000, + ); + + let windowInitiatingDelete: Page | undefined; + let fallbackDeleteType: MessageDeleteType | undefined; + switch (deleteType) { + case 'device_only_incoming': + // make Charlie delete Bob's message locally + windowInitiatingDelete = charlieWindow1; + fallbackDeleteType = 'device_only'; + + break; + case 'device_only_outgoing': + case 'for_everyone': + // Bob sent this message, so should be able to delete it both locally and for everyone + windowInitiatingDelete = bobWindow1; + fallbackDeleteType = + deleteType === 'for_everyone' ? 'for_everyone' : 'device_only'; + break; + case 'as_admin_for_everyone': + // Alice (admin) is deleting Bob's message + windowInitiatingDelete = aliceWindow1; + fallbackDeleteType = 'for_everyone'; + break; + default: + assertUnreachable(deleteType, `assertUnreachable for deleteType`); + break; + } + const otherWindows = [ + aliceWindow1, + aliceWindow2, + bobWindow1, + charlieWindow1, + ].filter((m) => m !== windowInitiatingDelete); + + // Bob sent this message, so should be able to delete it locally or for everyone + await deleteMessageFor( + windowInitiatingDelete, + unsendMessageFromBob, + fallbackDeleteType, + ); + await confirmMessageDeletedFor({ + deleteType: fallbackDeleteType, + messageToDelete: unsendMessageFromBob, + windowInitiatingDelete, + otherWindows, + }); + }, + ), ); diff --git a/tests/automation/message_requests.spec.ts b/tests/automation/message_requests.spec.ts index f68f1e9..62b0a11 100644 --- a/tests/automation/message_requests.spec.ts +++ b/tests/automation/message_requests.spec.ts @@ -1,5 +1,3 @@ -import { expect } from '@playwright/test'; - import { tStripped } from '../localization/lib'; import { Conversation, @@ -9,7 +7,7 @@ import { Settings, } from './locators'; import { test_Alice_1W_Bob_1W } from './setup/sessionTest'; -import { joinCommunity } from './utilities/join_community'; +import { openConversationWith } from './utilities/conversation'; import { sendMessage } from './utilities/message'; import { sendNewMessage } from './utilities/send_message'; import { @@ -17,8 +15,6 @@ import { clickOn, clickOnMatchingText, clickOnWithText, - grabTextFromElement, - scrollToBottomIfNecessary, waitForMatchingText, waitForTestIdWithText, } from './utilities/utils'; @@ -33,11 +29,7 @@ test_Alice_1W_Bob_1W( // Check the message request banner appears and click on it await clickOn(bobWindow1, HomeScreen.messageRequestBanner); // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); // Check that using the accept button has intended use await clickOn(bobWindow1, Conversation.acceptMessageRequestButton); // Check config message of message request acceptance @@ -51,6 +43,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); @@ -65,11 +58,8 @@ test_Alice_1W_Bob_1W( // Check the message request banner appears and click on it await clickOn(bobWindow1, HomeScreen.messageRequestBanner); // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); + await sendMessage(bobWindow1, testReply); // Check config message of message request acceptance @@ -83,6 +73,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); @@ -96,11 +87,7 @@ test_Alice_1W_Bob_1W( // Check the message request banner appears and click on it await clickOn(bobWindow1, HomeScreen.messageRequestBanner); // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); await clickOnWithText( bobWindow1, @@ -122,6 +109,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); @@ -155,82 +143,7 @@ test_Alice_1W_Bob_1W( await waitForMatchingText( bobWindow1, tStripped('messageRequestsNonePending'), + 15_000, ); }, ); - -test_Alice_1W_Bob_1W( - 'Community message requests on', - async ({ alice, aliceWindow1, bob, bobWindow1 }) => { - await clickOn(bobWindow1, LeftPane.settingsButton); - await clickOn(bobWindow1, Settings.privacyMenuItem); - await clickOn(bobWindow1, Settings.enableCommunityMessageRequests); - await clickOn(bobWindow1, Global.modalCloseButton); - await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); - const communityMsg = `I accept message requests + ${Date.now()}`; - await sendMessage(bobWindow1, communityMsg); - await scrollToBottomIfNecessary(aliceWindow1); - // Using native methods to locate the author corresponding to the sent message - await aliceWindow1 - .locator('.module-message__container', { hasText: communityMsg }) - .locator('..') // Go up to parent - .locator('svg') - .click(); - const elText = await grabTextFromElement( - aliceWindow1, - 'data-testid', - 'account-id', - ); - expect(elText).toMatch(/^15/); - await clickOn(aliceWindow1, HomeScreen.newMessageAccountIDInput); // yes this is the actual locator for the 'Message' button - await waitForTestIdWithText( - aliceWindow1, - 'header-conversation-name', - bob.userName, - ); - const messageRequestMsg = `${alice.userName} to ${bob.userName}`; - const messageRequestResponse = `${bob.userName} accepts message request`; - await sendMessage(aliceWindow1, messageRequestMsg); - await clickOn(bobWindow1, HomeScreen.messageRequestBanner); - // Select message request from User A - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); - await sendMessage(bobWindow1, messageRequestResponse); - // Check config message of message request acceptance - await waitForTestIdWithText( - bobWindow1, - 'message-request-response-message', - tStripped('messageRequestYouHaveAccepted', { - name: alice.userName, - }), - ); - }, -); -test_Alice_1W_Bob_1W( - 'Community message requests off', - async ({ aliceWindow1, bobWindow1 }) => { - await Promise.all([joinCommunity(aliceWindow1), joinCommunity(bobWindow1)]); - const communityMsg = `I do not accept message requests + ${Date.now()}`; - await sendMessage(bobWindow1, communityMsg); - await scrollToBottomIfNecessary(aliceWindow1); - // Using native methods to locate the author corresponding to the sent message - await aliceWindow1 - .locator('.module-message__container', { hasText: communityMsg }) - .locator('..') // Go up to parent - .locator('svg') - .click(); - const elText = await grabTextFromElement( - aliceWindow1, - 'data-testid', - 'account-id', - ); - expect(elText).toMatch(/^15/); - const messageButton = aliceWindow1.getByTestId( - HomeScreen.newMessageAccountIDInput.selector, - ); - await expect(messageButton).toHaveClass(/disabled/); - }, -); diff --git a/tests/automation/password.spec.ts b/tests/automation/password.spec.ts index 9849d71..47636bd 100644 --- a/tests/automation/password.spec.ts +++ b/tests/automation/password.spec.ts @@ -159,8 +159,7 @@ test_Alice_1W_no_network( await hasElementPoppedUpThatShouldnt( aliceWindow1, - 'data-testid', - Settings.recoveryPasswordContainer.selector, + Settings.recoveryPasswordContainer, recoveryPassword, ); diff --git a/tests/automation/pin_unpin.spec.ts b/tests/automation/pin_unpin.spec.ts index e179754..0fcc2ab 100644 --- a/tests/automation/pin_unpin.spec.ts +++ b/tests/automation/pin_unpin.spec.ts @@ -11,25 +11,24 @@ import { clickOnWithText, getConversationOrder, pasteIntoInput, + rightClickOnWithText, waitForTestIdWithText, } from './utilities/utils'; async function pinConversation(window: Page, conversationName: string) { - await clickOnWithText( + await rightClickOnWithText( window, HomeScreen.conversationItemName, conversationName, - { rightButton: true }, ); await clickOnWithText(window, Global.contextMenuItem, tStripped('pin')); } async function unpinConversation(window: Page, conversationName: string) { - await clickOnWithText( + await rightClickOnWithText( window, HomeScreen.conversationItemName, conversationName, - { rightButton: true }, ); await clickOnWithText(window, Global.contextMenuItem, tStripped('pinUnpin')); } diff --git a/tests/automation/recovery_phrase_banner.spec.ts b/tests/automation/recovery_phrase_banner.spec.ts index 4f9e267..62437eb 100644 --- a/tests/automation/recovery_phrase_banner.spec.ts +++ b/tests/automation/recovery_phrase_banner.spec.ts @@ -18,8 +18,7 @@ async function bannerShouldNotAppear(window: Page) { await waitForTestIdWithText(window, HomeScreen.plusButton.selector); await hasElementPoppedUpThatShouldnt( window, - HomeScreen.revealRecoveryPhraseButton.strategy, - HomeScreen.revealRecoveryPhraseButton.selector, + HomeScreen.revealRecoveryPhraseButton, ); console.log('On home screen, banner did not appear'); } diff --git a/tests/automation/setup/create_group.ts b/tests/automation/setup/create_group.ts index 866079b..0a9b2f8 100644 --- a/tests/automation/setup/create_group.ts +++ b/tests/automation/setup/create_group.ts @@ -4,12 +4,12 @@ import { tStripped } from '../../localization/lib'; import { sortByPubkey } from '../../pubkey'; import { HomeScreen } from '../locators'; import { Group, User } from '../types/testing'; +import { openConversationWith } from '../utilities/conversation'; import { sendMessage } from '../utilities/message'; import { sendNewMessage } from '../utilities/send_message'; import { clickOn, clickOnMatchingText, - clickOnWithText, pasteIntoInput, waitForTestIdWithText, waitForTextMessages, @@ -25,34 +25,34 @@ export const createGroup = async ( windowC: Page, ): Promise => { const group: Group = { userName, userOne, userTwo, userThree }; - const messageAB = `${userOne.userName} to ${userTwo.userName}`; - const messageBA = `${userTwo.userName} to ${userOne.userName}`; - const messageCA = `${userThree.userName} to ${userOne.userName}`; - const messageAC = `${userOne.userName} to ${userThree.userName}`; - const msgAToGroup = `${userOne.userName} -> ${group.userName}`; - const msgBToGroup = `${userTwo.userName} -> ${group.userName}`; - const msgCToGroup = `${userThree.userName} -> ${group.userName}`; - // Add contacts - await sendNewMessage( - windowA, - userThree.accountid, - `${messageAC} Time: ${Date.now()}`, - ); - await sendNewMessage( - windowA, - userTwo.accountid, - `${messageAB} Time: ${Date.now()}`, - ); - await sendNewMessage( - windowB, - userOne.accountid, - `${messageBA} Time: ${Date.now()}`, + + const actionsToDo = [ + { window: windowA, sender: userOne, receivers: [userTwo, userThree] }, + { window: windowB, sender: userTwo, receivers: [userOne, userThree] }, + { window: windowC, sender: userThree, receivers: [userOne, userTwo] }, + ]; + // make everyone a friend of everyone, by sending a message to each other + // Note: we need to do one send per window to avoid race conditions + await Promise.all( + actionsToDo.map(async (action) => + sendNewMessage( + action.window, + action.receivers[0].accountid, + `${action.sender.userName} to ${action.receivers[0].userName}`, + ), + ), ); - await sendNewMessage( - windowC, - userOne.accountid, - `${messageCA} Time: ${Date.now()}`, + // once the first batch is sent, we can start the second batch + await Promise.all( + actionsToDo.map(async (action) => + sendNewMessage( + action.window, + action.receivers[1].accountid, + `${action.sender.userName} to ${action.receivers[1].userName}`, + ), + ), ); + // Click new closed group tab await clickOn(windowA, HomeScreen.plusButton); await clickOn(windowA, HomeScreen.createGroupOption); @@ -86,9 +86,7 @@ export const createGroup = async ( ); // Click on test group await Promise.all( - [windowB, windowC].map((w) => - clickOnWithText(w, HomeScreen.conversationItemName, group.userName), - ), + [windowB, windowC].map((w) => openConversationWith(w, group.userName)), ); // Make sure the empty state is in windowB & windowC await Promise.all([ @@ -105,29 +103,30 @@ export const createGroup = async ( tStripped('groupInviteYouAndOtherNew', { other_name: userTwo.userName }), ), ]); - // Send message in group chat from user A - await sendMessage(windowA, msgAToGroup); - // Focus screen - await clickOnMatchingText(windowA, msgAToGroup); - - // Send message in group chat from user B - await sendMessage(windowB, msgBToGroup); - await clickOnMatchingText(windowB, msgBToGroup); - // Send message from C to the group - await sendMessage(windowC, msgCToGroup); - await clickOnMatchingText(windowC, msgCToGroup); + const msgsSent = await Promise.all( + [ + [windowA, userOne] as const, + [windowB, userTwo] as const, + [windowC, userThree] as const, + ].map(async ([w, u]) => { + const msgToGroup = `${u.userName} to ${group.userName}`; + await sendMessage(w, msgToGroup); + await clickOnMatchingText(w, msgToGroup); + return msgToGroup; + }), + ); // Verify that each messages was received by the other two accounts // windowA should see the message from B and the message from C - await waitForTextMessages(windowA, [msgBToGroup, msgCToGroup]); + await waitForTextMessages(windowA, [msgsSent[1], msgsSent[2]]); // windowB should see the message from A and the message from C - await waitForTextMessages(windowB, [msgAToGroup, msgCToGroup]); + await waitForTextMessages(windowB, [msgsSent[0], msgsSent[2]]); // windowC must see the message from A and the message from B - await waitForTextMessages(windowC, [msgAToGroup, msgBToGroup]); + await waitForTextMessages(windowC, [msgsSent[0], msgsSent[1]]); return { userName, userOne, userTwo, userThree }; }; diff --git a/tests/automation/setup/open.ts b/tests/automation/setup/open.ts index 4df9544..b16c2bd 100644 --- a/tests/automation/setup/open.ts +++ b/tests/automation/setup/open.ts @@ -1,4 +1,7 @@ -import { _electron as electron } from '@playwright/test'; +import { + _electron as electron, + type ElectronApplication, +} from '@playwright/test'; import chalk from 'chalk'; import { isEmpty } from 'lodash'; import { join } from 'path'; @@ -129,8 +132,7 @@ const openElectronAppOnly = async (multi: string, context?: TestContext) => { const logBrowserConsole = process.env.LOG_BROWSER_CONSOLE === '1'; -const openAppAndWait = async (multi: string, context?: TestContext) => { - const electronApp = await openElectronAppOnly(multi, context); +export async function waitFirstWindow(electronApp: ElectronApplication) { // Get the first window that the app opens, wait if necessary. const start = Date.now(); const window = await electronApp.firstWindow(); @@ -146,9 +148,9 @@ const openAppAndWait = async (multi: string, context?: TestContext) => { } }); return window; -}; +} -export async function openApp(windowsToCreate: number, context?: TestContext) { +export async function openApps(windowsToCreate: number, context?: TestContext) { if (windowsToCreate >= multisAvailable.length) { throw new Error(`Do you really need ${multisAvailable.length} windows?!`); } @@ -156,18 +158,29 @@ export async function openApp(windowsToCreate: number, context?: TestContext) { const multisToUse = multisAvailable.slice(0, windowsToCreate); const array = [...multisToUse]; - const toRet = []; + const apps = []; // not too sure why, but launching those windows with Promise.all triggers a sqlite error... for (let index = 0; index < array.length; index++) { - const element = array[index]; + const multi = array[index]; + + const electronApp = await openElectronAppOnly(multi, context); - const openedWindow = await openAppAndWait(`${element}`, context); - toRet.push(openedWindow); + apps.push(electronApp); } console.log( chalk.bgRedBright(`Pathway to app: `, process.env.SESSION_DESKTOP_ROOT), ); - return toRet; + return apps; +} + +export async function openAppsAndWaitWindows( + windowsToCreate: number, + context?: TestContext, +) { + const apps = await openApps(windowsToCreate, context); + + const windows = await Promise.all(apps.map((app) => waitFirstWindow(app))); + return windows; } export function getTrackedElectronPids(): Array { diff --git a/tests/automation/setup/recovery_using_seed.ts b/tests/automation/setup/recovery_using_seed.ts index 3187e24..7dfa79e 100644 --- a/tests/automation/setup/recovery_using_seed.ts +++ b/tests/automation/setup/recovery_using_seed.ts @@ -19,8 +19,7 @@ export async function recoverFromSeed( await waitForLoadingAnimationToFinish(window, 'loading-animation'); const displayNameInput = await doesElementExist( window, - 'data-testid', - 'display-name-input', + Onboarding.displayNameInput, ); if (displayNameInput) { if (!options?.fallbackName) { diff --git a/tests/automation/setup/sessionTest.ts b/tests/automation/setup/sessionTest.ts index bcb92b0..b3129dd 100644 --- a/tests/automation/setup/sessionTest.ts +++ b/tests/automation/setup/sessionTest.ts @@ -8,7 +8,12 @@ import { linkedDevice } from '../utilities/linked_device'; import { forceCloseAllWindows } from './closeWindows'; import { createGroup } from './create_group'; import { newUser } from './new_user'; -import { openApp, resetTrackedElectronPids, TestContext } from './open'; +import { + openApps, + resetTrackedElectronPids, + TestContext, + waitFirstWindow, +} from './open'; // This is not ideal, most of our test needs to open a specific number of windows and close them once the test is done or failed. // This file contains a bunch of utility function to use to open those windows and clean them afterwards. @@ -44,16 +49,17 @@ function sessionTest>( count: T, context?: TestContext, ) { - return test(testName, async ({}, testinfo) => { + return test(testName, async ({}, testInfo) => { resetTrackedElectronPids(); - const windows = await openApp(count, context); + const apps = await openApps(count, context); + const windows = await Promise.all(apps.map((app) => waitFirstWindow(app))); try { if (windows.length !== count) { throw new Error( - `openApp should have opened ${count} windows but did not.`, + `openApps should have opened ${count} windows but did not.`, ); } - await testCallback(windows as N, testinfo); + await testCallback(windows as N, testInfo); } finally { try { await forceCloseAllWindows(windows); @@ -139,8 +145,11 @@ function sessionTestGeneric< ) { const userNames: Tuple = ['Alice', 'Bob', 'Charlie', 'Dracula']; - return test(testName, async ({}, testinfo) => { - const mainWindows = await openApp(userCount, context); + return test(testName, async ({}, testInfo) => { + const mainApps = await openApps(userCount, context); + const mainWindows = await Promise.all( + mainApps.map((app) => waitFirstWindow(app)), + ); const linkedWindows: Array = []; try { @@ -189,7 +198,7 @@ function sessionTestGeneric< ? Group : undefined, }, - testinfo, + testInfo, ); } finally { try { @@ -206,7 +215,7 @@ function sessionTestGeneric< * Used for tests which don't need network (i.e. setting/checking passwords etc) */ export function test_Alice_1W_no_network( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1, testInfo: TestInfo, @@ -214,7 +223,7 @@ export function test_Alice_1W_no_network( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 1, { waitForNetwork: false, context }, ({ mainWindows, users }, testInfo) => { @@ -230,7 +239,7 @@ export function test_Alice_1W_no_network( } export function test_Alice_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1, testInfo: TestInfo, @@ -238,7 +247,7 @@ export function test_Alice_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 1, { waitForNetwork: true, context }, ({ mainWindows, users }, testInfo) => { @@ -258,7 +267,7 @@ export function test_Alice_1W( * - Alice with 2 windows. */ export function test_Alice_2W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & WithAliceWindow2, testInfo: TestInfo, @@ -266,7 +275,7 @@ export function test_Alice_2W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 1, { links: [1], context }, ({ mainWindows, users, linkedWindows }, testInfo) => { @@ -288,7 +297,7 @@ export function test_Alice_2W( * - Bob with 1 window. */ export function test_Alice_1W_Bob_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & WithBob & WithBobWindow1, testInfo: TestInfo, @@ -296,7 +305,7 @@ export function test_Alice_1W_Bob_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 2, { context }, ({ mainWindows, users }, testInfo) => { @@ -319,7 +328,7 @@ export function test_Alice_1W_Bob_1W( * - Bob with 1 window. */ export function test_Alice_2W_Bob_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -331,7 +340,7 @@ export function test_Alice_2W_Bob_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 2, { links: [1], context }, ({ mainWindows, users, linkedWindows }, testInfo) => { @@ -356,7 +365,7 @@ export function test_Alice_2W_Bob_1W( * - Charlie with 1 window. */ export function test_group_Alice_1W_Bob_1W_Charlie_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -370,7 +379,7 @@ export function test_group_Alice_1W_Bob_1W_Charlie_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 3, { grouped: [1, 2, 3], context }, ({ mainWindows, users, groupCreated }, testInfo) => { @@ -397,7 +406,7 @@ export function test_group_Alice_1W_Bob_1W_Charlie_1W( * - Charlie with 1 window. */ export function test_group_Alice_2W_Bob_1W_Charlie_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -412,7 +421,7 @@ export function test_group_Alice_2W_Bob_1W_Charlie_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 3, { grouped: [1, 2, 3], links: [1], context }, ({ mainWindows, users, groupCreated, linkedWindows }, testInfo) => { @@ -441,7 +450,7 @@ export function test_group_Alice_2W_Bob_1W_Charlie_1W( * - Dracula with 1 window, */ export function test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( - testname: string, + testName: string, testCallback: ( details: WithAlice & WithAliceWindow1 & @@ -458,7 +467,7 @@ export function test_group_Alice_1W_Bob_1W_Charlie_1W_Dracula_1W( context?: TestContext, ) { return sessionTestGeneric( - testname, + testName, 4, { grouped: [1, 2, 3], context }, ({ mainWindows, users, groupCreated }, testInfo) => { diff --git a/tests/automation/types/testing.ts b/tests/automation/types/testing.ts index 9942e53..d30dbe0 100644 --- a/tests/automation/types/testing.ts +++ b/tests/automation/types/testing.ts @@ -99,6 +99,7 @@ export type DataTestId = | 'classic-light-themes-settings-menu-item' | 'clear-data-settings-menu-item' | 'clear-group-info-name-button' + | 'community-invitation-details' | 'contact' | 'context-menu-item' | 'continue-button' @@ -132,6 +133,8 @@ export type DataTestId = | 'edit-profile-icon' | 'empty-conversation-control-message' | 'empty-conversation-notification' + | 'empty-msg-view-account-created' + | 'empty-msg-view-welcome' | 'enable-calls-settings-row' | 'enable-communities-message-requests-settings-row' | 'enable-microphone-settings-row' @@ -164,6 +167,7 @@ export type DataTestId = | 'market-cap-amount' | 'mentions-container-row' | 'mentions-container' + | 'message-container' | 'message-content' | 'message-input-text-area' | 'message-request-banner' @@ -177,6 +181,7 @@ export type DataTestId = | 'modal-heading' | 'module-contact-name__profile-name' | 'module-conversation__user__profile-name' + | 'msg-link-preview-title' | 'new-closed-group-name' | 'new-conversation-button' | 'new-session-conversation' @@ -189,6 +194,7 @@ export type DataTestId = | 'password-input-reconfirm' | 'password-input' | 'path-light-container' + | 'path-light-svg' | 'privacy-settings-menu-item' | 'profile-name-input' | 'quote-text' @@ -219,6 +225,7 @@ export type DataTestId = | 'theme-section' | 'tooltip-character-count' | 'unban-user-confirm-button' + | 'unban-user-input' | 'unblock-button-settings-screen' | 'update-group-info-name-input' | 'update-profile-info-name-input' diff --git a/tests/automation/user_actions.spec.ts b/tests/automation/user_actions.spec.ts index 3d9cedd..5e3e30a 100644 --- a/tests/automation/user_actions.spec.ts +++ b/tests/automation/user_actions.spec.ts @@ -9,13 +9,12 @@ import { LeftPane, Settings, } from './locators'; -import { newUser } from './setup/new_user'; import { - sessionTestTwoWindows, test_Alice_1W_Bob_1W, test_Alice_1W_no_network, test_Alice_2W, } from './setup/sessionTest'; +import { openConversationWith } from './utilities/conversation'; import { createContact } from './utilities/create_contact'; import { sendMessage, waitForMessageStatus } from './utilities/message'; import { compareElementScreenshot } from './utilities/screenshot'; @@ -29,6 +28,7 @@ import { doesElementExist, hasElementBeenDeleted, pasteIntoInput, + rightClickOnWithText, waitForLoadingAnimationToFinish, waitForMatchingText, waitForTestIdWithText, @@ -38,41 +38,6 @@ const cancelString = tStripped('cancel'); const saveString = tStripped('save'); const removeString = tStripped('remove'); -// Send message in one to one conversation with new contact -sessionTestTwoWindows('Create contact', async ([windowA, windowB]) => { - // no fixture for that one - const [userA, userB] = await Promise.all([ - newUser(windowA, 'Alice'), - newUser(windowB, 'Bob'), - ]); - await createContact(windowA, windowB, userA, userB); - // Navigate to contacts tab in User B's window - await waitForTestIdWithText( - windowB, - Conversation.messageRequestAcceptControlMessage.selector, - tStripped('messageRequestYouHaveAccepted', { - name: userA.userName, - }), - ); - await clickOn(windowB, Global.backButton); - await Promise.all([ - clickOnElement({ - window: windowA, - strategy: 'data-testid', - selector: 'new-conversation-button', - }), - clickOnElement({ - window: windowB, - strategy: 'data-testid', - selector: 'new-conversation-button', - }), - ]); - await Promise.all([ - waitForTestIdWithText(windowA, Global.contactItem.selector, userB.userName), - waitForTestIdWithText(windowB, Global.contactItem.selector, userA.userName), - ]); -}); - test_Alice_1W_Bob_1W( 'Block user in conversation list', async ({ aliceWindow1, bobWindow1, alice, bob }) => { @@ -88,11 +53,10 @@ test_Alice_1W_Bob_1W( // he is a contact, close the new conversation button tab as there is no right click allowed on it await clickOn(aliceWindow1, Global.backButton); // then right click on the contact conversation list item to show the menu - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); // Select block await clickOnWithText( @@ -136,7 +100,11 @@ test_Alice_1W_Bob_1W( tStripped('blockUnblock'), ); // make sure no blocked contacts are listed - await waitForMatchingText(aliceWindow1, tStripped('blockBlockedNone')); + await waitForMatchingText( + aliceWindow1, + tStripped('blockBlockedNone'), + 1_000, + ); }, ); @@ -242,11 +210,10 @@ test_Alice_1W_Bob_1W( const nickname = 'new nickname for Bob'; await createContact(aliceWindow1, bobWindow1, alice, bob); - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); await clickOnMatchingText(aliceWindow1, tStripped('nicknameSet')); await sleepFor(1000); @@ -295,11 +262,8 @@ test_Alice_1W_Bob_1W( selector: Settings.enableReadReceipts.selector, }); await clickOn(aliceWindow1, Global.modalCloseButton); - await clickOnWithText( - aliceWindow1, - HomeScreen.conversationItemName, - bob.userName, - ); + await openConversationWith(aliceWindow1, bob.userName); + await clickOnElement({ window: bobWindow1, strategy: 'data-testid', @@ -314,12 +278,7 @@ test_Alice_1W_Bob_1W( }); await clickOn(bobWindow1, Global.modalCloseButton); await sendMessage(aliceWindow1, 'Testing read receipts'); - await clickOn(bobWindow1, Global.backButton); - await clickOnWithText( - bobWindow1, - HomeScreen.conversationItemName, - alice.userName, - ); + await openConversationWith(bobWindow1, alice.userName); await waitForMessageStatus(aliceWindow1, 'Testing read receipts', 'read'); }, ); @@ -329,7 +288,6 @@ test_Alice_1W_Bob_1W( async ({ aliceWindow1, bobWindow1, alice, bob }) => { // Create contact and send new message await createContact(aliceWindow1, bobWindow1, alice, bob); - await clickOn(bobWindow1, Global.backButton); await Promise.all( [aliceWindow1, bobWindow1].map((w) => clickOnElement({ @@ -351,15 +309,16 @@ test_Alice_1W_Bob_1W( alice.userName, ), ]); + await Promise.all( [aliceWindow1, bobWindow1].map((w) => clickOn(w, Global.backButton)), ); + // Delete contact - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, bob.userName, - { rightButton: true }, ); await clickOnWithText( aliceWindow1, @@ -377,13 +336,10 @@ test_Alice_1W_Bob_1W( tStripped('delete'), ); // Check if conversation is deleted - await hasElementBeenDeleted( - aliceWindow1, - 'data-testid', - Global.contactItem.selector, - 1000, - bob.userName, - ); + await hasElementBeenDeleted(aliceWindow1, Global.contactItem, { + maxWait: 1000, + text: bob.userName, + }); }, ); @@ -413,11 +369,7 @@ test_Alice_2W( ); // Click yes await clickOnWithText(aliceWindow1, Global.confirmButton, tStripped('yes')); - await doesElementExist( - aliceWindow1, - 'data-testid', - Settings.recoveryPasswordMenuItem.selector, - ); + await doesElementExist(aliceWindow1, Settings.recoveryPasswordMenuItem); // Check linked device if Recovery Password is still visible (it should be) await clickOn(aliceWindow2, LeftPane.settingsButton); await waitForTestIdWithText( @@ -440,10 +392,11 @@ test_Alice_1W_no_network('Invite a friend', async ({ aliceWindow1, alice }) => { ); // Wait for copy to resolve await sleepFor(1000); - await waitForMatchingText(aliceWindow1, tStripped('accountIdCopied')); + await waitForMatchingText(aliceWindow1, tStripped('accountIdCopied'), 1_000); await waitForMatchingText( aliceWindow1, tStripped('shareAccountIdDescriptionCopied'), + 1_000, ); // To exit invite a friend await clickOn(aliceWindow1, Global.backButton); @@ -477,11 +430,10 @@ test_Alice_1W_no_network( Conversation.conversationHeader.selector, tStripped('noteToSelf'), ); - await clickOnWithText( + await rightClickOnWithText( aliceWindow1, HomeScreen.conversationItemName, tStripped('noteToSelf'), - { rightButton: true }, ); await clickOnWithText( aliceWindow1, @@ -498,13 +450,10 @@ test_Alice_1W_no_network( Global.confirmButton, tStripped('hide'), ); - await hasElementBeenDeleted( - aliceWindow1, - 'data-testid', - 'module-conversation__user__profile-name', - 5000, - tStripped('noteToSelf'), - ); + await hasElementBeenDeleted(aliceWindow1, HomeScreen.conversationItemName, { + maxWait: 5000, + text: tStripped('noteToSelf'), + }); }, ); diff --git a/tests/automation/utilities/conversation.ts b/tests/automation/utilities/conversation.ts new file mode 100644 index 0000000..7678184 --- /dev/null +++ b/tests/automation/utilities/conversation.ts @@ -0,0 +1,56 @@ +import type { Page } from '@playwright/test'; + +import { sleepFor } from '../../promise_utils'; +import { Conversation, HomeScreen } from '../locators'; +import { + clickOnWithText, + scrollToBottomIfNecessary, + waitForTestIdWithText, +} from './utils'; + +/** + * Open a conversation from the left pane with the provided name + */ +export async function openConversationWith(window: Page, convoName: string) { + await clickOnWithText(window, HomeScreen.conversationItemName, convoName); +} + +export async function scrollToBottomLookingForMessage({ + window, + msg, +}: { + window: Page; + msg: string; +}) { + // It seems that for communities, we sometimes need to press multiple times the scroll to bottom + // button for the message to be visible. + const start = Date.now(); + do { + try { + await window.bringToFront(); + + await scrollToBottomIfNecessary(window); + const found = await waitForTestIdWithText( + window, + Conversation.messageContent.selector, + msg, + 100, + ); + if (found) { + console.info(`scrollToBottomLookingForMessage: Found message "${msg}"`); + break; + } + } catch (_e) { + // nothing to do here + } + await sleepFor(1000, true); + } while (Date.now() - start < 15_000); + + // this just checks if the message is visible or not after exiting the loop. i.e. this will throw if the message is not visible + await waitForTestIdWithText( + window, + Conversation.messageContent.selector, + msg, + 100, + ); +} diff --git a/tests/automation/utilities/create_contact.ts b/tests/automation/utilities/create_contact.ts index 4b69332..f15d672 100644 --- a/tests/automation/utilities/create_contact.ts +++ b/tests/automation/utilities/create_contact.ts @@ -1,10 +1,7 @@ import { Page } from '@playwright/test'; -import { HomeScreen } from '../locators'; import { User } from '../types/testing'; -import { replyTo } from './reply_message'; import { sendNewMessage } from './send_message'; -import { clickOnElement, clickOnWithText } from './utils'; export const createContact = async ( windowA: Page, @@ -12,31 +9,13 @@ export const createContact = async ( userA: User, userB: User, ) => { + const start = Date.now(); const testMessage = `${userA.userName} to ${userB.userName}`; const testReply = `${userB.userName} to ${userA.userName}`; // User A sends message to User B - await sendNewMessage(windowA, userB.accountid, testMessage); - await clickOnElement({ - window: windowB, - strategy: 'data-testid', - selector: 'message-request-banner', - }); - await clickOnWithText( - windowB, - HomeScreen.conversationItemName, - userA.userName, - ); - await clickOnElement({ - window: windowB, - strategy: 'data-testid', - selector: 'accept-message-request', - }); - // Note: when creating a contact, we want to make sure both sides are friends when we finish this function, - // so passing the windowA here is very important, so we wait for windowA to have received the reply - await replyTo({ - senderWindow: windowB, - textMessage: testMessage, - replyText: testReply, - receiverWindow: windowA, - }); + await Promise.all([ + sendNewMessage(windowA, userB.accountid, testMessage), + sendNewMessage(windowB, userA.accountid, testReply), + ]); + console.warn(`createContact took ${Date.now() - start}ms`); }; diff --git a/tests/automation/utilities/join_community.ts b/tests/automation/utilities/join_community.ts index 2624a10..0f578fd 100644 --- a/tests/automation/utilities/join_community.ts +++ b/tests/automation/utilities/join_community.ts @@ -7,12 +7,14 @@ import { testCommunityName, } from '../constants/community'; import { Global, HomeScreen } from '../locators'; +import { openConversationWith } from './conversation'; import { clickOn, clickOnMatchingText, clickOnWithText, hasElementBeenDeleted, pasteIntoInput, + rightClickOnWithText, waitForLoadingAnimationToFinish, waitForMatchingText, waitForTestIdWithText, @@ -37,7 +39,7 @@ export const joinDefaultCommunity = async ( ) => { await clickOn(window, HomeScreen.plusButton); await clickOn(window, HomeScreen.joinCommunityOption); - await waitForMatchingText(window, communityName); + await waitForMatchingText(window, communityName, 15_000); await clickOnMatchingText(window, communityName); // Deliberately do not wait for loading spinner to finish because this takes forever await waitForTestIdWithText( @@ -48,21 +50,17 @@ export const joinDefaultCommunity = async ( }; export const leaveCommunity = async (window: Page, communityName: string) => { - await clickOnWithText( + await rightClickOnWithText( window, HomeScreen.conversationItemName, communityName, - { rightButton: true }, ); await clickOnWithText(window, Global.contextMenuItem, 'Leave Community'); await clickOn(window, Global.confirmButton); - await hasElementBeenDeleted( - window, - HomeScreen.conversationItemName.strategy, - HomeScreen.conversationItemName.selector, - 5_000, - communityName, - ); + await hasElementBeenDeleted(window, HomeScreen.conversationItemName, { + maxWait: 5_000, + text: communityName, + }); console.log('Left community'); }; @@ -85,11 +83,8 @@ export const joinOrOpenCommunity = async (window: Page) => { ); await clickOn(window, Global.backButton); await clickOn(window, Global.backButton); - await clickOnWithText( - window, - HomeScreen.conversationItemName, - testCommunityName, - ); + + await openConversationWith(window, testCommunityName); } catch (waitError) { // The error message we expected wasn't there, so this is a real failure throw joinError; // Throw the original join error, not the wait timeout diff --git a/tests/automation/utilities/leave_group.ts b/tests/automation/utilities/leave_group.ts index 38d3159..71b8db8 100644 --- a/tests/automation/utilities/leave_group.ts +++ b/tests/automation/utilities/leave_group.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; -import { Conversation, Global } from '../locators'; +import { Conversation, Global, HomeScreen } from '../locators'; import { Group } from '../types/testing'; import { clickOn, @@ -18,11 +18,8 @@ export const leaveGroup = async (window: Page, group: Group) => { // Confirm leave group await clickOnWithText(window, Global.confirmButton, tStripped('leave')); // check config message - await hasElementBeenDeleted( - window, - 'data-testid', - 'module-conversation__user__profile-name', - 5_000, - group.userName, - ); + await hasElementBeenDeleted(window, HomeScreen.conversationItemName, { + maxWait: 5_000, + text: group.userName, + }); }; diff --git a/tests/automation/utilities/linked_device.ts b/tests/automation/utilities/linked_device.ts index 6472182..fa48141 100644 --- a/tests/automation/utilities/linked_device.ts +++ b/tests/automation/utilities/linked_device.ts @@ -1,9 +1,9 @@ -import { openApp } from '../setup/open'; +import { openAppsAndWaitWindows } from '../setup/open'; import { recoverFromSeed } from '../setup/recovery_using_seed'; import { checkPathLight } from './utils'; export async function linkedDevice(recoveryPhrase: string) { - const [window] = await openApp(1); // not using sessionTest here as we need to close and reopen one of the window + const [window] = await openAppsAndWaitWindows(1); // not using sessionTest here as we need to close and reopen one of the window await recoverFromSeed(window, recoveryPhrase); await checkPathLight(window); diff --git a/tests/automation/utilities/message.ts b/tests/automation/utilities/message.ts index c897243..dbfe0d0 100644 --- a/tests/automation/utilities/message.ts +++ b/tests/automation/utilities/message.ts @@ -1,21 +1,46 @@ import { Page } from '@playwright/test'; +import { tStripped } from '../../localization/lib'; +import { sleepFor } from '../../promise_utils'; +import { Global } from '../locators'; import { MessageStatus } from '../types/testing'; -import { clickOnElement, pasteIntoInput } from './utils'; +import { + buildSelectorEscapeText, + checkModalStrings, + clickOn, + clickOnElement, + clickOnMatchingText, + clickOnTextMessage, + hasTextMessageBeenDeleted, + pasteIntoInput, + waitForMatchingText, + waitForTestIdWithText, +} from './utils'; + +export type MessageDeleteType = + | 'device_only' + | 'for_all_my_devices' + | 'for_everyone'; export const waitForMessageStatus = async ( window: Page, message: string, status: MessageStatus, ) => { - const selc = `css=[data-testid=message-content]:has-text("${message}"):has([data-testid=msg-status][data-testtype=${status}])`; + const selector = + buildSelectorEscapeText( + { + strategy: 'data-testid', + selector: 'message-container', + } as const, + message, + ) + `:has([data-testid=msg-status][data-testtype=${status}])`; const logSig = `${status} status of message '${message}'`; - console.info(`waiting for ${logSig}`); - const messageStatus = await window.waitForSelector(selc, { - timeout: 20_000, + const messageStatus = await window.waitForSelector(selector, { + timeout: 20_000, // a gif on mainnet can take a long time to upload }); - console.info(`${logSig} is ${Boolean(messageStatus)}`); + console.info(`${logSig} is ${!!messageStatus}`); }; export const sendMessage = async (window: Page, message: string) => { @@ -29,3 +54,111 @@ export const sendMessage = async (window: Page, message: string) => { }); await waitForMessageStatus(window, message, 'sent'); }; + +export async function deleteMessageFor( + window: Page, + message: string, + deletionType: MessageDeleteType, +) { + await clickOnTextMessage(window, message, true); + await clickOnMatchingText(window, tStripped('delete')); + switch (deletionType) { + case 'device_only': + await clickOnMatchingText(window, tStripped('deleteMessageDeviceOnly')); + break; + case 'for_everyone': + await clickOnMatchingText(window, tStripped('deleteMessageEveryone')); + break; + case 'for_all_my_devices': + await clickOnMatchingText(window, tStripped('deleteMessageDevicesAll')); + break; + } + + await checkModalStrings(window, tStripped('deleteMessage', { count: 1 })); + + await clickOn(window, Global.confirmButton); + + await waitForTestIdWithText( + window, + 'session-toast', + tStripped('deleteMessageDeleted', { count: 1 }), + ); +} + +/** + * Wait 15s and then confirms that all of the windows have the message + * in the expected state, depending on the delete type. + */ +export async function confirmMessageDeletedFor({ + deleteType, + messageToDelete, + otherWindows, + windowInitiatingDelete, +}: { + windowInitiatingDelete: Page; + otherWindows: Array; + messageToDelete: string; + deleteType: MessageDeleteType; +}) { + // explicit wait to make sure a deleted locally that was wrongly deleted globally had time to propagate + await sleepFor(15_000, true); + switch (deleteType) { + case 'device_only': + await Promise.all([ + // the content of the original message should be removed on the device that removed it + hasTextMessageBeenDeleted( + windowInitiatingDelete, + messageToDelete, + 1_000, + ), + // and should have been replaced with a tombstone (local version) + waitForMatchingText( + windowInitiatingDelete, + tStripped('deleteMessageDeletedLocally'), + 1_000, + ), + + // the other devices should have the message still visible + ...otherWindows.map((w) => + waitForMatchingText(w, messageToDelete, 1_000), + ), + ]); + break; + case 'for_everyone': + await Promise.all([ + // all of the devices should have the message content removed + ...[windowInitiatingDelete, ...otherWindows].map((w) => + hasTextMessageBeenDeleted(w, messageToDelete, 1_000), + ), + // all of the devices should have the tombstone shown (global version) + ...[windowInitiatingDelete, ...otherWindows].map((w) => + waitForMatchingText( + w, + tStripped('deleteMessageDeletedGlobally'), + 1_000, + ), + ), + ]); + break; + case 'for_all_my_devices': + // NTS for_all_my_devices does not leave tombstones, it removes the messages completely from all clients + await Promise.all([ + // all of our devices should have the message removed + ...[windowInitiatingDelete, ...otherWindows].map((w) => + hasTextMessageBeenDeleted(w, messageToDelete, 1_000), + ), + // and no tombstones at all + ...[windowInitiatingDelete, ...otherWindows].map((w) => + hasTextMessageBeenDeleted( + w, + tStripped('deleteMessageDeletedGlobally'), + 1_000, + ), + ), + ]); + break; + + default: + break; + } +} diff --git a/tests/automation/utilities/rename_group.ts b/tests/automation/utilities/rename_group.ts index 3501296..8e7ad32 100644 --- a/tests/automation/utilities/rename_group.ts +++ b/tests/automation/utilities/rename_group.ts @@ -27,5 +27,6 @@ export const renameGroup = async ( await waitForMatchingText( window, tStripped('groupNameNew', { group_name: newGroupName }), + 5_000, ); }; diff --git a/tests/automation/utilities/reply_message.ts b/tests/automation/utilities/reply_message.ts index 1591b61..9a00f30 100644 --- a/tests/automation/utilities/reply_message.ts +++ b/tests/automation/utilities/reply_message.ts @@ -3,7 +3,8 @@ 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 { type StrategyExtractionObj } from '../types/testing'; +import { scrollToBottomLookingForMessage } from './conversation'; import { sendMessage } from './message'; import { verifyMediaPreviewLoaded } from './send_media'; import { @@ -36,7 +37,10 @@ export const replyTo = async ({ receiverWindow: Page | null; shouldCheckMediaPreview?: boolean; }) => { - await waitForTextMessage(senderWindow, textMessage); + await scrollToBottomLookingForMessage({ + msg: textMessage, + window: senderWindow, + }); // If the original message has media, verify sender sees it before replying if (shouldCheckMediaPreview) { @@ -81,18 +85,23 @@ export const replyTo = async ({ export const replyToMedia = async ({ replyText, - strategy, - selector, + locator, receiverWindow, senderWindow, }: { replyText: string; - strategy: Strategy; - selector: string; + locator: StrategyExtractionObj; receiverWindow: Page; senderWindow: Page; }) => { - const selc = await waitForElement(senderWindow, strategy, selector); + const selc = await waitForElement({ + window: senderWindow, + locator, + options: { + shouldLog: true, + maxWaitMs: 20_000, + }, + }); // the right click context menu, for some reasons, often doesn't show up on the first try. Let's loop a few times for (let index = 0; index < 5; index++) { diff --git a/tests/automation/utilities/send_media.ts b/tests/automation/utilities/send_media.ts index bc3447b..afb277d 100644 --- a/tests/automation/utilities/send_media.ts +++ b/tests/automation/utilities/send_media.ts @@ -2,6 +2,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; import { sleepFor } from '../../promise_utils'; +import { testLinkTitle } from '../constants/variables'; import { Conversation, Global, Settings } from '../locators'; import { isRunningOnDevNet } from '../setup/open'; import { MediaType } from '../types/testing'; @@ -133,7 +134,9 @@ export const sendLinkPreview = async (window: Page, testLink: string) => { // doesn't pop up if manually typing link (needs to be pasted) await window.keyboard.press(`${controlOrMetaFor()}+A`); await window.keyboard.press(`${controlOrMetaFor()}+X`); + await sleepFor(100); await clickOn(window, Conversation.messageInput); + await sleepFor(100); await window.keyboard.press(`${controlOrMetaFor()}+V`); await checkModalStrings( window, @@ -149,11 +152,7 @@ export const sendLinkPreview = async (window: Page, testLink: string) => { ); } await waitForTestIdWithText(window, 'link-preview-image'); - await waitForTestIdWithText( - window, - 'link-preview-title', - 'Session | Send Messages, Not Metadata. | Private Messenger', - ); + await waitForTestIdWithText(window, 'link-preview-title', testLinkTitle); await clickOnElement({ window, strategy: 'data-testid', diff --git a/tests/automation/utilities/send_message.ts b/tests/automation/utilities/send_message.ts index ba1e194..8f72240 100644 --- a/tests/automation/utilities/send_message.ts +++ b/tests/automation/utilities/send_message.ts @@ -6,13 +6,13 @@ import { clickOn, pasteIntoInput } from './utils'; export const sendNewMessage = async ( window: Page, - sessionid: string, + sessionId: string, message: string, ) => { await clickOn(window, HomeScreen.plusButton); await clickOn(window, HomeScreen.newMessageOption); // Enter session ID of USER B - await pasteIntoInput(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/set_disappearing_messages.ts b/tests/automation/utilities/set_disappearing_messages.ts index 8dab2ff..e0afe5e 100644 --- a/tests/automation/utilities/set_disappearing_messages.ts +++ b/tests/automation/utilities/set_disappearing_messages.ts @@ -1,7 +1,7 @@ import { Page } from '@playwright/test'; import { tStripped } from '../../localization/lib'; -import { Conversation, ConversationSettings } from '../locators'; +import { Conversation, ConversationSettings, Global } from '../locators'; import { ConversationType, DataTestId, @@ -15,7 +15,6 @@ import { clickOnMatchingText, formatTimeOption, waitForElement, - waitForTestIdWithText, } from './utils'; export const setDisappearingMessages = async ( @@ -49,20 +48,33 @@ export const setDisappearingMessages = async ( let defaultTime; if (timerType === 'disappear-after-read-option') { // making explicit DataTestId here as `waitForElement` currently allows a string - // TODO: add explicit typing to waitForElement const dataTestId: DataTestId = 'input-time-option-12-hours'; - defaultTime = await waitForElement(windowA, 'data-testid', dataTestId); + defaultTime = await waitForElement({ + window: windowA, + locator: { + strategy: 'data-testid', + selector: dataTestId, + }, + options: { + maxWaitMs: 1_000, + shouldLog: true, + }, + }); } else { // making explicit DataTestId here as `waitForElement` currently allows a string - // TODO: add explicit typing to waitForElement const dataTestId: DataTestId = 'input-time-option-1-days'; - defaultTime = await waitForElement( - windowA, - 'data-testid', - dataTestId, - 1000, - ); + defaultTime = await waitForElement({ + window: windowA, + locator: { + strategy: 'data-testid', + selector: dataTestId, + }, + options: { + maxWaitMs: 1_000, + shouldLog: true, + }, + }); } const checked = await isChecked(defaultTime); if (checked) { @@ -88,7 +100,10 @@ export const setDisappearingMessages = async ( strategy: 'data-testid', selector: 'modal-close-button', }); - await waitForTestIdWithText(windowA, 'disappear-messages-type-and-time'); + await waitForElement({ + window: windowA, + locator: Conversation.DisappearMessagesTypeAndTime, + }); if (windowB) { await clickOnMatchingText( windowB, @@ -114,11 +129,11 @@ export const setDisappearingMessages = async ( tStripped('disappearingMessagesFollowSetting'), modalDescription, ); - await clickOnElement({ + + await clickOn(windowB, Global.confirmButton); + await waitForElement({ window: windowB, - strategy: 'data-testid', - selector: 'session-confirm-ok-button', + locator: Conversation.DisappearMessagesTypeAndTime, }); - await waitForTestIdWithText(windowB, 'disappear-messages-type-and-time'); } }; diff --git a/tests/automation/utilities/utils.ts b/tests/automation/utilities/utils.ts index 16e8e0a..c76bc05 100644 --- a/tests/automation/utilities/utils.ts +++ b/tests/automation/utilities/utils.ts @@ -22,8 +22,25 @@ type ElementOptions = { strictMode?: boolean; }; +export function escapeText(text: string) { + /* prettier-ignore */ + + return text.replace(/"/g, '\\\"'); +} + +/** + * This function can be used to make sure all the possible values as input of a switch is taken care off, without having a default case. + */ +export function assertUnreachable(_x: never, message: string): never { + const msg = `assertUnreachable: Didn't expect to get here with "${message}"`; + // eslint:disable: no-console + + console.info(msg); + throw new Error(msg); +} + // TODO Unify element interaction functions to use locator objects the way clickOn and clickOnWithText do -// Remaining functions to migrate: waitForElement, pasteIntoInput, grabTextFromElement etc. +// Remaining functions to migrate: pasteIntoInput, grabTextFromElement etc. // WAIT FOR FUNCTIONS @@ -33,46 +50,39 @@ export async function waitForTestIdWithText( text?: string, maxWait?: number, ) { - let builtSelector = `css=[data-testid="${dataTestId}"]`; - if (text) { - // " => \\\" - /* prettier-ignore */ - - const escapedText = text.replace(/"/g, '\\\"'); - - builtSelector += `:has-text("${escapedText}")`; - // console.info('builtSelector:', builtSelector); - // console.info('Text is tiny bubble: ', escapedText); - } - // console.info('looking for selector', builtSelector); + const builtSelector = buildSelectorEscapeText( + { strategy: 'data-testid', selector: dataTestId }, + text, + ); const found = await window.waitForSelector(builtSelector, { timeout: maxWait, }); - // console.info('found selector', builtSelector); return found; } -export async function waitForElement( - window: Page, - strategy: Strategy, - selector: string, - maxWaitMs?: number, - text?: string, -) { - const builtSelector = !text - ? `css=[${strategy}=${selector}]` - : `css=[${strategy}=${selector}]:has-text("${text.replace(/"/g, '\\"')}")`; +export async function waitForElement({ + window, + locator, + options, +}: { + window: Page; + locator: StrategyExtractionObj; + options?: { maxWaitMs?: number; text?: string; shouldLog?: boolean }; +}) { + const builtSelector = buildSelectorEscapeText(locator, options?.text); const start = Date.now(); - if (!selector.includes('path-light-svg')) { - console.log(`waitForElement: ${builtSelector} for maxMs ${maxWaitMs}`); + if (options?.shouldLog) { + console.log( + `waitForElement: ${builtSelector} for maxMs ${options?.maxWaitMs}`, + ); } const el = await window.waitForSelector(builtSelector, { - timeout: maxWaitMs, + timeout: options?.maxWaitMs, }); - if (!selector.includes('path-light-svg')) { + if (options?.shouldLog) { console.log( `waitForElement: got ${builtSelector} after ${Date.now() - start}ms`, ); @@ -82,27 +92,33 @@ export async function waitForElement( } export async function waitForTextMessage( - window: Page, + window: Array | Page, text: string, maxWait?: number, ) { - const escapedText = text.replace(/"/g, '\\"'); - - const builtSelector = `css=[data-testid=message-content]:has-text("${escapedText}")`; + const builtSelector = buildSelectorEscapeText( + { selector: 'message-content', strategy: 'data-testid' }, + text, + ); console.info('waitForTextMessage: builtSelector:', builtSelector); - const el = await window.waitForSelector(builtSelector, { timeout: maxWait }); + const windows = Array.isArray(window) ? window : [window]; + const el = await Promise.all( + windows.map((w) => w.waitForSelector(builtSelector, { timeout: maxWait })), + ); console.info(`Text message found. Text: "${text}"`); - return el; + return el[0]; } export async function waitForTextMessages( - window: Page, + window: Array | Page, texts: Array, maxWait?: number, ) { + const windows = Array.isArray(window) ? window : [window]; + return Promise.all( - texts.map(async (t) => waitForTextMessage(window, t, maxWait)), + texts.map(async (t) => waitForTextMessage(windows, t, maxWait)), ); } @@ -114,32 +130,50 @@ export async function waitForControlMessageWithText( } export async function waitForMatchingText( - window: Page, + window: Array | Page, text: string, - maxWait?: number, + maxWait: number, ) { const builtSelector = `css=:has-text("${text}")`; - const maxTimeout = maxWait ?? 55000; - console.info(`waitForMatchingText: ${text}`); + console.info(`waitForMatchingText: ${text} for maxWait: ${maxWait}ms`); + const start = Date.now(); + + const windows = Array.isArray(window) ? window : [window]; + const found = await Promise.all( + windows.map((w) => w.waitForSelector(builtSelector, { timeout: maxWait })), + ); - return window.waitForSelector(builtSelector, { timeout: maxTimeout }); + console.info( + `waitForMatchingText: found "${text}" in ${Date.now() - start}ms`, + ); + return found[0]; } export async function waitForMatchingPlaceholder( window: Page, - dataTestId: string, + dataTestId: DataTestId, placeholder: string, maxWait: number = 30000, ) { let found = false; const start = Date.now(); console.info( - `waitForMatchingPlaceholder: ${placeholder} with datatestId: ${dataTestId}`, + `waitForMatchingPlaceholder: ${placeholder} with dataTestId: ${dataTestId}`, ); do { try { - const elem = await waitForElement(window, 'data-testid', dataTestId); + const elem = await waitForElement({ + window, + locator: { + strategy: 'data-testid', + selector: dataTestId, + }, + options: { + shouldLog: false, + maxWaitMs: 100, + }, + }); const elemPlaceholder = await elem.getAttribute('placeholder'); if (elemPlaceholder === placeholder) { console.info( @@ -161,7 +195,7 @@ export async function waitForMatchingPlaceholder( if (!found) { throw new Error( - `Failed to find datatestid:"${dataTestId}" with placeholder: "${placeholder}"`, + `Failed to find dataTestId:"${dataTestId}" with placeholder: "${placeholder}"`, ); } } @@ -172,18 +206,38 @@ export async function waitForLoadingAnimationToFinish( ) { let loadingAnimation: ElementHandle | undefined; - await waitForElement(window, 'data-testid', `${loader}`, maxWait); + await waitForElement({ + window, + + locator: { + strategy: 'data-testid', + selector: `${loader}`, + }, + options: { + maxWaitMs: maxWait, + shouldLog: false, + }, + }); + let hasLoggedAlready = false; do { try { - loadingAnimation = await waitForElement( + loadingAnimation = await waitForElement({ window, - 'data-testid', - `${loader}`, - 100, - ); + locator: { + strategy: 'data-testid', + selector: `${loader}`, + }, + options: { + maxWaitMs: 100, + shouldLog: false, + }, + }); await sleepFor(500); - console.info(`${loader} was found, waiting for it to be gone`); + if (!hasLoggedAlready) { + console.info(`${loader} was found, waiting for it to be gone`); + hasLoggedAlready = true; + } } catch (_e) { loadingAnimation = undefined; } @@ -227,12 +281,18 @@ export async function checkPathLight(window: Page, maxWait?: number) { let pathFilter: string | null = null; await doWhileWithMax(maxWaitTime, waitPerLoop, 'checkPathLight', async () => { - const pathLight = await waitForElement( + const pathLight = await waitForElement({ window, - 'data-testid', - 'path-light-svg', - maxWait, - ); + locator: { + strategy: 'data-testid', + selector: 'path-light-svg', + }, + options: { + maxWaitMs: maxWait, + shouldLog: false, + }, + }); + pathFilter = await pathLight.getAttribute('style'); if (Date.now() - start >= maxWaitTime / 10) { @@ -258,6 +318,8 @@ export async function reloadWindow( // ACTIONS +// TODO: convert the clickOn* methods to take destructured args +// like waitForElement does /** * Clicks on an element using a locator object * @param window - Playwright page instance @@ -287,6 +349,21 @@ export async function clickOn( ); } +export function buildSelectorEscapeText( + locator: StrategyExtractionObj, + text?: string, +) { + const strategyWithSelector = + locator.strategy === 'class' + ? `.${locator.selector}` + : `[${locator.strategy}=${locator.selector}]`; + const textSelector = text ? `:has-text("${text.replace(/"/g, '\\"')}")` : ''; + + const builtSelector = `css=${strategyWithSelector}${textSelector}`; + + return builtSelector; +} + /** * Clicks on an element that contains specific text * @param window - Playwright page instance @@ -298,30 +375,51 @@ export async function clickOnWithText( window: Page, locator: StrategyExtractionObj, text: string, - options?: ElementOptions, + options?: Omit, ) { - let builtSelector: string; + const builtSelector = buildSelectorEscapeText(locator, text); - if (locator.strategy === 'class') { - builtSelector = `css=.${locator.selector}:has-text("${text.replace( - /"/g, - '\\"', - )}")`; - } else { - builtSelector = `css=[${locator.strategy}=${ - locator.selector - }]:has-text("${text.replace(/"/g, '\\"')}")`; - } + const sharedOpts = { + timeout: options?.maxWait, + strict: options?.strictMode ?? true, + }; + await window.click(builtSelector, sharedOpts); +} + +export async function rightClickOnWithText( + window: Page, + locator: StrategyExtractionObj, + text: string, + options?: Omit, +) { + const builtSelector = buildSelectorEscapeText(locator, text); const sharedOpts = { timeout: options?.maxWait, strict: options?.strictMode ?? true, + button: 'right' as const, }; - await window.click( - builtSelector, - options?.rightButton ? { ...sharedOpts, button: 'right' } : sharedOpts, + + for (let attempt = 0; attempt < 2; attempt++) { + await window.click(builtSelector, sharedOpts); + // This is a hack, but sometimes the right click makes the window move slightly, and close the context menu. + // So we wait for the context menu to appear (and to stay visible for 100ms), and if it doesn't, we try again. + await sleepFor(100, false); + const menuVisible = await window + .waitForSelector('[data-testid="context-menu-item"]', { timeout: 100 }) + .then(() => true) + .catch(() => false); + + if (menuVisible) { + return; + } + await sleepFor(500, true); + } + throw new Error( + `rightClickOnWithText: context menu never appeared for "${text}"`, ); } + // Legacy wrapper for backwards compatibility export async function clickOnElement({ window, @@ -372,7 +470,10 @@ export async function clickOnTextMessage( rightButton?: boolean, maxWait?: number, ) { - const builtSelector = `css=[data-testid=message-content]:has-text("${text}")`; + const builtSelector = buildSelectorEscapeText( + { selector: 'message-content', strategy: 'data-testid' }, + text, + ); const sharedOpts = { timeout: maxWait }; await window.click( @@ -429,36 +530,62 @@ export async function grabTextFromElement( export async function hasElementBeenDeleted( window: Page, - strategy: Strategy, - selector: string, - maxWait: number, - text?: string, + locator: StrategyExtractionObj, + options: { + maxWait: number; + text?: string; + }, ) { const start = Date.now(); let el: ElementHandle | undefined; + console.info( + `waiting for element to be deleted "${locator.strategy}:${locator.selector}:${options.text}", maxWait: ${options.maxWait}ms`, + ); + let hasLoggedAlready = false; do { try { - el = await waitForElement(window, strategy, selector, maxWait, text); + el = await waitForElement({ + window, + locator, + options: { + maxWaitMs: 100, // the outer loop is the one using the options.maxWait, not this one. + text: options.text, + shouldLog: false, + }, + }); await sleepFor(100); - console.info(`Element has been found, waiting for deletion`); + if (!hasLoggedAlready) { + console.info(`Element has been found, waiting for deletion`); + hasLoggedAlready = true; + } } catch (_e) { el = undefined; console.info(`Element has been deleted, woohoo!`); } - } while (Date.now() - start <= maxWait && el); + } while (Date.now() - start <= options.maxWait && el); try { - el = await waitForElement(window, strategy, selector, 1000, text); + el = await waitForElement({ + window, + locator, + options: { + maxWaitMs: 100, // the element should be there once the loop exits. if it's not right away it's an error. + text: options.text, + shouldLog: false, + }, + }); } catch (_e) { // if we did throw here it's actually because the element is gone, so it's ok } if (el) { throw new Error( - `hasElementBeenDeleted: element with selector ${selector} was expected to be gone but is still there`, + `hasElementBeenDeleted: element with selector ${locator.selector} was expected to be gone but is still there`, ); } - console.info(`Element has been deleted yay`); + console.info( + `Element "${locator.strategy}:${locator.selector}:${options.text}" has been deleted yay`, + ); } export async function hasTextMessageBeenDeleted( @@ -472,13 +599,15 @@ export async function hasTextMessageBeenDeleted( 'waiting for text message to be deleted', async () => { try { - await waitForElement( + await waitForElement({ window, - 'data-testid', - 'message-content', - maxWait, - text, - ); + locator: Conversation.messageContent, + options: { + maxWaitMs: maxWait, + text, + shouldLog: false, + }, + }); return false; } catch (_e) { console.info(`Text message not found, yay!`); @@ -490,15 +619,12 @@ export async function hasTextMessageBeenDeleted( export async function hasElementPoppedUpThatShouldnt( window: Page, - strategy: Strategy, - selector: string, + locator: StrategyExtractionObj, text?: string, ) { - const builtSelector = !text - ? `css=[${strategy}=${selector}]` - : `css=[${strategy}=${selector}]:has-text("${text.replace(/"/g, '\\"')}")`; + const builtSelector = buildSelectorEscapeText(locator, text); - const fakeError = `Found ${selector}, oops..`; + const fakeError = `Found ${locator.selector}, oops..`; const elVisible = await window.isVisible(builtSelector); if (elVisible === true) { throw new Error(fakeError); @@ -508,21 +634,18 @@ export async function hasElementPoppedUpThatShouldnt( export async function doesElementExist( window: Page, - strategy: Strategy, - selector: string, + locator: StrategyExtractionObj, text?: string, ) { - const builtSelector = !text - ? `css=[${strategy}=${selector}]` - : `css=[${strategy}=${selector}]:has-text("${text.replace(/"/g, '\\"')}")`; + const builtSelector = buildSelectorEscapeText(locator, text); - const fakeError = `Element ${selector} does not exist`; + const fakeError = `Element ${locator.selector} does not exist`; const elVisible = await window.isVisible(builtSelector); if (!elVisible) { console.log(fakeError); return undefined; } - console.log(`Element ${selector} exists`); + console.log(`Element ${locator.selector} exists`); return builtSelector; } @@ -565,7 +688,7 @@ function assertTextMatches( export async function checkModalStrings( window: Page, expectedHeading: string, - expectedDescription: string, + expectedDescription?: string, modalId?: ModalId, ) { let modalSelector = '[data-modal-id]'; // Base selector for modals @@ -583,41 +706,33 @@ export async function checkModalStrings( // Get elements within this specific modal const heading = targetModal.locator('[data-testid="modal-heading"]'); - const description = targetModal.locator('[data-testid="modal-description"]'); // Wait for these elements to be visible await heading.waitFor({ state: 'visible' }); - await description.waitFor({ state: 'visible' }); const headingText = await heading.innerText(); - const descriptionText = await description.innerText(); assertTextMatches(headingText, expectedHeading, 'Modal heading'); - assertTextMatches(descriptionText, expectedDescription, 'Modal description'); + if (expectedDescription) { + const description = targetModal.locator( + '[data-testid="modal-description"]', + ); + await description.waitFor({ state: 'visible' }); + const descriptionText = await description.innerText(); + assertTextMatches( + descriptionText, + expectedDescription, + 'Modal description', + ); + } } export async function verifyNoCTAShows(window: Page) { await sleepFor(1_000); // Let the UI settle await Promise.all([ - hasElementPoppedUpThatShouldnt( - window, - CTA.heading.strategy, - CTA.heading.selector, - ), - hasElementPoppedUpThatShouldnt( - window, - CTA.description.strategy, - CTA.description.selector, - ), - hasElementPoppedUpThatShouldnt( - window, - CTA.confirmButton.strategy, - CTA.confirmButton.selector, - ), - hasElementPoppedUpThatShouldnt( - window, - CTA.cancelButton.strategy, - CTA.cancelButton.selector, - ), + hasElementPoppedUpThatShouldnt(window, CTA.heading), + hasElementPoppedUpThatShouldnt(window, CTA.description), + hasElementPoppedUpThatShouldnt(window, CTA.confirmButton), + hasElementPoppedUpThatShouldnt(window, CTA.cancelButton), ]); } @@ -719,8 +834,7 @@ export async function assertUrlIsReachable(url: string): Promise { export async function scrollToBottomIfNecessary(window: Page): Promise { const canScroll = await doesElementExist( window, - Conversation.scrollToBottomButton.strategy, - Conversation.scrollToBottomButton.selector, + Conversation.scrollToBottomButton, ); if (canScroll) { await clickOn(window, Conversation.scrollToBottomButton); diff --git a/tests/localization/lib b/tests/localization/lib index 7e6603e..8ab418c 160000 --- a/tests/localization/lib +++ b/tests/localization/lib @@ -1 +1 @@ -Subproject commit 7e6603e97326cd267eade5667bf43d3a8dcecd22 +Subproject commit 8ab418ca14a512f30ceb84bb69ac48403289c1ed diff --git a/tests/promise_utils.ts b/tests/promise_utils.ts index 45b7f01..239839e 100644 --- a/tests/promise_utils.ts +++ b/tests/promise_utils.ts @@ -1,12 +1,25 @@ import { Page } from '@playwright/test'; +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + export const sleepFor = async (ms: number, showLog = false) => { if (showLog || ms > 5000) { console.info(`sleeping for ${ms}ms...`); + + if (ms > 5000) { + const chunks = 6; + const msPerChunk = Math.floor(ms / chunks); + for (let index = 0; index < chunks; index++) { + await sleep(msPerChunk); + console.info(`slept for ${msPerChunk * (index + 1)}/${ms}ms...`); + } + + return; + } } - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); + return sleep(ms); }; export async function doForAll( diff --git a/tuiReporter.ts b/tuiReporter.ts index c5c3a68..e4580ad 100644 --- a/tuiReporter.ts +++ b/tuiReporter.ts @@ -16,18 +16,16 @@ import { TerminalTui } from './terminalTui'; type TestAndResult = { test: TestCase; result: TestResult }; class TuiReporter implements Reporter { - private tui = new TerminalTui(); private allResults: Array = []; + private allTests: TestCase[] = []; private allTestsCount = 0; private countWorkers = 1; private startTime = 0; - - printsToStdio(): boolean { - return true; - } + private tui = new TerminalTui(); onBegin(config: FullConfig, suite: Suite) { - this.allTestsCount = suite.allTests().length; + this.allTests = suite.allTests(); + this.allTestsCount = this.allTests.length; this.countWorkers = config.workers; this.startTime = Date.now(); @@ -39,13 +37,59 @@ class TuiReporter implements Reporter { process.exit(1); }); - for (const test of suite.allTests()) { + for (const test of this.allTests) { this.tui.addTest(test.id, test.title); } this.tui.setProgress(0, this.allTestsCount, 0); } + 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(); + } + + 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'); + } + + 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); + } + } + + 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); + } + } + onTestBegin(test: TestCase, result: TestResult) { const status = result.retry > 0 ? 'retrying' : 'running'; if (result.retry > 0) { @@ -81,50 +125,8 @@ class TuiReporter implements Reporter { 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(); + printsToStdio(): boolean { + return true; } private printSummary() { @@ -170,9 +172,9 @@ class TuiReporter implements Reporter { } // Tests that never finished (still running/pending when stopped) - const finishedTitles = new Set(Object.keys(grouped)); - - const cancelledCount = this.allTestsCount - finishedTitles.size; + const finishedIds = new Set(this.allResults.map((r) => r.test.id)); + const cancelledTests = this.allTests.filter((t) => !finishedIds.has(t.id)); + const cancelledCount = cancelledTests.length; // Summary line const parts: string[] = []; if (passedCount > 0) @@ -200,14 +202,6 @@ class TuiReporter implements Reporter { })`, ), ); - 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(''); } @@ -227,6 +221,15 @@ class TuiReporter implements Reporter { console.log(''); } + // Cancelled details + if (cancelledTests.length > 0) { + console.log(chalk.dim.bold(' Cancelled:')); + for (const test of sortBy(cancelledTests, (t) => t.title)) { + console.log(chalk.dim(` \u25a0 ${test.title}`)); + } + console.log(''); + } + // Duration const totalMs = Date.now() - this.startTime; const mins = Math.floor(totalMs / 60000);