From e165269ed85458b75c7bfe1ef3785546b4bfadae Mon Sep 17 00:00:00 2001 From: Vlad Stan Date: Mon, 27 Jul 2026 15:38:47 +0300 Subject: [PATCH] test: support chat tests (#4102) --- tests/e2e/extension-helpers.ts | 103 +++++++++--- tests/e2e/extensions.ts | 14 ++ tests/e2e/start-lnbits-server.cjs | 107 ++++++++++++ tests/e2e/supportchat.spec.ts | 268 ++++++++++++++++++++++++++++++ 4 files changed, 472 insertions(+), 20 deletions(-) create mode 100644 tests/e2e/supportchat.spec.ts diff --git a/tests/e2e/extension-helpers.ts b/tests/e2e/extension-helpers.ts index 8fc7b59af..0508d1eba 100644 --- a/tests/e2e/extension-helpers.ts +++ b/tests/e2e/extension-helpers.ts @@ -12,6 +12,7 @@ const INSTALLABLE_EXTENSION_REFRESH_TASK = 'refresh_installable_extensions_cache' export type ExtensionUnderTest = { + configUrl?: string extId: string name: string permissionTexts?: string[] @@ -243,33 +244,64 @@ export async function installExtension( await expect(installButton).toBeVisible({timeout: 120_000}) await installButton.click() - const grantButton = page.getByRole('button', {name: /^grant and install$/i}) + const permissionsDialog = page + .locator('.q-dialog') + .filter({hasText: 'Grant extension permissions'}) + .last() + let hasPermissionsDialog = false try { - await expect(page.getByText('Grant extension permissions')).toBeVisible({ + await expect(permissionsDialog).toBeVisible({ timeout: 10_000 }) + hasPermissionsDialog = true + } catch (_error) {} + + if (hasPermissionsDialog) { for (const permissionText of extension.permissionTexts ?? []) { - await expect(page.getByText(permissionText).first()).toBeVisible({ + await expect( + permissionsDialog.getByText(permissionText).first() + ).toBeVisible({ timeout: 60_000 }) } + const grantButton = permissionsDialog.getByRole('button', { + name: /^grant and install$/i + }) await expect(grantButton).toBeEnabled({timeout: 60_000}) await grantButton.click() - } catch (_error) {} + } await waitForInstalledExtension(page, extension.extId) - const installed = await extensionState(page, extension.extId) - const grantedPermissionIds = new Set( - Array.isArray(installed?.permissions) - ? installed.permissions.filter(isRecord).map(permission => permission.id) - : [] - ) const latestConfig = await latestReleaseConfig(release) const permissions = Array.isArray(latestConfig.permissions) ? latestConfig.permissions.filter(isRecord) : [] + let installed = await extensionState(page, extension.extId) + let grantedPermissionIds = installedPermissionIds(installed) + const missingPermissionIds = permissions + .map(permission => permission.id) + .filter(permissionId => !grantedPermissionIds.has(permissionId)) + + if (extension.configUrl && missingPermissionIds.length) { + const response = await page + .context() + .request.put( + `/api/v1/extension/${encodeURIComponent(extension.extId)}/permissions`, + {data: {permissions}} + ) + expect( + response.ok(), + `Could not grant local fixture permissions: ${await response.text()}` + ).toBe(true) + installed = await extensionState(page, extension.extId) + grantedPermissionIds = installedPermissionIds(installed) + } + for (const permission of permissions) { - expect(grantedPermissionIds.has(permission.id)).toBe(true) + expect( + grantedPermissionIds.has(permission.id), + `Missing extension permission: ${String(permission.id)}` + ).toBe(true) } } @@ -277,18 +309,24 @@ export async function enableExtension( page: Page, extension: ExtensionUnderTest ): Promise { - await page.goto('/extensions') - await selectExtensionsTab(page, 'Installed') - await filterExtensions(page, extension.name) + if (await userExtensionEnabled(page, extension.extId)) return + + await page.goto(`/extensions#${encodeURIComponent(extension.extId)}`) + await dismissDisclaimer(page) const extensionCard = extensionCardFor(page, extension) await expect(extensionCard).toBeVisible({timeout: 120_000}) const enableButton = extensionCard.getByRole('button', {name: /^enable$/i}) - if (await enableButton.isVisible()) { - await enableButton.click() - await expect(page.getByText('Extension enabled!')).toBeVisible({ - timeout: 60_000 - }) - } + await expect(enableButton).toBeVisible({timeout: 60_000}) + await enableButton.click() + await expect(page.getByText('Extension enabled!')).toBeVisible({ + timeout: 60_000 + }) + await waitForResult( + `${extension.extId} extension to be enabled for the user`, + async () => + (await userExtensionEnabled(page, extension.extId)) ? true : null, + {timeout: 60_000, interval: 1_000} + ) await expect(extensionCard.getByRole('link', {name: /^open$/i})).toBeVisible({ timeout: 60_000 }) @@ -380,6 +418,21 @@ export async function extensionState( ) } +async function userExtensionEnabled( + page: Page, + extensionId: string +): Promise { + const extensions = await browserJson(page, 'GET', '/api/v1/extension') + if (!Array.isArray(extensions)) { + throw new Error( + `User extensions response is not a list: ${JSON.stringify(extensions)}` + ) + } + return extensions + .filter(isRecord) + .some(extension => extension.code === extensionId) +} + export async function grantBackgroundPaymentPermission( page: Page, extensionId: string, @@ -533,6 +586,16 @@ function latestReleaseFor( return release } +function installedPermissionIds( + extension: Record | null +): Set { + return new Set( + Array.isArray(extension?.permissions) + ? extension.permissions.filter(isRecord).map(permission => permission.id) + : [] + ) +} + async function latestReleaseConfig( release: Record ): Promise> { diff --git a/tests/e2e/extensions.ts b/tests/e2e/extensions.ts index c36a459f5..b4063a623 100644 --- a/tests/e2e/extensions.ts +++ b/tests/e2e/extensions.ts @@ -1,5 +1,19 @@ import type {ExtensionUnderTest} from './extension-helpers' +const extensionFixtureUrl = + process.env.LNBITS_E2E_EXTENSION_FIXTURE_URL ?? 'http://127.0.0.1:5010' + +export const SUPPORTCHAT: ExtensionUnderTest = { + extId: 'supportchat', + name: 'Support Chat', + configUrl: `${extensionFixtureUrl}/config.json`, + permissionTexts: [ + 'Read public extension storage', + 'Append public extension storage', + 'Use extension websockets' + ] +} + export const TIPS: ExtensionUnderTest = { extId: 'tips', name: 'Tips', diff --git a/tests/e2e/start-lnbits-server.cjs b/tests/e2e/start-lnbits-server.cjs index 0f36f3d70..c1a66be8c 100644 --- a/tests/e2e/start-lnbits-server.cjs +++ b/tests/e2e/start-lnbits-server.cjs @@ -1,5 +1,7 @@ const childProcess = require('node:child_process') +const crypto = require('node:crypto') const fs = require('node:fs') +const http = require('node:http') const os = require('node:os') const path = require('node:path') @@ -14,10 +16,41 @@ const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lnbits-e2e-')) const logDir = path.join(rootDir, 'test-reports', 'test-results') const logFile = path.join(logDir, 'lnbits-e2e-server.log') +const extensionFixtureUrl = new URL( + process.env.LNBITS_E2E_EXTENSION_FIXTURE_URL ?? 'http://127.0.0.1:5010' +) fs.mkdirSync(dataDir, {recursive: true}) fs.mkdirSync(logDir, {recursive: true}) +const supportchatFixture = createSupportchatFixture(extensionFixtureUrl) +const wasmExtensionManifests = [ + ...(supportchatFixture + ? [new URL('/manifest.json', extensionFixtureUrl).toString()] + : []), + 'https://raw.githubusercontent.com/lnbits/lnbits-extensions-wasm/refs/heads/main/extensions.json' +] +const extensionFixtureServer = supportchatFixture + ? http.createServer((request, response) => { + const pathname = new URL(request.url ?? '/', extensionFixtureUrl).pathname + const fixture = supportchatFixture.responses[pathname] + if (!fixture) { + response.writeHead(404, {'Content-Type': 'text/plain; charset=utf-8'}) + response.end('Not found') + return + } + response.writeHead(200, { + 'Cache-Control': 'no-store', + 'Content-Type': fixture.contentType + }) + response.end(fixture.body) + }) + : null +extensionFixtureServer?.listen( + Number(extensionFixtureUrl.port || 80), + extensionFixtureUrl.hostname +) + const log = fs.openSync(logFile, 'a') const server = childProcess.spawn( 'uv', @@ -44,6 +77,7 @@ const server = childProcess.spawn( LNBITS_DATA_FOLDER: dataDir, LNBITS_ENABLE_LOG_TO_FILE: 'false', LNBITS_EXTENSIONS_PATH: dataDir, + LNBITS_WASM_EXTENSIONS_MANIFESTS: JSON.stringify(wasmExtensionManifests), PORT: port, PYTHONUNBUFFERED: '1' }, @@ -56,6 +90,7 @@ let shuttingDown = false const shutdown = signal => { if (shuttingDown) return shuttingDown = true + extensionFixtureServer?.close() if (server.pid && server.exitCode === null) { try { @@ -77,8 +112,80 @@ process.on('SIGTERM', () => shutdown('SIGTERM')) process.on('SIGINT', () => shutdown('SIGINT')) server.on('exit', (code, signal) => { + extensionFixtureServer?.close() fs.closeSync(log) if (!shuttingDown) { process.exit(code ?? (signal ? 1 : 0)) } }) + +function createSupportchatFixture(fixtureUrl) { + const sourceDir = + process.env.LNBITS_E2E_SUPPORTCHAT_DIR ?? + path.join(rootDir, 'data', 'extensions', 'supportchat') + if (!fs.existsSync(path.join(sourceDir, 'config.json'))) return null + const config = JSON.parse( + fs.readFileSync(path.join(sourceDir, 'config.json'), 'utf8') + ) + const fixtureRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'lnbits-supportchat-e2e-') + ) + const archiveRootName = `supportchat-${config.version}` + const archiveRoot = path.join(fixtureRoot, archiveRootName) + fs.mkdirSync(archiveRoot, {recursive: true}) + + for (const name of ['config.json', 'static', 'storage', 'ui', 'wasm']) { + fs.cpSync(path.join(sourceDir, name), path.join(archiveRoot, name), { + recursive: true + }) + } + + const archivePath = path.join(fixtureRoot, 'supportchat.zip') + const zipped = childProcess.spawnSync( + 'zip', + ['-q', '-r', archivePath, archiveRootName], + { + cwd: fixtureRoot, + encoding: 'utf8' + } + ) + if (zipped.status !== 0) { + throw new Error(`Could not create supportchat fixture: ${zipped.stderr}`) + } + const archive = fs.readFileSync(archivePath) + const hash = crypto.createHash('sha256').update(archive).digest('hex') + const archiveUrl = new URL('/supportchat.zip', fixtureUrl).toString() + const configUrl = new URL('/config.json', fixtureUrl).toString() + const manifest = { + extensions: [ + { + id: 'supportchat', + name: config.name, + version: config.version, + archive: archiveUrl, + hash, + repo: 'local-e2e', + short_description: config.short_description, + min_lnbits_version: config.min_lnbits_version, + details_link: configUrl + } + ] + } + + return { + responses: { + '/config.json': { + body: Buffer.from(JSON.stringify(config)), + contentType: 'application/json; charset=utf-8' + }, + '/manifest.json': { + body: Buffer.from(JSON.stringify(manifest)), + contentType: 'application/json; charset=utf-8' + }, + '/supportchat.zip': { + body: archive, + contentType: 'application/zip' + } + } + } +} diff --git a/tests/e2e/supportchat.spec.ts b/tests/e2e/supportchat.spec.ts new file mode 100644 index 000000000..c1f9c0b67 --- /dev/null +++ b/tests/e2e/supportchat.spec.ts @@ -0,0 +1,268 @@ +import {existsSync} from 'node:fs' +import {join, resolve} from 'node:path' + +import { + expect, + installDetailedScreenshots, + isRecord, + randomHex, + test +} from './fixtures' +import { + extensionApi, + extensionFrame, + installAndEnableExtension, + login +} from './extension-helpers' +import {SUPPORTCHAT} from './extensions' + +const supportchatDir = + process.env.LNBITS_E2E_SUPPORTCHAT_DIR ?? + resolve('data/extensions/supportchat') + +test.skip( + !existsSync(join(supportchatDir, 'config.json')), + 'Support Chat E2E requires LNBITS_E2E_SUPPORTCHAT_DIR or data/extensions/supportchat.' +) + +test('install Support Chat and run the visitor-to-agent support workflow', async ({ + page, + browser, + lnbitsServer +}, testInfo) => { + await login(page, lnbitsServer) + await installAndEnableExtension(page, SUPPORTCHAT) + + await page.goto('/ext/supportchat') + let adminFrame = await extensionFrame(page, 'Support Chat') + await expect( + adminFrame.getByRole('heading', {name: 'Support Chat'}) + ).toBeVisible({timeout: 60_000}) + + const inboxName = `Playwright Support ${randomHex()}` + const inboxForm = adminFrame.locator('#inbox-form') + const createInboxButton = inboxForm.getByRole('button', { + name: 'Create inbox' + }) + await expect(createInboxButton).toBeEnabled() + await inboxForm.locator('[name="name"]').fill(inboxName) + await inboxForm + .locator('[name="welcomeMessage"]') + .fill('How can the Playwright support team help?') + await inboxForm.locator('[name="launcherText"]').fill('Ask Playwright') + await inboxForm + .locator('[name="offlineMessage"]') + .fill('Playwright support is offline; leave a message.') + await inboxForm.locator('[name="officeHoursEnabled"]').check() + await inboxForm.locator('[name="officeHoursStart"]').fill('0') + await inboxForm.locator('[name="officeHoursEnd"]').fill('0') + await createInboxButton.click() + await expect(adminFrame.getByText(inboxName).first()).toBeVisible({ + timeout: 60_000 + }) + + const inbox = await supportInbox(page, inboxName) + const publicPath = `/ext/supportchat/i/${encodeURIComponent(String(inbox.id))}` + const visitorContext = await browser.newContext({ + baseURL: lnbitsServer.baseUrl, + viewport: {width: 430, height: 820} + }) + const visitorPage = await visitorContext.newPage() + visitorPage.setDefaultTimeout(60_000) + const visitorRecorder = await installDetailedScreenshots( + visitorPage, + testInfo + ) + + try { + await visitorPage.goto(publicPath) + let visitorFrame = await extensionFrame(visitorPage, 'Support Chat') + await expect(visitorFrame.getByText(inboxName)).toBeVisible() + await expect( + visitorFrame.getByText('Playwright support is offline; leave a message.') + ).toBeVisible() + + const startForm = visitorFrame.locator('#start-box') + await startForm.locator('[name="name"]').fill('Alice Visitor') + await startForm + .locator('[name="email"]') + .fill('alice.playwright@example.com') + await startForm.locator('[name="subject"]').fill('Checkout is stuck') + await startForm + .locator('[name="body"]') + .fill('The checkout spinner never finishes.') + await startForm.getByTestId('start-conversation').click() + await expect(visitorPage).toHaveURL(/\/ext\/supportchat\/c\/[a-f0-9]+$/i, { + timeout: 60_000 + }) + visitorFrame = await extensionFrame(visitorPage, 'Support Chat') + await expect( + visitorFrame.getByText('The checkout spinner never finishes.') + ).toBeVisible() + + await page.goto('/ext/supportchat') + adminFrame = await extensionFrame(page, 'Support Chat') + await expect(adminFrame.getByText('Checkout is stuck')).toBeVisible({ + timeout: 60_000 + }) + await expect(adminFrame.getByTestId('unread-count')).toHaveText('1') + await adminFrame.getByText('Checkout is stuck').click() + await expect( + adminFrame.getByText('The checkout spinner never finishes.') + ).toBeVisible() + await expect( + adminFrame + .locator('.sc-message--theirs') + .filter({hasText: 'The checkout spinner never finishes.'}) + ).toHaveCSS('justify-content', 'flex-end') + + const cannedForm = adminFrame.locator('#canned-reply-form') + await cannedForm.locator('[name="title"]').fill('Investigating') + await cannedForm + .locator('[name="body"]') + .fill('Thanks — we are investigating this now.') + await cannedForm.getByRole('button', {name: 'Add canned reply'}).click() + await expect( + adminFrame.getByRole('button', {name: 'Investigating', exact: true}) + ).toBeVisible() + + await adminFrame.getByTestId('conversation-status').selectOption('pending') + await adminFrame.getByTestId('conversation-priority').selectOption('urgent') + await adminFrame.getByTestId('conversation-tags').fill('checkout, browser') + const ticketUpdated = page.waitForResponse( + response => + response.request().method() === 'PUT' && + response.url().includes('/api/v1/ext/supportchat/conversations/') + ) + await adminFrame.getByRole('button', {name: 'Save ticket'}).click() + await ticketUpdated + await expect(adminFrame.locator('main.sc-shell')).toHaveAttribute( + 'data-loading', + '' + ) + await expect(adminFrame.getByTestId('conversation-status')).toHaveValue( + 'pending' + ) + await expect(adminFrame.getByTestId('conversation-priority')).toHaveValue( + 'urgent' + ) + await expect(adminFrame.getByTestId('conversation-tags')).toHaveValue( + 'checkout, browser' + ) + + await adminFrame + .getByTestId('internal-note') + .fill('Only agents should see this diagnostic note.') + await adminFrame.getByRole('button', {name: 'Add internal note'}).click() + await expect( + adminFrame.getByText('Only agents should see this diagnostic note.') + ).toBeVisible() + + await adminFrame + .getByRole('button', {name: 'Investigating', exact: true}) + .click() + await expect(adminFrame.getByTestId('agent-reply')).toHaveValue( + 'Thanks — we are investigating this now.' + ) + await adminFrame.getByRole('button', {name: 'Send'}).click() + await expect( + visitorFrame.getByText('Thanks — we are investigating this now.') + ).toBeVisible({timeout: 60_000}) + await expect( + adminFrame + .locator('.sc-message--mine') + .filter({hasText: 'Thanks — we are investigating this now.'}) + ).toHaveCSS('justify-content', 'flex-start') + await expect( + visitorFrame + .locator('.sc-message--theirs') + .filter({hasText: 'Thanks — we are investigating this now.'}) + ).toHaveCSS('justify-content', 'flex-end') + await expect( + visitorFrame.getByText('Only agents should see this diagnostic note.') + ).toHaveCount(0) + + await visitorPage.waitForTimeout(1_100) + await visitorFrame + .locator('#message-box [name="body"]') + .fill('It also happens in a private window.') + await visitorFrame.getByTestId('visitor-send').click() + await expect( + adminFrame.getByText('It also happens in a private window.') + ).toBeVisible({timeout: 60_000}) + await expect(adminFrame.getByTestId('unread-count')).toHaveText('1') + + const ticketResolved = page.waitForResponse( + response => + response.request().method() === 'POST' && + response.url().includes('/api/v1/ext/supportchat/conversations/') && + response.url().endsWith('/resolve') + ) + await adminFrame.getByRole('button', {name: 'Resolve'}).click() + const ticketResolvedResponse = await ticketResolved + expect( + ticketResolvedResponse.ok(), + `Could not resolve ticket: ${await ticketResolvedResponse.text()}` + ).toBe(true) + await expect(visitorFrame.getByText('resolved').first()).toBeVisible({ + timeout: 60_000 + }) + await expect(adminFrame.locator('main.sc-shell')).toHaveAttribute( + 'data-loading', + '' + ) + await expect(adminFrame.getByTestId('conversation-status')).toHaveValue( + 'resolved' + ) + await adminFrame.getByTestId('conversation-status').selectOption('open') + const ticketReopened = page.waitForResponse( + response => + response.request().method() === 'PUT' && + response.url().includes('/api/v1/ext/supportchat/conversations/') + ) + await adminFrame.getByRole('button', {name: 'Save ticket'}).click() + const ticketReopenedResponse = await ticketReopened + expect( + ticketReopenedResponse.ok(), + `Could not reopen ticket: ${await ticketReopenedResponse.text()}` + ).toBe(true) + await expect(adminFrame.locator('main.sc-shell')).toHaveAttribute( + 'data-loading', + '' + ) + await visitorPage.reload() + visitorFrame = await extensionFrame(visitorPage, 'Support Chat') + await expect(visitorFrame.getByText('open').first()).toBeVisible({ + timeout: 60_000 + }) + + await visitorPage.goto(publicPath) + visitorFrame = await extensionFrame(visitorPage, 'Support Chat') + await expect( + visitorFrame.getByText('Thanks — we are investigating this now.') + ).toBeVisible({timeout: 60_000}) + } finally { + await visitorRecorder.finish() + await visitorContext.close() + } +}) + +async function supportInbox( + page: Parameters[0], + inboxName: string +): Promise> { + const response = await extensionApi( + page, + SUPPORTCHAT.extId, + 'GET', + '/inboxes?rowsPerPage=100' + ) + const inboxes = Array.isArray(response.inboxes) + ? response.inboxes.filter(isRecord) + : [] + const inbox = inboxes.find(item => item.name === inboxName) + if (!inbox) { + throw new Error(`Support inbox not found: ${JSON.stringify(response)}`) + } + return inbox +}