Files
LocalAI/core/http/react-ui/e2e/addon-navigation.spec.js
T
mudler-agentandEttore Di Giacinto 7c74d26555 feat(addons): run the add-on owner as its own workload (#12867)
* feat(addons): generate and load a static carrier key set

A split deployment needs the owner and the frontends to share
certificates and a signing key that outlive a process. The loopback
carrier makes fresh keys at every start, so it cannot serve that.

GenerateCarrierKeys writes a CA certificate, an owner leaf and a
frontend leaf, and the frontend signing key pair. It drops the CA key,
so nobody can issue a third leaf, and it refuses to overwrite any file.
LoadCarrierConfig reads only the files of one role and refuses key
files that group or other users can access.

The owner never forwards, but NewCarrier still requires a signing key,
so the owner config gets a throwaway key in memory instead of the
frontends' private key.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add util addons-carrier-keys

An operator who runs the add-on owner and its frontends as separate
workloads needs a way to make the shared key set. The command wraps
GenerateCarrierKeys: --out names the directory and --owner-host, which
can repeat, sets the names the owner certificate is valid for. It prints
which files belong on the owner and which on the frontends.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): add the owner and frontend roles to local-ai run

Every local-ai run process is its own add-on owner, so a distributed
deployment cannot scale its frontends: replicas that share a data
volume fight over the owner lock.

--addons-role selects combined (today's behaviour, the default), owner
or frontend. The owner and frontend roles also take --addons-owner-address
and --addons-carrier-dir, and the owner takes --addons-owner-listen. They
use the fixed carrier identities and the key set from the carrier key
directory. The frontend role has no add-on root, so it never creates the
add-ons folder. A bad role setup stops startup with a message that names
the flag.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): keep the frontend role away from owner state

A frontend holds no add-on state, but it still wrote the library
sources file of the data path, which is the owner's file. The frontend
role now skips the library option and reads only the galleries named by
the flags, and logs a warning when it has no account database, because
its pins are then kept per frontend.

The add-on manager rejects a relative root even for a forwarding
frontend, so the frontend config names the absolute path where an owner
would keep its state. Nothing creates or opens it, and a spec checks
that the data path stays empty.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* test(addons): run the owner and frontends as separate applications

The split roles are only useful if an owner and its frontends work as
separate processes that share nothing but the carrier keys and the owner
address. A spec now builds them as separate Application values with
their own data paths and keys from GenerateCarrierKeys.

It covers a frontend that lists apps and reads add-on status through the
owner, two frontends on one key set, a frontend that starts while the
owner is down and answers unavailable, a second owner refused on the
same root, and an owner that restarts on the same root and address and
still serves the installed add-on to the same frontend.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe running the owner as its own workload

An operator who runs several frontend replicas had no documented way to
stop each one from being its own add-on owner. The add-ons page now has
a section for the owner and frontend roles: one owner replica with its
own volume and a recreate-style rollout, a stable owner address, key
generation and per-role mounts, a Compose example, what a restart and an
owner outage look like, the models-path limit, and the frontend limits.
The singleton section names the roles and the distributed-state mode,
and the distributed mode page links to the new section.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): gate the addons feature in /api/features

One function, Application.AddonsFeature, decides whether the add-on
side is on. A combined or owner process answers true when add-ons are
enabled and the owner manager exists. A frontend answers true when the
owner answered the carrier's audience call within 2 s, and keeps that
answer for 10 s so /api/features does not dial the owner per request.

/api/features reports the result as "addons", and fine_tuning and
quantization take the same value, because both are add-ons. The
frontend cache is per-instance state.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): answer 503 on every add-on route when add-ons are off

With --addons-enabled=false the real add-on, fine-tuning and
quantization routes and services are not registered. One catch-all per
prefix answers the typed unavailable response of an absent owner, for
any path under it, so a client cannot tell which routes exist. The
response is the one the gateway already writes (gateway.WriteUnavailable
over typedError), not a second format.

With add-ons on, the same routes sit behind RequireAddonsFeature, which
reads Application.AddonsFeature, so a frontend that cannot reach the
owner gives the same answer. The per-user feature middleware still runs
after the gate. No endpoint is added, so the swagger, /api/instructions
and RouteFeatureRegistry surfaces do not change.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): build no carrier for a frontend with add-ons off

