import {randomUUID} from 'node:crypto' import {unlink} from 'node:fs/promises' import {test as base, expect, type Page, type TestInfo} from '@playwright/test' export type LNbitsE2EServer = { baseUrl: string password: string username: string } const server: LNbitsE2EServer = { baseUrl: process.env.LNBITS_E2E_BASE_URL ?? 'http://127.0.0.1:5009', username: 'superadmin', password: 'secret1234' } const detailedScreenshotStates = new WeakMap< TestInfo, DetailedScreenshotState >() const DETAILED_SCREENSHOT_SCRIPT = ` (() => { if (window.__lnbitsDetailedScreenshotsInstalled) return window.__lnbitsDetailedScreenshotsInstalled = true let nextSnapshot = 0 let scanScheduled = false const visibleElements = new WeakMap() const notify = payload => { setTimeout(() => { try { window.__lnbitsDetailedScreenshot({ ...payload, snapshotId: payload.snapshotId || (payload.kind + '-' + Date.now() + '-' + ++nextSnapshot), url: payload.url || window.location.href }) } catch (_error) {} }, 0) } const notifyUrl = () => { notify({ kind: 'url', label: window.location.href, url: window.location.href }) } for (const name of ['pushState', 'replaceState']) { const original = window.history[name] window.history[name] = function (...args) { const result = original.apply(this, args) setTimeout(notifyUrl, 0) return result } } window.addEventListener('hashchange', () => setTimeout(notifyUrl, 0)) window.addEventListener('popstate', () => setTimeout(notifyUrl, 0)) window.addEventListener('DOMContentLoaded', () => setTimeout(notifyUrl, 0)) setTimeout(notifyUrl, 0) const groups = [ { kind: 'dialog', selector: '.q-dialog, [role="dialog"]' }, { kind: 'toast', selector: '.q-notification' } ] const isVisible = element => { if (!(element instanceof HTMLElement)) return false if (element.getAttribute('aria-hidden') === 'true') return false if (element.classList.contains('q-dialog--hidden')) return false const style = window.getComputedStyle(element) if ( style.display === 'none' || style.visibility === 'hidden' || style.opacity === '0' ) { return false } const rect = element.getBoundingClientRect() return rect.width > 0 && rect.height > 0 } const textFor = element => ( element.getAttribute('aria-label') || element.innerText || element.textContent || '' ).replace(/\\s+/g, ' ').trim().slice(0, 120) const labelFor = element => { const headings = element.querySelectorAll([ '[role="heading"]', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', '.text-h1', '.text-h2', '.text-h3', '.text-h4', '.text-h5', '.text-h6' ].join(', ')) for (const heading of headings) { if (isVisible(heading)) { const headingText = textFor(heading) if (headingText) return headingText } } return textFor(element) } const scan = () => { for (const group of groups) { for (const element of document.querySelectorAll(group.selector)) { if (isVisible(element)) { const label = labelFor(element) if (visibleElements.get(element) !== label) { visibleElements.set(element, label) notify({ kind: group.kind, label, url: window.location.href }) } } else { visibleElements.delete(element) } } } } const scheduleScan = () => { if (scanScheduled) return scanScheduled = true requestAnimationFrame(() => { scanScheduled = false scan() }) } const installObserver = () => { if (!document.documentElement) return new MutationObserver(scheduleScan).observe(document.documentElement, { attributes: true, attributeFilter: ['aria-hidden', 'class', 'style'], childList: true, subtree: true }) scheduleScan() } window.addEventListener('DOMContentLoaded', installObserver) setTimeout(installObserver, 0) })() ` export const test = base.extend< {}, { lnbitsServer: LNbitsE2EServer } >({ lnbitsServer: [ async ({}, use) => { await completeFirstInstall(server) await use(server) }, {scope: 'worker'} ], page: async ({page, lnbitsServer}, use, testInfo) => { void lnbitsServer await page.addInitScript( "window.localStorage.setItem('lnbits.disclaimerShown', 'true')" ) const recorder = await installDetailedScreenshots(page, testInfo) page.setDefaultTimeout(60_000) let failed = false try { await use(page) } catch (error) { failed = true throw error } finally { await recorder.finish({ retain: failed || testInfo.status !== testInfo.expectedStatus }) } } }) export {expect} async function completeFirstInstall(e2eServer: LNbitsE2EServer): Promise { const deadline = Date.now() + 90_000 let lastError = '' while (Date.now() < deadline) { try { const response = await requestJson( `${e2eServer.baseUrl}/api/v1/auth/first_install`, { method: 'PUT', data: { username: e2eServer.username, password: e2eServer.password, password_repeat: e2eServer.password, first_install_token: '' }, timeoutMs: 2_000 } ) if (response.status === 200) return if ( response.status === 403 && isRecord(response.body) && response.body.detail === 'This is not your first install' ) { return } lastError = `${response.status}: ${JSON.stringify(response.body)}` } catch (error) { lastError = String(error) } await delay(500) } throw new Error( `LNbits e2e server did not complete first install. Last error: ${lastError}` ) } type RequestJsonOptions = { apiKey?: string data?: Record method: string timeoutMs?: number } export async function apiJson( baseUrl: string, method: string, path: string, data?: Record, apiKey?: string, timeoutMs = 30_000 ): Promise> { const response = await requestJson(`${baseUrl}${path}`, { method, data, apiKey, timeoutMs }) if (response.status < 200 || response.status >= 300) { throw new Error( `${method} ${path} failed with ${response.status}: ${JSON.stringify(response.body)}` ) } if (!isRecord(response.body)) { throw new Error( `${method} ${path} returned non-object JSON: ${JSON.stringify(response.body)}` ) } return response.body } async function requestJson( url: string, {method, data, apiKey, timeoutMs = 30_000}: RequestJsonOptions ): Promise<{body: unknown; status: number}> { const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), timeoutMs) try { const response = await fetch(url, { method, headers: { 'Content-Type': 'application/json', ...(apiKey ? {'X-Api-Key': apiKey} : {}) }, body: data === undefined ? undefined : JSON.stringify(data), signal: controller.signal }) const text = await response.text() let body: unknown = {} try { body = text ? JSON.parse(text) : {} } catch (_error) { body = {detail: text} } return {status: response.status, body} } finally { clearTimeout(timeout) } } export function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } export async function browserJson( page: Page, method: string, path: string, data?: Record, apiKey?: string ): Promise { const response = await page.evaluate( async ({method, path, data, apiKey}) => { const headers: Record = { 'Content-Type': 'application/json' } if (apiKey) headers['X-Api-Key'] = apiKey const response = await fetch(path, { method, headers, credentials: 'same-origin', body: data === undefined ? undefined : JSON.stringify(data) }) const text = await response.text() let body: unknown = {} try { body = text ? JSON.parse(text) : {} } catch (_error) { body = {detail: text} } return {status: response.status, body} }, {method, path, data, apiKey} ) if (response.status < 200 || response.status >= 300) { throw new Error( `${method} ${path} failed with ${response.status}: ${JSON.stringify(response.body)}` ) } return response.body } export async function waitForResult( description: string, callback: () => Promise, {timeout = 30_000, interval = 500}: {interval?: number; timeout?: number} = {} ): Promise { const deadline = Date.now() + timeout let lastResult: T | null | undefined let lastError: unknown while (Date.now() < deadline) { try { lastError = undefined lastResult = await callback() if (lastResult !== null && lastResult !== undefined) return lastResult } catch (error) { lastError = error } await delay(interval) } throw new Error( `Timed out waiting for ${description}. Last result: ${JSON.stringify(lastResult)}. Last error: ${String(lastError)}` ) } export function delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)) } export function randomHex(): string { return randomUUID().replace(/-/g, '').slice(0, 8) } export type DetailedScreenshotHandle = { finish(options?: {retain?: boolean}): Promise } export async function installDetailedScreenshots( page: Page, testInfo: TestInfo ): Promise { const recorder = new DetailedScreenshotRecorder(page, testInfo) await recorder.install() return recorder } type DetailedScreenshotPayload = { kind?: unknown label?: unknown snapshotId?: unknown url?: unknown } type DetailedScreenshotRequest = { kind: 'dialog' | 'toast' | 'url' label: string snapshotId: string url: string } type DetailedScreenshotCapture = { name: string path: string } type DetailedScreenshotState = { captures: DetailedScreenshotCapture[] finalized: boolean screenshotIndex: number } class DetailedScreenshotRecorder { private readonly capturedUrlKeys = new Set() private readonly page: Page private readonly pending: DetailedScreenshotRequest[] = [] private readonly testInfo: TestInfo private flushPromise?: Promise private flushTimer?: ReturnType private lastQueuedKey = '' private readonly state: DetailedScreenshotState private stopped = false constructor(page: Page, testInfo: TestInfo) { this.page = page this.testInfo = testInfo this.state = detailedScreenshotStateFor(testInfo) } async install(): Promise { await this.page.exposeBinding( '__lnbitsDetailedScreenshot', (_source, payload: DetailedScreenshotPayload) => { this.queue(this.requestFromPayload(payload)) } ) await this.page.addInitScript(DETAILED_SCREENSHOT_SCRIPT) this.page.on('framenavigated', frame => { const url = frame.url() this.queue({ kind: 'url', label: url, snapshotId: `frame-${Date.now()}`, url }) }) } async finish(options: {retain?: boolean} = {}): Promise { this.queue({ kind: 'url', label: this.page.url(), snapshotId: `final-${Date.now()}`, url: this.page.url() }) this.stopped = true if (this.flushTimer) { clearTimeout(this.flushTimer) this.flushTimer = undefined } await this.flush() if (typeof options.retain === 'boolean') { await finalizeDetailedScreenshots(this.testInfo, options.retain) } } private requestFromPayload( payload: DetailedScreenshotPayload ): DetailedScreenshotRequest | null { const kind = typeof payload.kind === 'string' ? payload.kind : '' if (!['dialog', 'toast', 'url'].includes(kind)) return null const url = typeof payload.url === 'string' && payload.url ? payload.url : this.page.url() const label = typeof payload.label === 'string' && payload.label ? payload.label : kind const snapshotId = typeof payload.snapshotId === 'string' && payload.snapshotId ? payload.snapshotId : `${kind}-${Date.now()}` return { kind: kind as DetailedScreenshotRequest['kind'], label, snapshotId, url } } private queue(request: DetailedScreenshotRequest | null): void { if (this.stopped || !request) return if (!request.url || request.url === 'about:blank') return const key = request.kind === 'url' ? `${request.kind}:${request.url}` : `${request.kind}:${request.snapshotId}:${request.url}:${request.label}` if (request.kind === 'url' && this.capturedUrlKeys.has(key)) return if (key === this.lastQueuedKey) return this.lastQueuedKey = key this.pending.push(request) this.scheduleFlush() } private scheduleFlush(): void { if (this.flushTimer) return this.flushTimer = setTimeout(() => { this.flushTimer = undefined void this.flush() }, 75) } private async flush(): Promise { if (this.flushPromise) { await this.flushPromise return } this.flushPromise = this.flushQueue() try { await this.flushPromise } finally { this.flushPromise = undefined } } private async flushQueue(): Promise { while (this.pending.length) { const request = this.pending.shift() if (!request) continue await this.capture(request) } } private async capture(request: DetailedScreenshotRequest): Promise { if (this.page.isClosed()) return if (request.kind === 'url') { const key = `${request.kind}:${request.url}` if (this.capturedUrlKeys.has(key)) return this.capturedUrlKeys.add(key) } const name = this.screenshotName(request) const path = this.testInfo.outputPath(`${name}.png`) try { await this.page.waitForLoadState('domcontentloaded', {timeout: 2_000}) } catch (_error) {} try { await this.page.waitForTimeout(250) await this.page.screenshot({path, fullPage: true, timeout: 5_000}) this.state.captures.push({name, path}) } catch (_error) {} } private screenshotName(request: DetailedScreenshotRequest): string { this.state.screenshotIndex += 1 return [ 'screenshot', this.state.screenshotIndex.toString().padStart(3, '0'), request.kind, slugFor(request.kind === 'url' ? request.url : request.label) ].join('-') } } async function finalizeDetailedScreenshots( testInfo: TestInfo, retain: boolean ): Promise { const state = detailedScreenshotStateFor(testInfo) if (state.finalized) return state.finalized = true if (retain) { for (const capture of state.captures) { await testInfo.attach(capture.name, { path: capture.path, contentType: 'image/png' }) } return } await Promise.all( state.captures.map(async capture => { try { await unlink(capture.path) } catch (_error) {} }) ) } function detailedScreenshotStateFor( testInfo: TestInfo ): DetailedScreenshotState { const existing = detailedScreenshotStates.get(testInfo) if (existing) return existing const state = { captures: [], finalized: false, screenshotIndex: 0 } detailedScreenshotStates.set(testInfo, state) return state } function slugFor(value: string): string { const slug = value .replace(/^[a-z]+:\/\//i, '') .split(/[?#]/, 1)[0] .replace(/[^A-Za-z0-9._-]+/g, '-') .replace(/^[-._]+|[-._]+$/g, '') .slice(0, 80) return slug || 'capture' }