mirror of
https://github.com/lnbits/lnbits.git
synced 2026-08-13 12:42:47 +02:00
626 lines
16 KiB
TypeScript
626 lines
16 KiB
TypeScript
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<void> {
|
|
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<string, unknown>
|
|
method: string
|
|
timeoutMs?: number
|
|
}
|
|
|
|
export async function apiJson(
|
|
baseUrl: string,
|
|
method: string,
|
|
path: string,
|
|
data?: Record<string, unknown>,
|
|
apiKey?: string,
|
|
timeoutMs = 30_000
|
|
): Promise<Record<string, unknown>> {
|
|
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<string, unknown> {
|
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
}
|
|
|
|
export async function browserJson(
|
|
page: Page,
|
|
method: string,
|
|
path: string,
|
|
data?: Record<string, unknown>,
|
|
apiKey?: string
|
|
): Promise<unknown> {
|
|
const response = await page.evaluate(
|
|
async ({method, path, data, apiKey}) => {
|
|
const headers: Record<string, string> = {
|
|
'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<T>(
|
|
description: string,
|
|
callback: () => Promise<T | null | undefined>,
|
|
{timeout = 30_000, interval = 500}: {interval?: number; timeout?: number} = {}
|
|
): Promise<T> {
|
|
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<void> {
|
|
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<void>
|
|
}
|
|
|
|
export async function installDetailedScreenshots(
|
|
page: Page,
|
|
testInfo: TestInfo
|
|
): Promise<DetailedScreenshotHandle> {
|
|
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<string>()
|
|
private readonly page: Page
|
|
private readonly pending: DetailedScreenshotRequest[] = []
|
|
private readonly testInfo: TestInfo
|
|
private flushPromise?: Promise<void>
|
|
private flushTimer?: ReturnType<typeof setTimeout>
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
if (this.flushPromise) {
|
|
await this.flushPromise
|
|
return
|
|
}
|
|
|
|
this.flushPromise = this.flushQueue()
|
|
try {
|
|
await this.flushPromise
|
|
} finally {
|
|
this.flushPromise = undefined
|
|
}
|
|
}
|
|
|
|
private async flushQueue(): Promise<void> {
|
|
while (this.pending.length) {
|
|
const request = this.pending.shift()
|
|
if (!request) continue
|
|
await this.capture(request)
|
|
}
|
|
}
|
|
|
|
private async capture(request: DetailedScreenshotRequest): Promise<void> {
|
|
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<void> {
|
|
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'
|
|
}
|