Files
LocalAI/core/http/react-ui/e2e/addon-navigation.spec.js
T
mudler-agentandEttore Di Giacinto 8f9a0b281d test: update stale tests after the add-on owner and Sandbox skill changes (#13045)
* test(e2e): use a core built-in skill in the agent journeys

#13038 moved the docx, pdf, pptx and xlsx skills into the Sandbox
add-on, so core no longer embeds them. Three journeys still named docx
and xlsx as built-in skills. The backend e2e suite then failed: reading
/builtin/docx answered 404, and the system prompt had no docx entry.

The journeys now use localai-apps and localai-workspace, which core
still embeds. They keep their intent: an agent lists only the skills it
names, a plain chat lists all of them, and built-in skills are readable
but cannot be deleted.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(ui): serve owner host settings in the add-on e2e fixtures

#13033 moved the network policy and the skill provider pins to the
add-on owner. The add-on Settings tab and the skills switch now read
and write them with the host-settings-read and host-settings-update
dispatch actions. The specs still mocked only /api/settings, so the
owner read failed and the network block never rendered. Six specs
failed on master.

mockAddons now answers both actions from a revisioned policy, checks
the expected revision, applies patches like hostsettings.Patch and logs
each patch. The specs assert the owner patch instead of the
/api/settings POST. They also follow two other changes of #13033: the
audit retention stays on the Settings page, and *.example.com is a
valid host pattern.

The document skill wording follows #13038: an add-on provides docx and
xlsx, it does not replace a built-in.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(routes): port the UI capability tests to Ginkgo

The capability tests from #13024 used t.Fatal and t.Errorf, which the
forbidigo lint rule rejects. The routes package already runs a Ginkgo
suite, so the two tests are now specs in it, with the same models and
the same assertions.

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

---------

Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
2026-11-02 22:09:45 +01:00

1097 lines
63 KiB
JavaScript

import { test, expect } from '@playwright/test'
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { ADMIN, MEMBER, app, installedAddon, release, sessionFor, mockAddons } from './addons-fixtures.js'
import { buildAddonBundles, installStudioAddons, packagedApp } from './studio-addon.js'
import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } from './identity-fixtures.js'
// Studio and Talk are packaged add-ons, not built-in tiles: the specs install
// the real packages. No page is built in today, so `builtin` is empty unless a
// spec makes one up to check the built-in rules.
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases, hostSettings, features = { addon_navigation: true } } = {}) {
let saved = pins
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) }))
await page.route('**/api/features', route => route.fulfill({ json: features }))
await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin } }))
// mockAddons installs the Apps routes with the real response shape ({ apps }).
// The packages are served from the build output, so a page that opens one runs it.
let mocked = null
if (studio) mocked = await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] })
else mocked = await mockAddons(page, { apps, installed, releases, hostSettings })
await page.route('**/api/addons/pins', async route => {
if (route.request().method() === 'PUT') saved = route.request().postDataJSON()
await route.fulfill({ json: saved })
})
return { saved: () => saved, dispatchLog: () => (mocked ? mocked.log.dispatch : []), mocked: () => mocked }
}
test.describe('Add-on navigation', () => {
test('the sidebar lists the pinned apps under Add-ons, in pin order, and no Show all link', async ({ page }) => {
await setup(page)
await page.goto('/app')
const nav = page.getByRole('navigation')
await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible()
// Apps, then the pinned apps in pin order; Apps is the only add-on entry.
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
const pinned = nav.getByTestId('apps-pinned-link')
await expect(pinned).toHaveCount(2)
await expect(pinned.nth(0)).toHaveAttribute('data-app', 'studio')
await expect(pinned.nth(0)).toHaveText('Studio')
await expect(pinned.nth(1)).toHaveAttribute('data-app', 'talk')
await expect(pinned.nth(1)).toHaveText('Talk')
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
await expect(nav.getByText('Show all')).toHaveCount(0)
const group = await nav.locator('a.nav-item').evaluateAll(els => els.map(e => e.textContent.trim()))
const at = group.indexOf('Add-ons')
expect(group.slice(at, at + 4)).toEqual(['Add-ons', 'Studio', 'Talk'])
await expect(pinned.nth(0)).toHaveAttribute('href', /\/app\/addons\/studio\//)
// There is no Pinned section.
await expect(page.getByTestId('pinned-nav')).toHaveCount(0)
await expect(page.locator('.sidebar-section-title', { hasText: 'Pinned' })).toHaveCount(0)
})
test('the sidebar follows the pin order and shows no pinned app when none is pinned', async ({ page }) => {
await setup(page, { pins: ['talk', 'studio', 'missing'] })
await page.goto('/app')
const nav = page.getByRole('navigation')
await expect(nav.getByTestId('apps-pinned-link')).toHaveText(['Talk', 'Studio'])
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
await page.unroute('**/api/addons/pins')
await page.route('**/api/addons/pins', route => route.fulfill({ json: [] }))
await page.goto('/app')
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0)
})
test('the icon rail shows Add-ons only, centred, with no pinned app', async ({ page }) => {
await setup(page)
for (const size of [{ width: 1280, height: 800, collapsed: true }, { width: 1000, height: 800, collapsed: false }]) {
await page.setViewportSize({ width: size.width, height: size.height })
await page.addInitScript(collapsed => {
try { localStorage.setItem('localai_sidebar_collapsed', String(collapsed)) } catch (_) { /* ignore */ }
}, size.collapsed)
await page.goto('/app')
const nav = page.getByRole('navigation')
await expect(nav.locator('a.nav-item[href="/app/addons"]')).toBeVisible()
await expect(nav.getByTestId('apps-pinned-link').filter({ visible: true })).toHaveCount(0)
const rail = await page.locator('.sidebar').boundingBox()
const icons = await nav.locator('a.nav-item:visible .nav-icon').evaluateAll(els => els.map(e => {
const r = e.getBoundingClientRect()
return (r.left + r.right) / 2
}))
expect(icons.length).toBeGreaterThan(0)
for (const x of icons) expect(Math.abs(x - (rail.x + rail.width / 2))).toBeLessThan(2)
}
})
test('the Add-ons entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => {
const server = await setup(page, { pins: ['studio', 'talk'] })
await page.goto('/app')
await page.getByRole('navigation').getByRole('link', { name: 'Add-ons', exact: true }).click()
await expect(page).toHaveURL(/\/app\/addons$/)
await expect(page.getByTestId('addon-tile')).toHaveCount(2)
// A bare pin button or pair of arrows is gone: each card has one overflow menu.
await expect(page.getByTestId('pin-toggle')).toHaveCount(0)
await expect(page.getByTestId('pin-later')).toHaveCount(0)
await expect(page.getByRole('button', { name: /^Actions for / })).toHaveCount(2)
const studio = page.locator('[data-testid="addon-tile"][data-app="studio"]')
await studio.getByRole('button', { name: /^Actions for / }).click()
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
await page.getByRole('menuitem', { name: /^Move .* later$/ }).click()
await expect.poll(() => server.saved()).toEqual(['talk', 'studio'])
})
test('the card menu offers Show in sidebar, Details and Remove, and pins from the keyboard', async ({ page }) => {
const server = await setup(page, { pins: [], user: ADMIN, apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile')).toHaveCount(3)
await page.getByTestId('addon-search').fill('note')
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
const menu = page.getByRole('button', { name: 'Actions for Notes' })
await menu.focus()
await page.keyboard.press('Enter')
await expect(page.getByRole('menuitem')).toHaveText(['Show in sidebar', 'Details', 'Remove'])
await page.keyboard.press('Enter')
await expect.poll(() => server.saved()).toEqual(['notes'])
await menu.click()
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
})
test('a reader sees no Remove in the card menu', async ({ page }) => {
await setup(page, { pins: [], apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
await page.goto('/app/addons')
await page.getByRole('button', { name: 'Actions for Notes' }).click()
await expect(page.getByRole('menuitem', { name: 'Show in sidebar' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Remove', exact: true })).toHaveCount(0)
})
test('the card says what the add-on does and what it adds', async ({ page }) => {
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', description: 'Keep notes.', state: 'running', mainPage: '/app/addons/notes/home', tools: ['add', 'list'], toolCount: 2 }), app({ id: 'quiet', title: 'Quiet', description: 'Works in the background.', hasUI: false, mainPage: undefined, tools: [], toolCount: 0 })] })
await page.goto('/app/addons')
const notes = page.locator('[data-testid="addon-tile"][data-app="notes"]')
await expect(notes).toContainText('Keep notes.')
await expect(notes.getByTestId('tile-adds')).toContainText('Adds a page')
await expect(notes.getByTestId('tile-adds')).toContainText('2 chat tools')
await expect(page.locator('[data-testid="addon-tile"][data-app="quiet"]').getByTestId('tile-adds')).toHaveCount(0)
})
test('pinned apps come first in the grid, in pin order', async ({ page }) => {
await setup(page, {
pins: ['notes'],
apps: [app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' }), app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })],
})
await page.setViewportSize({ width: 1280, height: 800 })
await page.goto('/app/addons')
const tiles = page.getByTestId('addon-tile')
await expect(tiles.first()).toHaveAttribute('data-app', 'notes')
await tiles.first().getByRole('button', { name: /^Actions for / }).click()
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
})
test('selecting a card opens the preview pane with what the add-on is, adds and offers', async ({ page }) => {
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'indexer', title: 'Indexer', description: 'Indexes your files.', enabled: false, state: 'stopped', hasUI: false, mainPage: undefined, tools: ['query', 'export'], toolCount: 2, version: '0.4.0', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })], installed: [installedAddon({ id: 'indexer', title: 'Indexer', sourceId: 'main', enabled: false, state: 'stopped', version: '0.4.0' })] })
await page.setViewportSize({ width: 1360, height: 860 })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-detail')).toHaveCount(0)
await page.getByRole('button', { name: /^Indexer, Off/ }).click()
const pane = page.getByTestId('addon-detail')
await expect(pane).toBeVisible()
await expect(page).toHaveURL(/detail=indexer/)
await expect(pane.getByRole('heading', { name: 'Indexer' })).toBeVisible()
// The header chip; the operator facts below repeat it under Health.
await expect(pane.getByRole('img', { name: 'State: Off' }).first()).toBeVisible()
await expect(pane).toContainText('Indexes your files.')
await expect(pane.getByTestId('app-tools')).toContainText('2 chat tools')
await expect(pane.getByTestId('app-tools')).toContainText('query, export')
await expect(pane).toContainText('Runs in the background')
await expect(pane.getByTestId('preview-facts')).toContainText('0.4.0')
await expect(pane.getByTestId('preview-source')).toHaveText('main')
await expect(pane.getByRole('button', { name: 'Turn on' })).toBeVisible()
await expect(pane.getByRole('button', { name: 'Remove' })).toBeVisible()
// No screenshot slot unless the add-on provides images.
await expect(pane.getByTestId('addon-shots')).toHaveCount(0)
await pane.getByRole('button', { name: 'Close' }).click()
await expect(page.getByTestId('addon-detail')).toHaveCount(0)
})
test('the preview pane shows screenshot slots only for an add-on that ships images', async ({ page }) => {
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', screenshots: ['/api/addons/apps/notes/icon?generation=3'] })] })
await page.goto('/app/addons?detail=notes')
await expect(page.getByTestId('addon-detail').getByTestId('addon-shots').getByRole('listitem')).toHaveCount(1)
})
// An add-on that declares a settings.sections contribution: its settings
// live on the Settings tab of its details, and the Settings page links there.
const withSettings = (over = {}) => app({ id: 'sandbox', name: 'sandbox', title: 'Sandbox', description: 'Run commands.', mainPage: '/app/addons/sandbox/sandbox', ui: { slots: [{ id: 'settings', slot: 'settings.sections', component: 'SandboxSettings' }] }, ...over })
test('an administrator gets a Settings tab in the details of an add-on with settings, with the host controls it has', async ({ page }) => {
const controlled = withSettings({ shadows: ['bash'], ui: { entry: `/api/addons/sandbox/assets/${'a'.repeat(64)}/ui.js`, sdk: { min: 2, max: 2 }, controls: ['network-policy'] } })
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [controlled, app({ id: 'notes', title: 'Notes' })], installed: [installedAddon({ id: 'sandbox', title: 'Sandbox' }), installedAddon()], hostSettings: { chats_egress_ceiling: 'registries' } })
await page.route('**/api/settings', route => route.fulfill({ json: { chats_egress_ceiling: 'registries', chats_egress_audit_days: 14 } }))
await page.route('**/api/chats/admin/egress', route => route.fulfill({ json: { ceiling: 'registries', default: 'off', ports: [80, 443], audit_days: 14, registries: [], allowed_24h: 0, blocked_24h: 0 } }))
await page.goto('/app/addons?detail=sandbox')
const pane = page.getByTestId('addon-detail')
await expect(pane.getByTestId('addon-detail-tab-overview')).toHaveAttribute('aria-selected', 'true')
await pane.getByTestId('addon-detail-tab-settings').click()
await expect(page).toHaveURL(/detail=sandbox&view=settings$/)
await expect(pane.getByTestId('addon-detail-tab-settings')).toHaveAttribute('aria-selected', 'true')
const block = pane.getByTestId('host-control-network-policy')
await expect(block).toBeVisible()
await expect(block.locator('[data-field="chats_egress_ceiling"] select')).toHaveValue('registries')
// The audit retention stays on the Settings page: it is not owner policy.
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toHaveCount(0)
await expect(pane.getByTestId('addon-server-settings')).toHaveCount(0)
// Arrow keys move between the two tabs.
await pane.getByTestId('addon-detail-tab-settings').press('ArrowLeft')
await expect(pane.getByTestId('addon-detail-tab-overview')).toHaveAttribute('aria-selected', 'true')
// An add-on without settings has no tab.
await page.goto('/app/addons?detail=notes&view=settings')
await expect(page.getByTestId('addon-detail')).toBeVisible()
await expect(page.getByTestId('addon-detail-tab-settings')).toHaveCount(0)
await expect(page.getByTestId('preview-facts')).toBeVisible()
})
test('a member gets no Settings tab, even from its address', async ({ page }) => {
await setup(page, { pins: [], studio: false, apps: [withSettings()] })
await page.goto('/app/addons?detail=sandbox&view=settings')
await expect(page.getByTestId('addon-detail')).toBeVisible()
await expect(page.getByTestId('addon-detail-tab-settings')).toHaveCount(0)
await expect(page.getByTestId('addon-settings')).toHaveCount(0)
})
test('the Settings page lists the add-ons with settings in an Add-ons group, and only when there is one', async ({ page }) => {
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [withSettings(), app({ id: 'notes', title: 'Notes' })] })
await page.goto('/app/settings')
const group = page.locator('.st-group[data-group="addons"]')
await expect(group).toBeVisible()
await expect(group).toContainText('1')
await group.click()
const rows = page.getByTestId('settings-addon')
await expect(rows).toHaveCount(1)
await expect(rows.first()).toHaveAttribute('data-app', 'sandbox')
await expect(rows.first().getByTestId('settings-addon-link')).toHaveAttribute('href', /\/app\/addons\?detail=sandbox&view=settings$/)
// The old anchor of the section opens the Settings tab.
await page.goto('/app/settings#ad-section-sandbox-settings')
await expect(page).toHaveURL(/\/app\/addons\?detail=sandbox&view=settings$/)
})
test('the Settings page has no Add-ons group when no add-on has settings', async ({ page }) => {
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'notes', title: 'Notes' })] })
const listed = page.waitForResponse(r => /\/api\/addons\/apps$/.test(new URL(r.url()).pathname))
await page.goto('/app/settings')
await listed
await expect(page.getByTestId('settings-page')).toBeVisible()
await expect(page.locator('.st-group').first()).toBeVisible()
await expect(page.locator('.st-group[data-group="addons"]')).toHaveCount(0)
})
test('a member sees the pane without Remove and without the source', async ({ page }) => {
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes' })] })
await page.goto('/app/addons?detail=notes')
const pane = page.getByTestId('addon-detail')
await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible()
await expect(pane.getByRole('button', { name: 'Remove' })).toHaveCount(0)
await expect(pane.getByTestId('preview-source')).toHaveCount(0)
})
test('the pane menu is the same sidebar control as the card menu', async ({ page }) => {
const server = await setup(page, { pins: ['notes'], studio: false, apps: [app({ id: 'notes', title: 'Notes' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })] })
await page.setViewportSize({ width: 1360, height: 860 })
await page.goto('/app/addons?detail=zeta')
const detail = page.getByTestId('addon-detail')
await detail.getByRole('button', { name: 'Actions for Zeta' }).click()
await page.getByRole('menuitem', { name: 'Show in sidebar' }).click()
await expect.poll(() => server.saved()).toEqual(['notes', 'zeta'])
await detail.getByRole('button', { name: 'Actions for Zeta' }).click()
await page.getByRole('menuitem', { name: 'Remove from sidebar' }).click()
await expect.poll(() => server.saved()).toEqual(['notes'])
})
test('the first-run card offers three featured add-ons and a way to find more', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], releases: ['notes', 'tables', 'voice', 'extra'].map(id => release(id, { description: `${id} does a thing.`, ui: id === 'notes' ? { mainPage: '/app/addons/notes/home' } : undefined })) })
await page.goto('/app/addons')
const card = page.getByTestId('addons-first-run')
await expect(card).toBeVisible()
await expect(card.getByRole('heading', { name: 'No add-ons yet' })).toBeVisible()
const featured = card.getByTestId('featured').getByRole('listitem')
await expect(featured).toHaveCount(3)
await expect(featured.first()).toContainText('does a thing.')
await expect(featured.first()).toContainText('Adds a page')
await expect(card.getByRole('button', { name: /^Install / })).toHaveCount(3)
// One way into Find add-ons while the card shows: the bar's button waits.
await expect(page.getByRole('link', { name: 'Find add-ons' })).toHaveCount(1)
await expect(page.getByTestId('addons-add')).toHaveCount(0)
await expect(page.getByTestId('addon-tile')).toHaveCount(0)
await card.getByTestId('addons-first-run-browse').click()
await expect(page).toHaveURL(/tab=browse/)
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
})
test('Install and turn on asks once, installs, then turns the add-on on', async ({ page }) => {
const state = await setup(page, { user: ADMIN, studio: false, pins: [], releases: [release('notes', { name: 'Notes' })] })
await page.goto('/app/addons')
await page.getByRole('button', { name: 'Install Notes' }).click()
const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog'))
await expect(dialog).toContainText('Install and turn on Notes?')
await dialog.getByRole('button', { name: 'Install and turn on' }).click()
await expect.poll(() => state.dispatchLog().filter(d => ['install', 'enable'].includes(d.action)).map(d => d.action)).toEqual(['install', 'enable'])
})
test('a search with no result says no add-on matches, not that none are installed', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'notes', title: 'Notes' })] })
await page.goto('/app/addons')
await page.getByTestId('addon-search').fill('zzz')
await expect(page.getByRole('heading', { name: 'No add-on matches' })).toBeVisible()
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
await page.getByRole('button', { name: 'Clear search' }).click()
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
})
test('the first-run card is hidden once an add-on is installed', async ({ page }) => {
await setup(page, { user: ADMIN, pins: [] })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile').first()).toBeVisible()
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
await expect(page.getByTestId('addons-add')).toBeVisible()
})
test('the first-run card is hidden from a caller who cannot install add-ons', async ({ page }) => {
await setup(page, { studio: false, pins: [] })
await page.goto('/app/addons')
await expect(page.getByRole('heading', { name: 'No add-ons yet' })).toBeVisible()
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
})
test('a stopped tile opens the detail view and never the main page', async ({ page }) => {
await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', mainPage: undefined })] })
await page.goto('/app/addons')
await page.getByTestId('addon-tile').filter({ hasText: 'Notes' }).getByRole('button', { name: /^Notes,/ }).click()
await expect(page.getByTestId('addon-detail')).toBeVisible()
expect(page.url()).toContain('/app/addons?detail=notes')
})
test('a pinned tile can be moved later and the new order is saved', async ({ page }) => {
const server = await setup(page, { pins: ['studio', 'talk'] })
await page.goto('/app/addons')
await page.getByTestId('addon-tile').filter({ hasText: 'Studio' }).getByRole('button', { name: /^Actions for / }).click()
await page.getByRole('menuitem', { name: /^Move .* later$/ }).click()
await expect.poll(() => server.saved()).toEqual(['talk', 'studio'])
})
test('the ninth pin asks the user to unpin one', async ({ page }) => {
const nine = Array.from({ length: 9 }, (_, i) => app({ id: `a${i}`, title: `App ${i}`, state: 'running', mainPage: `/app/addons/a${i}/home` }))
const eight = nine.slice(0, 8).map(a => a.id)
await setup(page, { apps: nine, pins: eight })
await page.goto('/app/addons')
await page.getByTestId('addon-tile').filter({ hasText: 'App 8' }).getByRole('button', { name: /^Actions for / }).click()
await page.getByRole('menuitem', { name: 'Show in sidebar' }).click()
await expect(page.getByRole('alertdialog').or(page.getByRole('dialog'))).toContainText(/unpin/i)
})
test('member without the add-ons feature sees the built-in tiles and no error', async ({ page }) => {
let appsCalled = false
// No page is built in today, so the spec makes one up.
await setup(page, { addons: false, builtin: [{ id: 'guide', kind: 'page', titleKey: 'items.chat', icon: 'chat', mainPath: '/app/chat' }] })
// Registered after setup, so it wins over the mocked Apps route.
await page.route(/\/api\/addons\/apps$/, route => { appsCalled = true; return route.fulfill({ status: 403, json: {} }) })
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
await expect(page.getByTestId('addons-add')).toHaveCount(0)
await expect(page.getByRole('alert')).toHaveCount(0)
expect(appsCalled).toBe(false)
})
// The icon of a built-in tile sat in the top-left corner of its 36px box, at the
// size of the page text, because only the empty-icon box was laid out as a grid.
test('a built-in tile draws its icon centred in the icon box', async ({ page }) => {
await setup(page, { builtin: [{ id: 'guide', kind: 'page', titleKey: 'items.chat', icon: 'palette', mainPath: '/app/chat' }], studio: false })
await page.goto('/app/addons')
const box = page.locator('.ad-icon').first()
await expect(box.locator('svg[data-icon="palette"]')).toBeVisible()
const offsets = await box.evaluate(el => {
const outer = el.getBoundingClientRect()
const inner = el.querySelector('svg').getBoundingClientRect()
return { dx: (inner.left + inner.width / 2) - (outer.left + outer.width / 2), dy: (inner.top + inner.height / 2) - (outer.top + outer.height / 2), width: inner.width, box: outer.width }
})
expect(Math.abs(offsets.dx)).toBeLessThan(1)
expect(Math.abs(offsets.dy)).toBeLessThan(1)
expect(offsets.width).toBeGreaterThan(16)
expect(offsets.box).toBe(36)
})
test('member without the add-ons feature and with no built-in tile goes home, and Studio and Talk are not built in', async ({ page }) => {
await setup(page, { addons: false })
await page.goto('/app/addons')
await expect(page).toHaveURL(/\/app$/)
await expect(page.getByRole('navigation').locator('a.nav-item[href="/app/addons"]')).toHaveCount(0)
})
test('the legacy flag brings the old sidebar back', async ({ page }) => {
await setup(page)
await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } }))
await page.goto('/app')
await expect(page.getByTestId('apps-show-all')).toHaveCount(0)
// Home, Models and Chat head the old sidebar; Studio and Talk are add-ons and are not in it.
await expect(page.getByRole('navigation').getByRole('link', { name: 'Home' })).toBeVisible()
await expect(page.getByRole('navigation').getByRole('link', { name: 'Studio' })).toHaveCount(0)
await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0)
})
test('the layout holds at phone width', async ({ page }) => {
await page.setViewportSize({ width: 375, height: 700 })
await setup(page)
await page.goto('/app/addons')
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)
expect(overflow).toBe(false)
})
test("the server's admin block is what shows a Start action for a stopped add-on in the detail view", async ({ page }) => {
await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=notes' } })] })
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) }))
await page.goto('/app/addons?detail=notes')
await expect(page.getByTestId('addon-detail').getByRole('button', { name: 'Turn on' })).toBeVisible()
})
test('the card menu has a visible focus state and a name', async ({ page }) => {
await setup(page)
await page.goto('/app/addons')
const trigger = page.getByRole('button', { name: /^Actions for / }).first()
await trigger.focus()
await expect(trigger).toBeFocused()
await expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
await expect(trigger).toHaveAccessibleName(/^Actions for .+/)
})
test('a pin of an add-on that is no longer listed is not drawn in the drawer and shows no error', async ({ page }) => {
await setup(page, { pins: ['studio', 'removed-addon'] })
// The Home page raises its own alert when the model list or a staging
// operation fails. Both come from the shared test server, so a slow answer
// or a failed install left there would count as an alert about the pin.
// Fix their answers so the alert count tells only about the pin.
await page.route('**/v1/models', route => route.fulfill({ json: { object: 'list', data: [] } }))
await page.route('**/api/operations', route => route.fulfill({ json: { operations: [] } }))
// Hold the Apps list back so the assertions run after it has arrived and
// been drawn from, not before, when a missing pin proves nothing.
await page.route(/\/api\/addons\/apps$/, async route => {
await new Promise(resolve => setTimeout(resolve, 4000))
await route.fulfill({ json: { apps: [packagedApp('studio')] } })
})
const listed = page.waitForResponse(res => /\/api\/addons\/apps$/.test(res.url()))
await page.goto('/app/addons')
await listed
// Two frames: the list reaches React and the drawer redraws from it.
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
await expect(page.getByRole('alert')).toHaveCount(0)
})
test('a stopped pinned add-on in the drawer opens the detail view', async ({ page }) => {
await setup(page, { pins: ['notes'], apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped' })] })
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) }))
await page.goto('/app/addons')
const tile = page.locator('[data-testid="addon-tile"][data-app="notes"]')
await expect(tile).toHaveAttribute('data-state', 'stopped')
await tile.locator('.ad-tile__open').click()
await expect(page).toHaveURL(/\/app\/addons\?detail=notes$/)
await expect(page.getByTestId('addon-detail')).toBeVisible()
})
test('core pages that share a primitive with the SDK draw the same header', async ({ page }) => {
await setup(page)
await page.goto('/app/addons')
const header = page.locator('header.page-header').first()
await expect(header.locator('h1.page-title')).toHaveText('Add-ons')
expect(await header.getAttribute('class')).toBe('page-header page-header--editorial')
})
})
test.describe('Add-on navigation: the legacy Add-ons hub tab and the route in both layouts', () => {
test('the new layout has no Add-ons hub tab and serves the drawer at /app/addons', async ({ page }) => {
await setup(page)
await page.goto('/app/addons')
await expect(page.getByTestId('addon-tile')).toHaveCount(2)
await expect(page.locator('.dk-hubtabs')).toHaveCount(0)
})
test('the legacy flag brings the Add-ons hub tab back and /app/addons opens under it', async ({ page }) => {
await setup(page, { apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } }))
await page.goto('/app')
// The legacy sidebar lists the Add-ons hub, which opens the drawer.
await page.locator('.sidebar-nav a.nav-item[href="/app/addons"]').click()
const hubTab = page.locator('.dk-hubtabs [data-hub-tab="addons"]')
await expect(hubTab).toHaveAttribute('href', '/app/addons')
await hubTab.click()
await expect(page).toHaveURL(/\/app\/addons$/)
await expect(hubTab).toHaveAttribute('aria-current', 'page')
await expect(page.getByTestId('addon-tile').filter({ hasText: 'Notes' })).toBeVisible()
await page.goto('/app/addons')
await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('aria-current', 'page')
})
test('an old generator link keeps its model, query and hash', async ({ page }) => {
await setup(page)
await page.goto('/app/image/stable%2Fdiffusion?x=1#top')
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?x=1&model=stable%2Fdiffusion#top$/)
})
test('the legacy ?tab= link of Studio still works', async ({ page }) => {
await setup(page)
await page.goto('/app/studio?tab=tts')
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/tts$/)
})
test('a viewer without the 3d feature still ends on the app home', async ({ page }) => {
await setup(page)
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, '3d': false }) }))
await page.goto('/app/3d')
await expect(page).toHaveURL(/\/app$/)
})
})
// The built Voice module, served the way the owner serves it (addons-shell.spec.js
// does the same for the uifixture module).
const VOICE_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../addons/voice/ui/dist')
const VOICE_DIGEST = 'd'.repeat(64)
const VOICE_BASE = `/api/addons/voice/assets/${VOICE_DIGEST}`
// dist/ is not committed and CI does not build it, so the spec builds the
// bundle itself, once per run, with the builder of the add-on.
test.beforeAll(async () => {
const { build } = await import('../../../../addons/voice/ui/build.mjs')
await build()
})
const voiceApp = () => app({
id: 'voice', name: 'voice', title: 'Voice', icon: undefined, generation: 1, version: '0.1.0', mainPage: '/app/addons/voice/voice',
ui: { entry: `${VOICE_BASE}/ui/voice.js`, css: [`${VOICE_BASE}/ui/voice.css`], sdk: { min: 2, max: 2 }, pages: ['voice', 'recognition', 'library', 'create', 'speech'], navigation: ['voice'], chatActions: [] },
})
async function serveVoice(page) {
await page.route(/\/api\/addons\/voice\/assets\//, route => {
const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/')
const known = { 'ui/voice.js': 'text/javascript', 'ui/voice.css': 'text/css' }
if (!known[file]) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
return route.fulfill({ status: 200, contentType: known[file], headers: { 'cache-control': 'no-store' }, body: fs.readFileSync(path.join(VOICE_DIST, file)) })
})
}
async function asAdmin(page, features = {}) {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true, voice_recognition: true, ...features }) }))
}
// The tabs of the one Voice page. An administrator sees all four.
async function expectVoiceTabs(page, active) {
const tabs = page.getByTestId('voice-tabs')
await expect(tabs).toBeVisible()
for (const name of [/^Recognition/, /^Library/, /^Text to speech$/, /^From a recording$/]) {
await expect(tabs.getByRole('tab', { name })).toBeVisible()
}
await expect(tabs.locator('[aria-selected="true"]')).toHaveAttribute('data-tab', active)
}
// The add-on draws its own title, once: the shell adds none, and each tab has
// exactly one h1.
async function expectOneVoiceTitle(page, title) {
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
await expect(page.getByRole('heading', { name: title, level: 1 })).toBeVisible()
}
test.describe('Add-on navigation: the Voice recognition page', () => {
test('the Voice add-on draws the recognition tab with the core test ids', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
await page.goto('/app/addons/voice/voice/recognition')
await expect(page.getByTestId('voices-page')).toBeVisible()
await expectOneVoiceTitle(page, 'Voices')
await expectVoiceTabs(page, 'recognition')
await expect(page.locator('[data-testid="addon-surface"][data-addon="voice"]')).toBeVisible()
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
// With nobody saved there is one enrol button, in the empty state.
await expect(page.getByRole('button', { name: /Enrol a speaker/ })).toHaveCount(1)
})
test('an add-on that is installed but off says it is off, by its name, and offers Turn on', async ({ page }) => {
const server = await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=fine-tuning' } })] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state).toHaveAttribute('data-state', 'off')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tuning')
await expect(state.getByRole('heading', { name: 'Fine-tuning is off' })).toBeVisible()
await expect(state).not.toContainText('fine-tuning')
await state.getByRole('button', { name: 'Turn on' }).click()
await expect.poll(() => server.mocked().log.start.length).toBe(1)
expect(server.mocked().log.start[0].id).toBe('fine-tuning')
})
test('an off add-on shows a reader who can turn it on, and no button', async ({ page }) => {
await setup(page, { studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', enabled: false, state: 'stopped', mainPage: undefined })] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state.getByRole('heading', { name: 'Fine-tuning is off' })).toBeVisible()
await expect(state).toContainText('An administrator can turn it on.')
await expect(state.getByRole('button', { name: 'Turn on' })).toHaveCount(0)
})
test('an add-on that is not installed says so and sends an administrator to Find add-ons', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state).toHaveAttribute('data-state', 'not-installed')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tuning')
await expect(state.getByRole('heading', { name: 'Fine-tuning is not installed' })).toBeVisible()
await expect(state.getByRole('link', { name: 'Find add-ons' })).toHaveAttribute('href', '/app/addons?tab=browse&q=fine-tuning')
})
test('a reader of an add-on that is not installed gets no link to Find add-ons', async ({ page }) => {
await setup(page, { studio: false, pins: [], apps: [] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state.getByRole('heading', { name: 'Fine-tuning is not installed' })).toBeVisible()
await expect(state).toContainText('An administrator can install it.')
await expect(state.getByRole('link', { name: 'Find add-ons' })).toHaveCount(0)
})
test('a failed add-on shows its reason and, for an administrator, a link to its details', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'fine-tuning', title: 'Fine-tuning', state: 'failed', mainPage: undefined, ui: { entry: '/x.js', sdk: { min: 1, max: 1 }, pages: ['jobs'] } })] })
await page.goto('/app/addons/fine-tuning/jobs')
const state = page.getByTestId('addon-unavailable')
await expect(state).toHaveAttribute('data-state', 'failed')
await expect(state.getByRole('heading', { name: 'Fine-tuning failed to start' })).toBeVisible()
await expect(state).toContainText('stopped responding')
await expect(state.getByRole('link', { name: 'Details' })).toHaveAttribute('href', '/app/addons?detail=fine-tuning')
})
test('the page names a headless add-on by a capitalised id, not the bare identifier', async ({ page }) => {
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'indexer', title: 'indexer', enabled: false, state: 'stopped', mainPage: undefined, admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })] })
await page.goto('/app/addons/indexer/home')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Indexer')
})
test('an old Voice link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
await page.goto('/app/voice')
await expect(page).toHaveURL(/\/app\/addons\/voice\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old model link ends in the add-on with the model in the query', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
await page.goto('/app/voice/org%2Fname')
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recognition\?model=org%2Fname$/)
await expect(page.getByTestId('voices-page')).toBeVisible()
})
test('a viewer without voice_recognition gets the off notice in the add-on', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
// An administrator holds every feature, so the viewer is a member.
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: false }) }))
await serveVoice(page)
await page.goto('/app/addons/voice/voice/recognition')
await expect(page.getByTestId('voice-off')).toBeVisible()
await expect(page.getByTestId('voices-page')).toHaveCount(0)
})
test('the sections are tabs of one page and each tab keeps its address', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
await page.goto('/app/addons/voice/voice')
// No tab in the address opens the default tab.
await expect(page.getByTestId('voices-page')).toBeVisible()
await expectVoiceTabs(page, 'recognition')
const tabs = page.getByTestId('voice-tabs')
await tabs.getByRole('tab', { name: /^Library/ }).click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library$/)
await expect(page.getByTestId('voice-library')).toBeVisible()
await expectVoiceTabs(page, 'library')
await expectOneVoiceTitle(page, 'Voices')
await page.getByTestId('voice-library-new').first().click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library\/new$/)
await expect(page.getByTestId('voice-profile-create')).toBeVisible()
await expectVoiceTabs(page, 'library')
await expectOneVoiceTitle(page, /Add a speech voice/)
await tabs.getByRole('tab', { name: /^Text to speech$/ }).click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/speech$/)
await expect(page.getByTestId('voice-speech')).toBeVisible()
await expectVoiceTabs(page, 'speech')
await expectOneVoiceTitle(page, 'Voices')
await tabs.getByRole('tab', { name: /^From a recording$/ }).click()
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recording$/)
await expect(page.getByTestId('recording-tab')).toBeVisible()
await expectVoiceTabs(page, 'recording')
await expectOneVoiceTitle(page, 'Voices')
})
test('the Text to speech tab says where to get a model when none is installed', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
// Registered last, so it wins over the fixture's model list.
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [] } }))
await page.goto('/app/addons/voice/voice/speech')
const hint = page.getByTestId('speech-no-models')
await expect(hint).toBeVisible({ timeout: 15_000 })
await expect(hint.getByRole('link', { name: /model gallery/ })).toHaveAttribute('href', /\/app\/models$/)
await expectOneVoiceTitle(page, 'Voices')
})
test('a viewer who is not an administrator gets no Library tab', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: true }) }))
await serveVoice(page)
await mockIdentity(page)
await page.goto('/app/addons/voice/voice/recognition')
const tabs = page.getByTestId('voice-tabs')
await expect(tabs.getByRole('tab', { name: /^Recognition/ })).toBeVisible()
await expect(tabs.getByRole('tab', { name: /^Text to speech$/ })).toBeVisible()
await expect(tabs.getByRole('tab', { name: /^Library/ })).toHaveCount(0)
await page.goto('/app/addons/voice/voice/library')
await expect(page.getByTestId('voice-admin-only')).toBeVisible()
await expect(page.getByTestId('voice-library')).toHaveCount(0)
})
test('the older page addresses of the add-on open the matching tab', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
await mockIdentity(page)
const cases = [
['/app/addons/voice/recognition', /\/voice\/voice\/recognition$/, 'recognition'],
['/app/addons/voice/recognition?model=wespeaker-resnet34', /\/voice\/voice\/recognition\?model=wespeaker-resnet34$/, 'recognition'],
['/app/addons/voice/recognition?tab=recording', /\/voice\/voice\/recording$/, 'recording'],
['/app/addons/voice/library?selected=prof-1', /\/voice\/voice\/library\?selected=prof-1$/, 'library'],
['/app/addons/voice/create', /\/voice\/voice\/library\/new$/, 'library'],
['/app/addons/voice/speech?voice=prof-1', /\/voice\/voice\/speech\?voice=prof-1$/, 'speech'],
]
for (const [from, to, active] of cases) {
await page.goto(from)
await expect(page, from).toHaveURL(to)
await expectVoiceTabs(page, active)
}
})
test('there is no Build hub to hold a Voices tab: its address opens the models', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
await page.goto('/app/build')
await expect(page).toHaveURL(/\/app\/models/)
await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"], .dk-hubtabs [data-hub-tab="faces"]')).toHaveCount(0)
})
test('the add-on page identifies, enrols from a side panel and holds a removal behind Undo', async ({ page }) => {
await setup(page, { apps: [voiceApp()] })
await asAdmin(page)
await serveVoice(page)
const calls = await mockIdentity(page, { stored: people, identify: { matches: [match('v-marta', 'Marta Rossi', 0.18), match('v-jonas', 'Jonas Weber', 0.41)] } })
await page.goto('/app/addons/voice/voice/recognition')
await expect(page.getByTestId('voices-page')).toBeVisible()
await page.locator('#voice-probe-audio-file').setInputFiles(clipFile())
await expect(page.getByTestId('voice-probe-clip')).toHaveAttribute('data-state', 'filled')
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
await expect(page.getByTestId('distance-scale')).toBeVisible()
expect(calls.identify).toHaveLength(1)
expect(calls.identify[0].model).toBe('wespeaker-resnet34')
await page.getByTestId('enrol-open').click()
const sheet = page.getByTestId('enrol-sheet')
await expect(sheet).toBeVisible()
await sheet.locator('#voice-enrol-audio-file').setInputFiles(clipFile('new.wav', 6))
await sheet.locator('#voice-enrol-name').fill('Ada Byron')
await sheet.getByLabel(/This person agreed/).check()
await sheet.getByRole('button', { name: /^Enrol/ }).click()
await expect.poll(() => calls.register.length).toBe(1)
expect(calls.register[0].name).toBe('Ada Byron')
await expect(sheet).toHaveCount(0)
await expect(page.getByTestId('known-list')).toContainText('Ada Byron')
await page.clock.install()
await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
await expect(page.getByRole('status').filter({ hasText: 'Jonas Weber' })).toBeVisible()
await page.getByRole('button', { name: 'Undo' }).first().click()
// Past the 10 second delay: a cancelled removal must send nothing.
await page.clock.fastForward(15000)
await page.waitForTimeout(300)
expect(calls.forget).toHaveLength(0)
await expect(page.getByTestId('known-list')).toContainText('Jonas Weber')
})
})
// The built Face module, served the same way. dist/ is not committed, so the
// global setup builds it once per run with the builder of the add-on.
const FT_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../addons/fine-tuning/ui/dist')
const FACE_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../addons/face/ui/dist')
const FACE_BASE = `/api/addons/face/assets/${'e'.repeat(64)}`
test.beforeAll(async () => {
// The global setup has built the bundles already. A build rewrites the
// dist folder that every worker serves, so a worker must not start one.
if (process.env.E2E_ADDON_BUNDLES_BUILT) return
await buildAddonBundles(['face', 'fine-tuning'])
})
const faceApp = () => app({
id: 'face', name: 'face', title: 'Face', icon: undefined, generation: 1, version: '0.1.0', mainPage: '/app/addons/face/recognition',
ui: { entry: `${FACE_BASE}/ui/face.js`, css: [`${FACE_BASE}/ui/face.css`], sdk: { min: 2, max: 2 }, pages: ['recognition'], navigation: [], chatActions: [] },
})
const FT_BASE = `/api/addons/fine-tuning/assets/${'f'.repeat(64)}`
const ftApp = () => app({
id: 'fine-tuning', name: 'fine-tuning', title: 'Fine-tuning', icon: undefined, generation: 1, version: '0.1.0', mainPage: '/app/addons/fine-tuning/jobs',
ui: { entry: `${FT_BASE}/ui/fine-tuning.js`, css: [`${FT_BASE}/ui/fine-tuning.css`], sdk: { min: 2, max: 2 }, pages: ['jobs'], navigation: [], chatActions: [] },
})
async function serveFineTuning(page) {
await page.route(/\/api\/addons\/fine-tuning\/assets\//, route => {
const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/')
const known = { 'ui/fine-tuning.js': 'text/javascript', 'ui/fine-tuning.css': 'text/css' }
if (!known[file]) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
return route.fulfill({ status: 200, contentType: known[file], headers: { 'cache-control': 'no-store' }, body: fs.readFileSync(path.join(FT_DIST, file)) })
})
}
async function serveFace(page) {
await page.route(/\/api\/addons\/face\/assets\//, route => {
const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/')
const known = { 'ui/face.js': 'text/javascript', 'ui/face.css': 'text/css' }
if (!known[file]) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
return route.fulfill({ status: 200, contentType: known[file], headers: { 'cache-control': 'no-store' }, body: fs.readFileSync(path.join(FACE_DIST, file)) })
})
}
async function asFaceAdmin(page, features = {}) {
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true, face_recognition: true, ...features }) }))
}
// The page draws its own title "Faces", once, in the core and in the add-on.
async function expectFacesPage(page, title = 'Faces') {
await expect(page.getByTestId('faces-page')).toBeVisible()
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
await expect(page.getByRole('heading', { name: title, exact: true, level: 1 })).toBeVisible()
}
test.describe('Add-on navigation: the Face recognition page', () => {
// Where the add-on page mounts, the add-on titles it and the shell adds
// nothing; where it cannot mount, the shell names the add-on and nothing
// else does. Either way the page has exactly one title.
test('the Face page that is not enabled on this server has one title, the add-on\'s', async ({ page }) => {
await setup(page, { apps: [faceApp()] })
await asFaceAdmin(page)
await serveFace(page)
await mockIdentity(page, { faceModels: [] })
await page.goto('/app/addons/face/recognition')
await expectFacesPage(page)
await expect(page.getByText('Not enabled on this server')).toBeVisible()
await expect(page.getByTestId('addon-unavailable')).toHaveCount(0)
await expect(page.locator('h1')).toHaveCount(1)
})
test('the Face page of a stopped add-on has one title, the shell\'s, because the add-on cannot draw', async ({ page }) => {
await setup(page, { apps: [{ ...faceApp(), enabled: false, state: 'stopped' }] })
await asFaceAdmin(page)
await serveFace(page)
await mockIdentity(page)
await page.goto('/app/addons/face/recognition')
await expect(page.getByTestId('addon-unavailable')).toHaveAttribute('data-reason', 'disabled')
await expect(page.getByTestId('faces-page')).toHaveCount(0)
await expect(page.locator('h1')).toHaveCount(1)
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Face')
})
test('the Face add-on draws the same page and test ids as the core page', async ({ page }) => {
await setup(page, { apps: [faceApp()] })
await asFaceAdmin(page)
await serveFace(page)
await mockIdentity(page)
await page.goto('/app/addons/face/recognition')
await expectFacesPage(page)
await expect(page.locator('[data-testid="addon-surface"][data-addon="face"]')).toBeVisible()
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
})
test('an old Face link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
await page.goto('/app/face')
await expect(page).toHaveURL(/\/app\/addons\/face\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old model link ends in the add-on with the model in the query', async ({ page }) => {
await setup(page, { apps: [faceApp()] })
await asFaceAdmin(page)
await serveFace(page)
await mockIdentity(page)
await page.goto('/app/face/org%2Fname')
await expect(page).toHaveURL(/\/app\/addons\/face\/recognition\?model=org%2Fname$/)
await expect(page.getByTestId('faces-page')).toBeVisible()
})
test('a viewer without face_recognition gets the off notice in the add-on', async ({ page }) => {
await setup(page, { apps: [faceApp()] })
// An administrator holds every feature, so the viewer is a member.
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, face_recognition: false }) }))
await serveFace(page)
await page.goto('/app/addons/face/recognition')
await expect(page.getByTestId('face-off')).toBeVisible()
await expect(page.getByTestId('faces-page')).toHaveCount(0)
})
test('a member without fine_tuning sees the off notice inside the add-on, and the core call answers 403', async ({ page }) => {
await setup(page, { apps: [ftApp()] })
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, fine_tuning: false }) }))
await page.route('**/api/fine-tuning/jobs', route => route.fulfill({ status: 403, json: { error: 'forbidden' } }))
await serveFineTuning(page)
await page.goto('/app/addons/fine-tuning/jobs')
await expect(page.getByTestId('tool-off')).toBeVisible()
})
test('Fine-tune titles itself with its name and keeps the Experimental badge out of the h1', async ({ page }) => {
await setup(page, { apps: [ftApp()] })
await asAdmin(page, { fine_tuning: true })
await page.route('**/api/fine-tuning/jobs', route => route.fulfill({ json: [] }))
await serveFineTuning(page)
await page.goto('/app/addons/fine-tuning/jobs')
await expect(page.getByTestId('fine-tune-page')).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Fine-tune')
await expect(page.locator('h1 .bt-experimental')).toHaveCount(0)
await expect(page.locator('.bt-experimental')).toBeVisible()
})
test('an old fine-tuning link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
await page.goto('/app/fine-tune')
await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old fine-tuning link opens the add-on when it is installed and running', async ({ page }) => {
await setup(page, { apps: [ftApp()] })
await serveFineTuning(page)
await page.goto('/app/fine-tune')
await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\/jobs$/)
})
test('an old quantization link ends in the add-on shell, which says the add-on is not available', async ({ page }) => {
await setup(page, { apps: [] })
await asAdmin(page)
await page.goto('/app/quantize')
await expect(page).toHaveURL(/\/app\/addons\/quantization\//)
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
})
test('an old quantization link opens the add-on when it is installed and running', async ({ page }) => {
await setup(page, { apps: [app({ id: 'quantization', name: 'quantization', title: 'Quantization', icon: undefined, mainPage: '/app/addons/quantization/jobs', ui: { entry: `/api/addons/quantization/assets/${'a'.repeat(64)}/ui/quantization.js`, css: [], sdk: { min: 2, max: 2 }, pages: ['jobs'], navigation: [], chatActions: [] } })] })
await page.goto('/app/quantize')
await expect(page).toHaveURL(/\/app\/addons\/quantization\/jobs$/)
})
test('there is no Build hub with a fine-tuning or quantization tab', async ({ page }) => {
await setup(page)
await asFaceAdmin(page)
await page.goto('/app/build')
await expect(page).toHaveURL(/\/app\/models/)
await expect(page.getByRole('tab', { name: /Fine-tun/ })).toHaveCount(0)
await expect(page.getByRole('tab', { name: /Quantiz/ })).toHaveCount(0)
await expect(page.locator('.dk-hubtabs [data-hub-tab="fine-tune"], .dk-hubtabs [data-hub-tab="quantize"]')).toHaveCount(0)
})
test('there is no Build hub to hold a Faces tab', async ({ page }) => {
await setup(page, { apps: [] })
await asFaceAdmin(page)
await page.goto('/app/build')
await expect(page).toHaveURL(/\/app\/models/)
await expect(page.locator('.dk-hubtabs [data-hub-tab="faces"]')).toHaveCount(0)
})
test('the add-on page identifies, enrols from a side panel and holds a removal behind Undo', async ({ page }) => {
await setup(page, { apps: [faceApp()] })
await asFaceAdmin(page)
await serveFace(page)
const calls = await mockIdentity(page, { storedFaces: faces, faceIdentify: { matches: [match('f-anna', 'Anna Kowalski', 0.21), match('f-tomas', 'Tomas Ribeiro', 0.52)] } })
await page.goto('/app/addons/face/recognition')
await expect(page.getByTestId('faces-page')).toBeVisible()
await page.locator('#face-probe-image-file').setInputFiles(photoFile())
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toContainText('Probably Anna Kowalski')
expect(calls.faceIdentify).toHaveLength(1)
expect(calls.faceIdentify[0].model).toBe(FACE_MODEL)
expect(calls.faceIdentify[0].img).toMatch(/^data:image\//)
await page.getByTestId('enrol-open').click()
const sheet = page.getByTestId('enrol-sheet')
await expect(sheet).toBeVisible()
await sheet.locator('#face-enrol-image-file').setInputFiles(photoFile('new.png'))
await sheet.locator('#face-enrol-name').fill('Ada Byron')
await sheet.getByLabel(/This person agreed/).check()
await sheet.getByRole('button', { name: /^Enrol/ }).click()
await expect.poll(() => calls.faceRegister.length).toBe(1)
expect(calls.faceRegister[0]).toMatchObject({ model: FACE_MODEL, name: 'Ada Byron' })
await expect(sheet).toHaveCount(0)
await expect(page.getByTestId('known-list')).toContainText('Ada Byron')
await page.clock.install()
await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click()
await expect(page.locator('.vc-undo').filter({ hasText: 'Anna Kowalski' })).toBeVisible()
await page.getByRole('button', { name: 'Undo' }).first().click()
// Past the 10 second delay: a cancelled removal must send nothing.
await page.clock.fastForward(15000)
await page.waitForTimeout(300)
expect(calls.forget).toHaveLength(0)
await expect(page.getByTestId('known-list')).toContainText('Anna Kowalski')
})
test('with the removal left to run, the add-on sends the face forget call', async ({ page }) => {
await setup(page, { apps: [faceApp()] })
await asFaceAdmin(page)
await serveFace(page)
await page.clock.install()
const calls = await mockIdentity(page, { storedFaces: faces })
await page.goto('/app/addons/face/recognition')
await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click()
await page.clock.runFor(11000)
await expect.poll(() => calls.forget.length).toBe(1)
expect(calls.forget[0]).toMatchObject({ face: true, id: 'f-anna' })
})
})
test.describe('Add-on navigation: the addons feature gate', () => {
const OFF = { addon_navigation: true, addons: false, fine_tuning: false, quantization: false }
// With add-ons off the server answers 503 on every add-on route, so a request
// to the pins or built-in routes is a failure of the page, not of the server.
async function watch(page) {
const calls = []
page.on('request', request => {
const { pathname } = new URL(request.url())
if (pathname === '/api/addons/pins' || pathname === '/api/addons/builtin') calls.push(pathname)
})
return calls
}
test('with add-ons off the sidebar has no Add-ons entry, no pinned app and no fine-tuning or quantization entry', async ({ page }) => {
const calls = await watch(page)
await setup(page, { features: OFF, user: ADMIN })
await page.goto('/app')
const nav = page.getByRole('navigation')
await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible()
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(0)
await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0)
await expect(nav.getByRole('link', { name: /Fine-?tun/i })).toHaveCount(0)
await expect(nav.getByRole('link', { name: /Quantiz/i })).toHaveCount(0)
expect(calls).toEqual([])
})
test('with add-ons off there is no Add-ons, fine-tuning or quantization entry, in either layout', async ({ page }) => {
for (const addon_navigation of [true, false]) {
await setup(page, { features: { ...OFF, addon_navigation }, user: ADMIN })
await page.goto('/app')
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/agents"]')).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/addons"]')).toHaveCount(0)
for (const name of [/^Add-ons$/, /Fine-?tun/i, /Quantiz/i]) {
await expect(page.getByRole('tab', { name })).toHaveCount(0)
await expect(page.locator('.hub-tabs, [role="tablist"]').getByRole('link', { name })).toHaveCount(0)
}
await page.unroute('**/api/features')
}
})
for (const address of ['/app/addons', '/app/addons/studio/home', '/app/fine-tune', '/app/quantize']) {
test(`with add-ons off ${address} ends at /app and calls no add-on route`, async ({ page }) => {
const calls = await watch(page)
await setup(page, { features: OFF, user: ADMIN })
await page.goto(address)
await expect(page).toHaveURL(/\/app\/?$/)
await expect(page.getByRole('navigation').getByRole('link', { name: 'Home' })).toBeVisible()
expect(calls).toEqual([])
})
}
test('an older server without the addons key keeps today\'s behaviour', async ({ page }) => {
await setup(page)
await page.goto('/app')
const nav = page.getByRole('navigation')
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(2)
})
})