test: support chat tests (#4102)

This commit is contained in:
Vlad Stan 2026-07-27 15:38:47 +03:00 committed by GitHub
parent 7258bd32b4
commit e165269ed8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 472 additions and 20 deletions

View file

@ -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<void> {
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<boolean> {
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<string, unknown> | null
): Set<unknown> {
return new Set(
Array.isArray(extension?.permissions)
? extension.permissions.filter(isRecord).map(permission => permission.id)
: []
)
}
async function latestReleaseConfig(
release: Record<string, unknown>
): Promise<Record<string, unknown>> {

View file

@ -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',

View file

@ -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'
}
}
}
}

View file

@ -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<typeof extensionApi>[0],
inboxName: string
): Promise<Record<string, unknown>> {
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
}