addonConfigFor already returned the zero config before it read a role
flag when add-ons are disabled. Say so on the way out: when a role,
owner address, listen address or carrier directory flag is set, log one
info line that the flags are ignored. A frontend with
--addons-enabled=false needs no owner address and no key directory,
loads no keys and builds no carrier.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(ui): hide add-on entries when the addons feature is off

The UI reads features.addons from /api/features. When it is false:
the sidebar shows no Add-ons entry and no pinned apps, the 404 page
lists no add-on destination, the shell does not call /api/addons/pins
or /api/addons/builtin, and /app/addons, /app/addons/*, /app/fine-tune
and /app/quantize lead back to /app. The Build hub tabs for add-ons,
fine-tuning and quantization were already keyed on features and now
follow the gate. An absent key (an older server) counts as on.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): describe the addons feature gate

The --addons-enabled row now says the UI hides the add-on entries and
every add-on, fine-tuning and quantization route answers 503. A new
subsection describes the addons key of /api/features, the 10 s frontend
cache as per-instance state, and that a frontend with add-ons off needs
no owner address or key directory. The fine-tuning and quantization
pages say they are off with --addons-enabled=false.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): give a frontend no add-on state root

OwnerConfig.validate required an absolute root for every role, so a
frontend named a path under the data directory that nothing was allowed
to open. Only the owner has a state root now: validate checks it when
the instance is the owner, and the frontend role leaves Root empty.
Nothing on a frontend reads or creates it.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): make the carrier signing key optional for the owner

NewCarrier required an Ed25519 signing key of every instance, so the
owner role loaded a throwaway key in memory that it never used. The
owner never signs an outbound request, so it now needs none:
NewCarrier accepts an empty key when the instance is the owner and
still requires a whole key from a frontend, and a malformed key is
refused for both. LoadCarrierConfig no longer generates one. The owner
still verifies frontend signatures with frontend-sign.pub.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* Keep the owner address out of the add-on identity file

The owner address is runtime configuration, so storing it and comparing
the whole file made a moved owner refuse its own root. The identity file
now holds the owner ID and storage ID only. An older file that still has
an address loads, and the address is ignored. A different owner ID or
storage ID is still refused.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): state that an existing root loads under the owner role

The owner identity no longer stores its address, so a root written by a
single node loads under the owner role when the owner and storage
identity match. Say so, and keep the caveat about absolute paths in the
journal.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): refuse library-source routes on a frontend

A frontend must keep no owner state, but the library-source routes, the
ApplyLibraries call and the startup official-libraries step still opened
and wrote addons/sources.json in its data path.

Mark the frontend role in the add-on config, also when add-ons are off.
On a frontend, LibrarySources returns the unavailable error,
ApplyLibraries does nothing and the startup step skips the store. The
six /api/library/sources routes answer 503 with the body that the add-on
feature gate writes, and an X-LocalAI-Reason header names the cause.
Reads are refused too: the carrier has no verb for them. Owner and
combined roles are unchanged.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* fix(addons): put the reason in the unavailable body

WriteUnavailable wrote the plain text "addon request failed", and the
library-source refusal put its reason in an X-LocalAI-Reason header.

Write the LocalAI error envelope instead, with a message, a type and the
code "unavailable", and keep the 503 status. The message names the
cause: add-ons are off on this instance, the add-on owner is not
reachable, or library sources are managed on the owner. Remove the
header.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): serve library calls on the add-on owner

Add the carrier verbs a frontend needs to work on the library through
the owner: library-list, library-add, library-update, library-enable,
library-disable, library-remove, library-backend-resolve and
library-backend-releases. A call carries the operation in one
LibraryCall field of the wire request, so an old owner rejects it as an
invalid binding. Every verb but the three reads is a mutation, and the
reply is an envelope of data or a typed error, so a frontend gives the
status the owner's own routes give.

The owner serves them through gateway.LibraryAdmin, implemented in the
application on the same LocalAdmin as the owner's HTTP routes. It runs
the owner's administrator check before any store call.

Add two peer-authenticated reads that take no actor and leave the
audience reply unchanged: GET /addons/capabilities (protocol, calls and
the owner's OS and architecture) and GET /addons/library/snapshot (the
gallery-relevant source list and its version).

Split the backend install: Installer.Resolve is the owner's half
(resolve, pick the meta backend for the asker's capability, pin by
digest) and returns a Plan; the node that installs runs the collision
check and the gallery install itself. Move the source administration
behind library.Admin and add library.ReconcileInfos for a source list
read from the owner.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): forward library routes from a frontend to the owner

A frontend refused the library-source routes and did not serve the
backend library routes. Forward them instead, and keep the owner the
only writer of the sources.

The library-source routes work on library.Admin: the local store on a
combined or owner process, the carrier on a frontend. The frontend
checks the calling account (a legacy API key is refused with 401), and
the owner checks the administrator right again before any store call.
With add-ons off, a frontend answers these routes with a 503.

A frontend asks the owner for its source list every 60 seconds through
the peer-authenticated snapshot call and rebuilds its model and backend
gallery lists, and the official-libraries switch, only when the version
changed. If the owner does not answer, it keeps the last lists and marks
them stale. Changing official_libraries on a frontend is refused with
409.

The backend library install is split. The owner resolves the entry and
pins the image; the frontend runs the gallery install on its own node,
with its own size limit and strict mode. A frontend refuses library
calls to an owner that does not serve them, or that runs on another OS
or architecture.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(addons): document library operations on a frontend

Replace the frontend limits that said a frontend refuses the library
routes with what happens now: the library sources and the backend
library go through the owner, the gallery lists follow the owner every
60 seconds, official_libraries is set on the owner, and the owner and
the frontends must run on one OS and architecture.

Add specs for the split owner: a frontend lists sources through the
owner, a source added on a frontend reaches the owner and then the other
frontend after a refresh, a write with the owner down answers 503 with
the error envelope, official_libraries answers 409 on a frontend and a
change on the owner reaches the frontend's gallery lists, the lists are
kept and marked stale while the owner is down, a platform mismatch and a
legacy API key are refused, and a frontend installs a backend into its
own path from the owner's plan.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): check the shared models path with a probe

Exports write into the models path of the owner, and frontends load
models from their own path. A wrong mount fails silently: the export
succeeds and the model never appears.

The owner writes a probe file with a random nonce into its models path
at start. A frontend fetches the nonce over the carrier and checks at
start, then every 60 seconds, that the same file is visible at the same
absolute path. On a mismatch it logs an error, reports model_exports
false and blocks its export and import routes. The owner refuses an
export or import while its own probe is missing, and keeps running if
it cannot write the probe. An unreachable or older owner gives
"unknown" and leaves exports on.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* docs(api): describe the library refusal and forwarding

The library-source and backend-library routes forward to the add-on
owner on a frontend and answer 503 when the owner cannot serve them.
Add the 503 to their swagger annotations, regenerate the swagger files,
and say in /api/instructions that a frontend forwards these routes.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): let frontends run the backend update check through the owner

In named-user mode a frontend has no user to act as, so the background
backend update check failed there. Add one read-only system call for
the release lookup. The owner accepts it only on the peer-authenticated
route from a pinned frontend, only for backend-releases, and without an
actor. Calls through /addons/forward cannot carry the mark, and every
user-facing call still goes through the administrator check.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(mcp): expose the backend update check as a tool

Add list_backend_library_updates, a read-only tool for
GET /backends/library/updates. The in-process client reads the
library installer, and the HTTP client calls the route. The six
library source routes stay out of the catalog: the assistant has no
per-call actor and no approval card for library changes.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(api): add export status routes for fine-tuning and quantization

The add-on UIs could not know that model exports are blocked until a user
hit the refusal of the export route. Add GET /api/fine-tuning/status and
GET /api/quantization/status. Each returns {"model_exports": bool}, from
the same check that drives the model_exports key of /api/features.

The routes sit behind the add-ons gate, so they answer 503 when add-ons
are off. They are listed in the swagger spec, the instructions registry,
the route-feature registry and the feature docs.

Assisted-by: Claude:claude-sonnet-5-5
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>

* feat(addons): disable exports when the models probe fails

The fine-tuning and quantization pages now read the new status route
and turn off the export form, the checkpoint export button and the
import button when model_exports is false. A short reason shows next
to the control. A failed or slow answer leaves the controls on, as the
server does for an unknown check.

Assisted-by: Claude:claude-sonnet-5-5
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-10-24 14:29:39 +02:00

1031 lines
58 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, 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 })
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' })
})
})
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: 'Chat' })).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 the Build hub has no Add-ons, fine-tuning or quantization tab, 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/build')
await expect(page.getByRole('tab', { name: 'Agents' }).or(page.getByRole('link', { name: 'Agents' })).first()).toBeVisible()
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: 'Chat' })).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)
})
})