mirror of
https://github.com/mudler/LocalAI.git
synced 2026-10-28 17:18:49 -04:00
* refactor(addons): one name and one verb pair in the add-ons UI The sidebar, the page and the tabs now say Add-ons. The tabs are My add-ons, Find add-ons and Sources. Turn on and Turn off replace Enable, Start, Disable and Stop wherever they label a button, and the state words read On and Off. The subtitle no longer names the owner. Other locales get the new keys; the main labels are translated and the rest carry the English text until a translator refines them. A unit test checks that every locale has the keys of English. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): show what an add-on does on its card and move actions to a menu A card now shows the add-on description, an "Adds a page" line and the number of chat tools when they apply. The pin button and the bare arrows are replaced by one overflow menu with Show in sidebar, Details and Remove. Reordering a pinned add-on stays in the same menu. Remove asks first and runs the typed uninstall action; only administrators see it. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add a preview pane for the selected add-on Selecting a card, or choosing Details in its menu, opens a pane beside the list. It shows the name and state, the description, what the add-on adds, Turn on or Turn off and Remove, and its version and source. The pane replaces the modal sheet and uses the data the list already has. The source comes from the installed list that the host already serves to administrators. Screenshot slots show only when the host lists images for the add-on; today it lists none, so no slot is drawn. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): clear first-run and empty states for My add-ons With nothing installed, an administrator now sees three add-ons from the sources, each with Install and turn on, and a link to Find add-ons. A search that finds nothing says that no add-on matches and offers to clear the search, instead of saying that nothing is installed. Install and turn on is one action of the user: it installs, then turns the add-on on, with a separate operation identity for each step and the progress or the reason for a failure shown on the card. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): one button and folded details on Find add-ons entries Each entry shows its description, an "Adds a page" label when the library entry declares a main page, and one button: Install and turn on. An add-on that is installed but off offers Turn on. Details is folded into the entry and shows the trust and build facts the entry already carries, so the separate details sheet is gone. A backend keeps a plain Install, because a backend has no on or off. The library entry carries no tool count, so the entry cannot say how many chat tools an add-on adds. That label appears on My add-ons, where the list knows it. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): install and turn on in one dialog with inline progress Install and turn on now asks once. The dialog then stays open and shows two steps, the install and the turn on, and ends on the result or on the reason a step failed with a way to try again. A failed turn on keeps the install and the retry only turns the add-on on. Each step is its own action with its own operation identity, so an unknown outcome still blocks further actions on the add-on until it is read or dismissed. The first-run cards use the same dialog. The two-step flow is a plain function with unit tests. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): fold the Installed tab into My add-ons The page now has three tabs: My add-ons, Find add-ons and Sources. What the Installed tab showed is in the preview pane of each add-on, under More details for administrators: health, trust, compatibility, storage, data recovery and updates, with Check for updates and Upgrade. Remove and Turn on or Turn off are on the card and in the pane. An old ?tab=installed address opens My add-ons, and the management link ?manage=ID opens the details of that add-on. A card shows an update mark when the host reports one. Closing the pane returns focus to its card. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs(addons): describe the new add-ons page labels Update the add-ons feature page and the pages that point at it: the sidebar item and the page are called Add-ons, the tabs are My add-ons, Find add-ons and Sources, Turn on and Turn off replace Start and Stop, the card menu replaces the pin control, and the preview pane holds what the Installed tab showed. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): say what is wrong on the page of an add-on that cannot open The page of an add-on that cannot open now depends on the add-on. One that is installed but off says "<name> is off" and offers Turn on to an administrator. One that is not installed says so and sends an administrator to Find add-ons. One that failed shows the reason and a link to its details. A reader gets a line that says who can fix it, and no button. The page title is the name of the add-on, with a capital first letter when the host lists no title, and no longer the bare identifier. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(chat): put the add-on tools control next to MCP and say what off means The add-on tools control moves from the chat header to the composer, right after the MCP control, because both decide what the model may call in this chat. Its menu starts with one line: an add-on that is off here does nothing in this chat. Only add-ons that ship tools appear in it, as before. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * chore(addons): clear lint warnings of the new components Add the lint header the add-on components use for JSX-only imports, drop an unneeded directive, stop exporting a helper from a component file and extract the dependencies of an effect. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): update the backend library specs for the folded entry details A backend says its kind on the entry, an add-on says it under Details, Details is a fold of the entry and an installed backend has no Install button. Assisted-by: Claude:claude-sonnet-5-5 [Claude Code] 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>
971 lines
55 KiB
JavaScript
971 lines
55 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 } = {}) {
|
|
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: { addon_navigation: true } }))
|
|
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 })
|
|
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: 'Chat' })).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()
|
|
await expect(pane.getByRole('img', { name: 'State: Off' })).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)
|
|
})
|
|
|
|
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: 'Add your first add-on' })).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 and turn on' })).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 and turn on' }).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 40px 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(40)
|
|
})
|
|
|
|
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: 'Chat' })).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 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/build')
|
|
await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveCount(0)
|
|
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/build')
|
|
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('the Build hub has no Voices tab', async ({ page }) => {
|
|
await setup(page, { apps: [] })
|
|
await asAdmin(page)
|
|
await page.goto('/app/build')
|
|
await expect(page.locator('.dk-hubtabs')).toBeVisible()
|
|
await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"]')).toHaveCount(0)
|
|
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: [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('the Build hub has no fine-tuning or quantization tab', async ({ page }) => {
|
|
await setup(page)
|
|
await asFaceAdmin(page)
|
|
await page.goto('/app/build')
|
|
await expect(page.locator('.dk-hubtabs')).toBeVisible()
|
|
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('the Build hub shows no Faces tab', async ({ page }) => {
|
|
await setup(page, { apps: [] })
|
|
await asFaceAdmin(page)
|
|
await page.goto('/app/build')
|
|
await expect(page.locator('.dk-hubtabs')).toBeVisible()
|
|
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' })
|
|
})
|
|
})
|