mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
* feat(addons): model execution status observations Keep observation lifetimes explicit before runtime integration. The pure reducer rejects retries and stale replies using exact binding identity. Cover all eleven trace families, the complete transition matrix and bounded histories with safety and liveness checks. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): visualize execution status traces Generate a self-contained viewer from the real reducer and shared traces. Check independent expectations, coverage and bounded invariants before exporting data. Detect stale artifacts and nondeterministic generation without maintaining a second reducer in JavaScript. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): honor the viewer artifact root Keep default viewer output under SDD_LOCAL_ROOT when configured.\nLet go generate use the same default without forcing a source artifact.\nRetain the portable fallback and explicit output and check semantics. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): add typed extension contracts Validate optional home commands, execution bindings and requested host operations without changing manifest versions or granting authority. Decode bounded execution status payloads conservatively and document strict staging compatibility for older hosts. Assisted-by: nib:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): reject noncanonical extension keys Reject case aliases before typed decoding can override canonical fields or bypass presence checks. Keep locale maps outside exact-key checks. Add regression coverage for status, declaration objects, and nested entries. All status errors return unknown readiness and isolation. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): reject root enums for status tools Status tools must accept the empty object. The supported schema enums contain only strings, so a root enum excludes every object. Reject root enums only for execution status bindings. Cover incompatible root constraints and optional properties without changing shared validation. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): project typed home commands Resolve declared home destinations and display metadata in the installed Apps view. Pin packaged command icons to immutable asset URLs. Cover strict declaration transport, immutable receipts, exact health echo, and process restart. Keep legacy packages and disabled installed intent. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): preserve typed lifecycle declarations Copy execution and host-operation declarations from installed receipts. The existing capability completeness test exposes the missing fields. Exercise real staged Manager upgrades, activation rollback and recovery. Check installed Apps metadata through pinned mTLS frontends for each actor. Verify mutation isolation and name receipt replay coverage accurately. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): stabilize fixture archive digests Map iteration changes tar entry order between identical publishes. Sort fixture filenames so unchanged packages retain their digest. Check repeated publish bytes, digests and lexical archive entry order with and without a manifest transform. Register the regression case. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): resolve explicit host policy grants Load strict administrator policy files once before application startup. Honor explicit empty overrides without reading ignored policy files. Copy programmatic and manager grants to isolate startup authority. Keep the legacy CLI fallback until the official deployment policy ships. The generic resolver already defaults to empty grants. Verify config, CLI, hostops and execution-status checks with GOMAXPROCS=2, -p=2 and -ldflags="-s -w" to fit the available build disk. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): select explicit deployment grants Move official host grants out of the generic executable into reviewed host deployment policy. Stock Docker explicitly selects the shipped file; unconfigured binaries and nil or empty programmatic grants grant nothing. Document the standalone upgrade break and deliberate operator selection. Do not infer consent from installed addons or merge explicit overrides. Guard policy packaging and generic configuration boundaries with tests. Verify executionstatus, viewer -check, config, CLI, packaging and root architecture tests with auth, GOMAXPROCS=2 and bounded build parallelism. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): gate activation on required host ops Refuse unmet required requests before package publication or runtime withdrawal. Keep installed intent unavailable when startup grants change. Optional requests permit degraded operation and never create authority. Carry bounded admin diagnostics through Apps and the owner carrier. Keep startup policy immutable and broker authorization authoritative. Verify executionstatus, viewer -check, lifecycle, packages, updates, hostops, Apps and gateway offline with auth and bounded parallelism. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): guard host operation registry parity Compare startup admission names against real builtin registrations so registry drift cannot silently change request satisfaction. Cover dynamic progress and stop operations and connector inbound. Reject missing, extra, duplicate and case-changed names with negative controls. Record both regression cases in the suite inventory. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): fence status observation effects Keep observation ownership in admitted runtime operations. Deny all correlated callbacks before dispatch, including inbound-class callbacks. Reject status outputs before callers can open or adopt artifact handles. Retain bounded denial correlations until the runtime endpoint closes so late callbacks cannot recover ordinary authority. Preserve independent ordinary operations and document the dedicated read API for T9. Verify the status model, viewer, and race-enabled gateway, hostops and runtime suites with the pinned offline environment. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): drain callbacks before retirement Server.Close does not wait for active callback requests. Clearing retired observation correlations first can restore inbound authority. Stop endpoint admission before closing connections. Drain admitted requests before releasing correlations, and refuse dispatch after Stop. Keep locks out of handlers and cleanup waits so cancellation cannot deadlock teardown. Cover pending inbound callbacks, decoded authorization paused across Stop, and handlers that ignore cancellation. Preserve ordinary authorized work on other endpoints. Document delayed reclamation under the existing bound. Verify the status model and viewer, then race-test gateway, hostops, runtime, the model and root architecture checks with auth enabled. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): bind owner status to exact clients Add authenticated, versioned status reads over the owner carrier. Revalidate actor permissions and provider selection before dispatch and before returning an observation. Keep status separate from authority. Admit the captured client against its immutable identity under the withdrawal lock. Never dispatch an old binding through a replacement. Generate runtime operation IDs on the owner and retain the T8 effect fence. Cover forged identities, strict DTOs, stale replies, restart ordering, wire discriminators, and unchanged model exclusion of uiOnly tools. Document the T10 callback and request-shell integration requirements. Verify the model and viewer, then race-test gateway, runtime, tools, and the model with auth enabled. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): compose owner status with runtime Exercise installed addon processes through the authenticated mTLS carrier. Keep runtime clients real while channel barriers control lookup ordering. Reject captured clients after same-generation replacement before dispatch. Reject completed status reads when replacement precedes owner consumption. Prove fresh bindings succeed and record named tools and host operation IDs. Verify the model and viewer, then race-test gateway, runtime, tools, the model, and the protocol fixture package with auth enabled. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): scope execution status observations Run the execution status reducer within one explicit request lifetime. Coalesce consumers without retries and withdraw stale or canceled reads. Keep owner admission and production lifecycle integration separate for review before wiring the shell into requests and turns. Assisted-by: OpenAI:ChatGPT Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): isolate consumer cancellation Consumer cancellation during validation must not withdraw a valid observation from other consumers. Return unknown locally before shared invalidation, while preserving parent closure and freshness failures. Cover cancellation and deterministic deadline expiry with one-read reducer traces and live-consumer checks. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): track selection mutation epochs Detect selection ABA at config and owner mutation boundaries. Clone provider pins for Studio readers and share synchronized selection state across config value copies. Track lifecycle publication, candidate rollback and recovery transitions. Keep reads and unchanged publications stable. These lifetime epochs do not replace current-incarnation checks or grant execution authority. Verify race tests for config and the add-on Manager, plus application compilation with auth tags. Request binding remains separate work. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): expose owner binding transport Add a host-only binding API with an optional owner resolver. Forward conversation claims over authenticated mTLS with strict versioned DTOs. Compare full bindings during validation and deny missing resolvers. Keep ordinary tool execution unchanged through an optional backend. Test trusted resolver fixtures, stale bindings, denial and compatibility. Application authority and store revisions remain a separate milestone. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): fence selection snapshots with epochs Read actor-scoped records and opaque mutation tokens under store locks. Invalidate tokens after successful eligibility changes, including ABA. Keep reads, rejected writes and turn history from invalidating tokens. Clone mutable records so callers cannot bypass mutation tracking. Support authoritative revisions only for memory-only JSON stores. File stores lack exclusive ownership; GORM lacks persisted revisions. Fail closed for both rather than trust frontend-local state. Ordinary chats remain unchanged; application integration stays separate. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): enforce persistent JSON ownership Acquire lifetime sidecar locks before loading or repairing JSON data. Enable selection revisions only under cooperating exclusive ownership. Close drains operations before releasing ownership and rejects later use. Clean up unpublished stores when application initialization fails. Retain published locks through Shutdown because HTTP users may remain live. Document coordinated upgrades and the advisory local-filesystem boundary. Keep GORM revisions unsupported pending a separate approved design. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: Codex:gpt-5 * test(application): isolate warning log capture Run warning assertions in isolated test subprocesses. The suite leaves background log producers running, which race with global logger changes. Keep the parent logger unchanged and check child exits and completion markers. Preserve pin persistence and ownership warning assertions. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): resolve local owner execution bindings Resolve execution providers from owner-held chat and agent snapshots. Fence bindings with selection epochs, mutation tokens and live runtime identity. Recompute every component during status authorization. Keep named-user and SQL observations unavailable until authoritative access and shared selection revisions can detect ABA changes. Drain restart test producers before releasing JSON store ownership. Register six product resolver cases in the gateway suite inventory. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): bound product scopes to chat turns Keep request-local product state alive through tool calls, checkpoints and persistence. Give chat priming its own scope instead of seeding the next turn with a closed observation. Exercise one reducer read per scope across prompt and tool consumers. Check closure of pending reads on normal completion and interruption. Provider wiring and safety projection remain separate work. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(studio): consume typed execution providers Replace the Sandbox-specific status heuristic with owner-bound typed reads. Share one reducer observation across turn consumers and close it at the outer scope boundary. Keep priming and standalone requests independent. Sandbox exposes a separate callback-free typed read while preserving its private Settings payload. Unknown backends never imply isolation. Provider isolation does not prove host network-policy enforcement. Local-owner JSON bindings are supported. Named-user and SQL bindings remain explicitly unavailable and fail closed. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(application): isolate auth startup lifetime Shutdown does not join every background producer. Auth startup tests can therefore delete their temporary directory while a writer remains. Run each case in a bounded child process. The parent waits for child exit before deleting the fixture, preserving every startup assertion. Production shutdown and JSON ownership retention remain unchanged. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(ui): resolve installed Home app commands Project Home navigation from installed addon declarations, not product IDs. Reserve native and skill names and suppress every colliding addon alias. Re-read the authenticated registry before navigation and reject stale actors, generations, immutable entries and destinations. Keep mounted consumers polling through disabled and failed states. Abort pending reads on actor changes and ignore replies from obsolete request epochs. Studio declares its own alias. Preserve legacy redirects and pin migration. Add unit coverage, localized Home copy and command lifecycle documentation. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): accept resolved Home command metadata Apps resolves inherited titles beyond the explicit manifest label cap. Bound resolved labels to 147 Unicode characters, including secondary pages. Keep the backend's 60-character limit for explicit manifest labels. Match packaged icon paths to the contract's ASCII alphabet and image types. Accept dotted names while rejecting traversal, encodings and foreign assets. Verify 68 focused cases, 657 total cases, lint, i18n checks and the UI build. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): bind safety notices to actual providers Warn for addon host execution and unknown isolation without changing tool permissions or built-in host consent. Reset dismissal when the admitted binding, observation, actor or chat context changes. Expose presentation-only status identity from the admitted request. Keep configured network policy visible when enforcement is unverified. Render bounded provider explanations as escaped React text. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): isolate command status across sessions Invalidate command observations when actors or authentication sessions change, before effects can expose a previous actor’s reply. Keep actual host execution warnings generic instead of recommending Sandbox installation or repair. Cover both regressions with source-hook lifecycle tests and provider notice assertions. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): declare audited host requests Declare actual official hostclient operations without inferring requests from deployment grants. Keep optional settings and egress checks separate from required workspace and job operations. Verify all twelve manifests through strict package fixtures and assert that requests never self-grant. Guard generic provider and Home dispatch without banning historical routes, native Studio, or deployment policy. Document the audited operations and unresolved named-user and SQL gaps. Refresh Sandbox catalog metadata for its existing typed status tool. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): wire exact extension inventories and real provider selection Run contract, official packaging and root architecture gates through actual report inventories. Exercise two installed execution providers through the real owner resolver and distinguish callback pass-through coverage. Share strict report validation with focused-scope handling and missing/skipped artifact self-tests; document direct evidence and remaining harness limits. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): expire registry reads on auth refresh Bind registry authority to the actual authentication session epoch. Same-user refresh now rejects old polls and pending Home navigation before effect cleanup, even when permissions and metadata stay unchanged. Exercise both races through the production registry provider harness. Verify 59 focused tests, 669 total tests, lint, i18n, and the UI build. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(studio): resolve current execution selection Use existing principal-scoped chat and agent reads for advisory status. Revalidate current named credentials instead of requiring historical SQL revisions. Local-owner mode also works with the existing GORM store. Remove resolver nonces and historical revision prerequisites. Keep exact runtime binding and existing status admission hooks. Document current-read consistency without promising revoke-and-restore detection. Verify the SQLite regression fails before the fix. Two separate full race runs pass: application 262 specs (3 skips), auth 257, gateway 115, tools 91. Cover named chat, API key and session credentials and current-state denial. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): drop selection revision tracking Use current selection comparisons for advisory status instead of mutation history. Keep actor, credential and conversation identity explicit in the model. Hash current inputs at the gateway without exposing raw store data. Remove unused epochs, revision interfaces and JSON ownership lifetimes. Preserve current authorization, runtime identity, mutexes and atomic writes. Document current-read semantics without historical ABA guarantees. Verify all affected packages with the race detector and auth tag. Check the trace viewer, prerequisite self-tests and 18 chat-command unit tests. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): scope Apps reads and provider status Bind Apps replies and registry ingestion to their originating auth session. Reject late replies before effect cleanup on refresh or account switch. Use selected-provider command DTOs in the composer instead of private Sandbox discovery. Keep unknown isolation unknown and remove specific add-on installation recommendations from the affected native surfaces. Test production Apps, registry, switches and composer paths with controlled cleanup and two arbitrary providers. Update the matching feature docs. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): guard composer and session publication Remove the deleted Sandbox variable from the full composer render. Report unknown command isolation explicitly in the about card. Bind Apps records and admin action results to their originating actor and actual authentication epoch before cleanup can run. Reject stale request admission and verification without changing server authorization. Add full-component render and pending session-transition regressions. Enable no-undef for the production composer in normal UI lint. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): expire cancelled upgrade reconciliation Bind cancellation reconciliation to the initiating session authority. Discard status replies after expiry without publishing old outcomes. Match the exact operation identity before clearing a held action. Cover pending cancellation reads, expiry before reads, and preservation of a newer attempt with the same action key. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): bind background upgrades to auth lifetime Keep background upgrade authority in AuthProvider, independent of views. Expire pending observations before auth consumers render or logout waits. Preserve same-session navigation and exact-operation cleanup. Cover cancellation, completion, and verification after the origin unmounts with actual provider transitions and the real upgrade store. Thread captured authority through Activity retries and document expiry. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): remove implicit sidebar pins Start every account without implicit Studio or Talk pins. Preserve saved user selections and durable explicit pins across reopen and frontends. Remove startup seeding, seed metadata APIs, and first-install exceptions. Apply equal retention to all absent IDs without dropping database tables. Keep store initialization, cached errors, and the leader sweep unchanged. Verify genuine RED regressions before removal. Race checks pass for all 42 pin specs, 10 focused application specs, and 10 pin endpoint specs. Update pin documentation; renamed specs have no exact inventory entries. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(addons): declare typed model destinations Let installed addons own model launch destinations without teaching core about addon identities or private routes. Keep native chat and completion outside the declaration vocabulary. Validate strict keys, unique capabilities, declared pages and SDK route bounds. Carry cloned typed metadata through Apps using the shared contract type, and add owning Studio, Talk, Face and Voice declarations. Verify strict contract RED before implementation, then 1276 passing specs across contract, Apps, packages, runtime, protocol, config and manager. Verify 178 packaging and architecture specs and exact carrier inventories. Frontend dispatch and route cleanup remain a separate milestone. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(ui): resolve declared model launch actions Resolve installed model actions through the shared addon registry. Keep native chat and completion launches, and never promote absent providers. Refresh actor-filtered Apps before navigation and reject stale authority, provider metadata, generation, immutable entry and destination claims. Use named menu choices for collisions without loading addon modules. Remove unreleased addon-specific redirects and migrate browser fixtures to canonical declared pages. Preserve native chat handoff and addon-local navigation adapters. Update user documentation and historical plans. Verify three production UI consumers fail against their previous code, then pass all 13 mocked-HTTP browser regressions. Verify 696 host unit cases, full quiet lint, targeted no-undef, locales, inline styles and build. Full backend/browser integration remains outside these mocked-HTTP checks. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): validate Ginkgo hooks before focus Ginkgo emits null container hierarchies for suite hooks. Require hooks\nto pass before applying focus only to It cases. Normalize null\nhierarchies without weakening exact required-case comparisons.\n\nRun reporter regressions in prerequisites. Register the declared uiOnly\ntool case and remove an update fixture case excluded by the existing\nmanager focus. Both inventories match the complete CI case output.\n\nVerify 12 reporter regressions, 39 prerequisite cases, three existing\nreports, and fresh runtime, tools and focused update manager suites. Assisted-by: Codex:gpt-5 [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): restore Go CI contract checks Align route fixtures with explicit pins and declared network controls. Keep network enforcement unknown instead of inferring it from isolation. Propagate viewer output errors and select output paths with CLI arguments. Check test writes and use Ginkgo assertions without copying atomic state. Register the three migrated binding cases in the exact suite inventory. Document two narrow security false positives for administrator file access and symbolic credentials in reducer traces. Focused race tests pass. The available golangci-lint reports zero issues in affected packages. Assisted-by: OpenAI ChatGPT [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(ui): align browser coverage with addon contracts The generic provider refactor removes implicit install advice and private Sandbox discovery. Update browser fixtures to use the installed command metadata and typed execution observations without restoring those paths. Keep command warnings, consent, disabled switches and privacy assertions. Home now holds a live command consumer. Exercise polling shutdown on a route without that consumer, after its lazy transition finishes. Preserve the bounded cadence, error backoff and disable-stop assertions. Restore the remembered-voices link through the addon's compatibility router. Cover native migrations and NotFound for removed generator aliases. Validation: 293 affected browser cases and three native service cases pass. The gallery command passes all 74 inventoried cases. Full UI units, quiet lint, locale parity, production build and diff checks pass. Assisted-by: OpenAI ChatGPT [functions] Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * fix(addons): bind status to owner provider policy Resolve typed status with the same current owner pins as execution. Fail closed on owner policy outages and fingerprint authoritative pins. Exercise CAS updates, conflicts, stale bindings, and policy outages. Retain host settings when the named-account fixture replaces its gateway. Refresh exact gateway and Apps inventories from passing reports. Validation: the new regression fails before the fix. All 16 focused binding and policy cases pass. Gateway passes 361 exact cases and Apps passes 265 exact cases across their configured package reports. Assisted-by: Codex:gpt-5 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): share qualified tool identities Use one formatter and contract-component parser for add-on identities. Keep callable names bounded to 64 characters while recognizing long unavailable stand-ins without granting dispatch authority. Validate selected execution names and their provider ID at the owner binding boundary. The real adapter already emits canonical identities. Update exact importer and case inventories from passing reports. Migrate the binding backend test to Ginkgo and check shutdown errors reported by lint in the affected packages. Validation: behavioral RED proves malformed stand-in acceptance and plain selected-name acceptance. Affected full race suites pass: tools 94 and Studio 638, with zero skips. Application passes 275 of 278; three existing PostgreSQL cases skip without their configured URL. All 12 owner-binding cases pass and match the exact inventory. Scoped golangci-lint reports no issues. Assisted-by: Codex:gpt-5 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * refactor(addons): share execution declarations Share pure declaration predicates across manifest validation, gateway observations, and owner bindings. Keep authorization and exact-runtime admission checks local. Reuse the strict manifest schema validator so raw status definitions fail closed without changing manifest acceptance. Share the workspace-operation prefix scan without changing grant sources. Add declaration parity and workspace-prefix tables and update exact inventories from passing reports. Validation: two new gateway cases fail before the fix; all 14 pass after. Race tests pass: contract 245, gateway 136, tools 94, and focused application binding/workspace cases 22. Scoped golangci-lint: 0 issues. Assisted-by: Codex:gpt-5 golangci-lint Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(addons): retain document skill cases Match the contract inventory to the passing report after rebasing.\nKeep the new document-provider case and updated reserved-name cases. Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Assisted-by: OpenAI:gpt-6.1-sol * test(addons): follow owner-managed upgrades Accepted upgrades belong to the owner and cannot be cancelled by UI. Exercise observer expiry during reads, completion and verification. Supply the real upgrade store to source-executed auth harnesses so logout reaches its synchronous authority invalidation. Match whole-package inventories to fresh Manager and gateway reports. Assisted-by: Codex:gpt-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>
1101 lines
63 KiB
JavaScript
1101 lines
63 KiB
JavaScript
import { test, expect } from '@playwright/test'
|
|
import fs from 'node:fs'
|
|
import path from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { ADMIN, MEMBER, app, installedAddon, release, sessionFor, mockAddons } from './addons-fixtures.js'
|
|
import { buildAddonBundles, installStudioAddons, packagedApp } from './studio-addon.js'
|
|
import { mockSettings } from './settings-fixtures.js'
|
|
import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } from './identity-fixtures.js'
|
|
|
|
// Studio and Talk are packaged add-ons, not built-in tiles: the specs install
|
|
// the real packages. No page is built in today, so `builtin` is empty unless a
|
|
// spec makes one up to check the built-in rules.
|
|
async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true, user = MEMBER, installed, releases, hostSettings, features = { addon_navigation: true } } = {}) {
|
|
let saved = pins
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(user, { addons }) }))
|
|
await page.route('**/api/features', route => route.fulfill({ json: features }))
|
|
await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin } }))
|
|
// mockAddons installs the Apps routes with the real response shape ({ apps }).
|
|
// The packages are served from the build output, so a page that opens one runs it.
|
|
let mocked = null
|
|
if (studio) mocked = await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] })
|
|
else mocked = await mockAddons(page, { apps, installed, releases, hostSettings })
|
|
await page.route('**/api/addons/pins', async route => {
|
|
if (route.request().method() === 'PUT') saved = route.request().postDataJSON()
|
|
await route.fulfill({ json: saved })
|
|
})
|
|
return { saved: () => saved, dispatchLog: () => (mocked ? mocked.log.dispatch : []), mocked: () => mocked }
|
|
}
|
|
|
|
test.describe('Add-on navigation', () => {
|
|
test('the sidebar lists the pinned apps under Add-ons, in pin order, and no Show all link', async ({ page }) => {
|
|
await setup(page)
|
|
await page.goto('/app')
|
|
const nav = page.getByRole('navigation')
|
|
await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible()
|
|
// Apps, then the pinned apps in pin order; Apps is the only add-on entry.
|
|
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
|
|
const pinned = nav.getByTestId('apps-pinned-link')
|
|
await expect(pinned).toHaveCount(2)
|
|
await expect(pinned.nth(0)).toHaveAttribute('data-app', 'studio')
|
|
await expect(pinned.nth(0)).toHaveText('Studio')
|
|
await expect(pinned.nth(1)).toHaveAttribute('data-app', 'talk')
|
|
await expect(pinned.nth(1)).toHaveText('Talk')
|
|
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
|
|
await expect(nav.getByText('Show all')).toHaveCount(0)
|
|
const group = await nav.locator('a.nav-item').evaluateAll(els => els.map(e => e.textContent.trim()))
|
|
const at = group.indexOf('Add-ons')
|
|
expect(group.slice(at, at + 4)).toEqual(['Add-ons', 'Studio', 'Talk'])
|
|
await expect(pinned.nth(0)).toHaveAttribute('href', /\/app\/addons\/studio\//)
|
|
// There is no Pinned section.
|
|
await expect(page.getByTestId('pinned-nav')).toHaveCount(0)
|
|
await expect(page.locator('.sidebar-section-title', { hasText: 'Pinned' })).toHaveCount(0)
|
|
})
|
|
|
|
test('the sidebar follows the pin order and shows no pinned app when none is pinned', async ({ page }) => {
|
|
await setup(page, { pins: ['talk', 'studio', 'missing'] })
|
|
await page.goto('/app')
|
|
const nav = page.getByRole('navigation')
|
|
await expect(nav.getByTestId('apps-pinned-link')).toHaveText(['Talk', 'Studio'])
|
|
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
|
|
await page.unroute('**/api/addons/pins')
|
|
await page.route('**/api/addons/pins', route => route.fulfill({ json: [] }))
|
|
await page.goto('/app')
|
|
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
|
|
await expect(nav.getByTestId('apps-show-all')).toHaveCount(0)
|
|
await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0)
|
|
})
|
|
|
|
test('the icon rail shows Add-ons only, centred, with no pinned app', async ({ page }) => {
|
|
await setup(page)
|
|
for (const size of [{ width: 1280, height: 800, collapsed: true }, { width: 1000, height: 800, collapsed: false }]) {
|
|
await page.setViewportSize({ width: size.width, height: size.height })
|
|
await page.addInitScript(collapsed => {
|
|
try { localStorage.setItem('localai_sidebar_collapsed', String(collapsed)) } catch (_) { /* ignore */ }
|
|
}, size.collapsed)
|
|
await page.goto('/app')
|
|
const nav = page.getByRole('navigation')
|
|
await expect(nav.locator('a.nav-item[href="/app/addons"]')).toBeVisible()
|
|
await expect(nav.getByTestId('apps-pinned-link').filter({ visible: true })).toHaveCount(0)
|
|
const rail = await page.locator('.sidebar').boundingBox()
|
|
const icons = await nav.locator('a.nav-item:visible .nav-icon').evaluateAll(els => els.map(e => {
|
|
const r = e.getBoundingClientRect()
|
|
return (r.left + r.right) / 2
|
|
}))
|
|
expect(icons.length).toBeGreaterThan(0)
|
|
for (const x of icons) expect(Math.abs(x - (rail.x + rail.width / 2))).toBeLessThan(2)
|
|
}
|
|
})
|
|
|
|
test('the Add-ons entry opens the drawer, where pinned add-ons are shown and their order can be changed', async ({ page }) => {
|
|
const server = await setup(page, { pins: ['studio', 'talk'] })
|
|
await page.goto('/app')
|
|
await page.getByRole('navigation').getByRole('link', { name: 'Add-ons', exact: true }).click()
|
|
await expect(page).toHaveURL(/\/app\/addons$/)
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(2)
|
|
// A bare pin button or pair of arrows is gone: each card has one overflow menu.
|
|
await expect(page.getByTestId('pin-toggle')).toHaveCount(0)
|
|
await expect(page.getByTestId('pin-later')).toHaveCount(0)
|
|
await expect(page.getByRole('button', { name: /^Actions for / })).toHaveCount(2)
|
|
const studio = page.locator('[data-testid="addon-tile"][data-app="studio"]')
|
|
await studio.getByRole('button', { name: /^Actions for / }).click()
|
|
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
|
|
await page.getByRole('menuitem', { name: /^Move .* later$/ }).click()
|
|
await expect.poll(() => server.saved()).toEqual(['talk', 'studio'])
|
|
})
|
|
|
|
test('the card menu offers Show in sidebar, Details and Remove, and pins from the keyboard', async ({ page }) => {
|
|
const server = await setup(page, { pins: [], user: ADMIN, apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
|
|
await page.goto('/app/addons')
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(3)
|
|
await page.getByTestId('addon-search').fill('note')
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
|
|
const menu = page.getByRole('button', { name: 'Actions for Notes' })
|
|
await menu.focus()
|
|
await page.keyboard.press('Enter')
|
|
await expect(page.getByRole('menuitem')).toHaveText(['Show in sidebar', 'Details', 'Remove'])
|
|
await page.keyboard.press('Enter')
|
|
await expect.poll(() => server.saved()).toEqual(['notes'])
|
|
await menu.click()
|
|
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
|
|
})
|
|
|
|
test('a reader sees no Remove in the card menu', async ({ page }) => {
|
|
await setup(page, { pins: [], apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
|
|
await page.goto('/app/addons')
|
|
await page.getByRole('button', { name: 'Actions for Notes' }).click()
|
|
await expect(page.getByRole('menuitem', { name: 'Show in sidebar' })).toBeVisible()
|
|
await expect(page.getByRole('menuitem', { name: 'Remove', exact: true })).toHaveCount(0)
|
|
})
|
|
|
|
test('the card says what the add-on does and what it adds', async ({ page }) => {
|
|
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', description: 'Keep notes.', state: 'running', mainPage: '/app/addons/notes/home', tools: ['add', 'list'], toolCount: 2 }), app({ id: 'quiet', title: 'Quiet', description: 'Works in the background.', hasUI: false, mainPage: undefined, tools: [], toolCount: 0 })] })
|
|
await page.goto('/app/addons')
|
|
const notes = page.locator('[data-testid="addon-tile"][data-app="notes"]')
|
|
await expect(notes).toContainText('Keep notes.')
|
|
await expect(notes.getByTestId('tile-adds')).toContainText('Adds a page')
|
|
await expect(notes.getByTestId('tile-adds')).toContainText('2 chat tools')
|
|
await expect(page.locator('[data-testid="addon-tile"][data-app="quiet"]').getByTestId('tile-adds')).toHaveCount(0)
|
|
})
|
|
|
|
test('pinned apps come first in the grid, in pin order', async ({ page }) => {
|
|
await setup(page, {
|
|
pins: ['notes'],
|
|
apps: [app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' }), app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })],
|
|
})
|
|
await page.setViewportSize({ width: 1280, height: 800 })
|
|
await page.goto('/app/addons')
|
|
const tiles = page.getByTestId('addon-tile')
|
|
await expect(tiles.first()).toHaveAttribute('data-app', 'notes')
|
|
await tiles.first().getByRole('button', { name: /^Actions for / }).click()
|
|
await expect(page.getByRole('menuitem', { name: 'Remove from sidebar' })).toBeVisible()
|
|
})
|
|
|
|
test('selecting a card opens the preview pane with what the add-on is, adds and offers', async ({ page }) => {
|
|
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'indexer', title: 'Indexer', description: 'Indexes your files.', enabled: false, state: 'stopped', hasUI: false, mainPage: undefined, tools: ['query', 'export'], toolCount: 2, version: '0.4.0', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=indexer' } })], installed: [installedAddon({ id: 'indexer', title: 'Indexer', sourceId: 'main', enabled: false, state: 'stopped', version: '0.4.0' })] })
|
|
await page.setViewportSize({ width: 1360, height: 860 })
|
|
await page.goto('/app/addons')
|
|
await expect(page.getByTestId('addon-detail')).toHaveCount(0)
|
|
await page.getByRole('button', { name: /^Indexer, Off/ }).click()
|
|
const pane = page.getByTestId('addon-detail')
|
|
await expect(pane).toBeVisible()
|
|
await expect(page).toHaveURL(/detail=indexer/)
|
|
await expect(pane.getByRole('heading', { name: 'Indexer' })).toBeVisible()
|
|
// The header chip; the operator facts below repeat it under Health.
|
|
await expect(pane.getByRole('img', { name: 'State: Off' }).first()).toBeVisible()
|
|
await expect(pane).toContainText('Indexes your files.')
|
|
await expect(pane.getByTestId('app-tools')).toContainText('2 chat tools')
|
|
await expect(pane.getByTestId('app-tools')).toContainText('query, export')
|
|
await expect(pane).toContainText('Runs in the background')
|
|
await expect(pane.getByTestId('preview-facts')).toContainText('0.4.0')
|
|
await expect(pane.getByTestId('preview-source')).toHaveText('main')
|
|
await expect(pane.getByRole('button', { name: 'Turn on' })).toBeVisible()
|
|
await expect(pane.getByRole('button', { name: 'Remove' })).toBeVisible()
|
|
// No screenshot slot unless the add-on provides images.
|
|
await expect(pane.getByTestId('addon-shots')).toHaveCount(0)
|
|
await pane.getByRole('button', { name: 'Close' }).click()
|
|
await expect(page.getByTestId('addon-detail')).toHaveCount(0)
|
|
})
|
|
|
|
test('the preview pane shows screenshot slots only for an add-on that ships images', async ({ page }) => {
|
|
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes', screenshots: ['/api/addons/apps/notes/icon?generation=3'] })] })
|
|
await page.goto('/app/addons?detail=notes')
|
|
await expect(page.getByTestId('addon-detail').getByTestId('addon-shots').getByRole('listitem')).toHaveCount(1)
|
|
})
|
|
|
|
// An add-on that declares a settings.sections contribution: its settings
|
|
// live on the Settings tab of its details, and the Settings page links there.
|
|
const withSettings = (over = {}) => app({ id: 'sandbox', name: 'sandbox', title: 'Sandbox', description: 'Run commands.', mainPage: '/app/addons/sandbox/sandbox', ui: { slots: [{ id: 'settings', slot: 'settings.sections', component: 'SandboxSettings' }] }, ...over })
|
|
|
|
test('an administrator gets a Settings tab in the details of an add-on with settings, with the host controls it has', async ({ page }) => {
|
|
const controlled = withSettings({ shadows: ['bash'], ui: { entry: `/api/addons/sandbox/assets/${'a'.repeat(64)}/ui.js`, sdk: { min: 2, max: 2 }, controls: ['network-policy'] } })
|
|
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [controlled, app({ id: 'notes', title: 'Notes' })], installed: [installedAddon({ id: 'sandbox', title: 'Sandbox' }), installedAddon()], hostSettings: { chats_egress_ceiling: 'registries' } })
|
|
await page.route('**/api/settings', route => route.fulfill({ json: { chats_egress_ceiling: 'registries', chats_egress_audit_days: 14 } }))
|
|
await page.route('**/api/chats/admin/egress', route => route.fulfill({ json: { ceiling: 'registries', default: 'off', ports: [80, 443], audit_days: 14, registries: [], allowed_24h: 0, blocked_24h: 0 } }))
|
|
await page.goto('/app/addons?detail=sandbox')
|
|
const pane = page.getByTestId('addon-detail')
|
|
await expect(pane.getByTestId('addon-detail-tab-overview')).toHaveAttribute('aria-selected', 'true')
|
|
await pane.getByTestId('addon-detail-tab-settings').click()
|
|
await expect(page).toHaveURL(/detail=sandbox&view=settings$/)
|
|
await expect(pane.getByTestId('addon-detail-tab-settings')).toHaveAttribute('aria-selected', 'true')
|
|
const block = pane.getByTestId('host-control-network-policy')
|
|
await expect(block).toBeVisible()
|
|
await expect(block.locator('[data-field="chats_egress_ceiling"] select')).toHaveValue('registries')
|
|
// The audit retention stays on the Settings page: it is not owner policy.
|
|
await expect(block.locator('[data-field="chats_egress_audit_days"]')).toHaveCount(0)
|
|
await expect(pane.getByTestId('addon-server-settings')).toHaveCount(0)
|
|
// Arrow keys move between the two tabs.
|
|
await pane.getByTestId('addon-detail-tab-settings').press('ArrowLeft')
|
|
await expect(pane.getByTestId('addon-detail-tab-overview')).toHaveAttribute('aria-selected', 'true')
|
|
// An add-on without settings has no tab.
|
|
await page.goto('/app/addons?detail=notes&view=settings')
|
|
await expect(page.getByTestId('addon-detail')).toBeVisible()
|
|
await expect(page.getByTestId('addon-detail-tab-settings')).toHaveCount(0)
|
|
await expect(page.getByTestId('preview-facts')).toBeVisible()
|
|
})
|
|
|
|
test('a member gets no Settings tab, even from its address', async ({ page }) => {
|
|
await setup(page, { pins: [], studio: false, apps: [withSettings()] })
|
|
await page.goto('/app/addons?detail=sandbox&view=settings')
|
|
await expect(page.getByTestId('addon-detail')).toBeVisible()
|
|
await expect(page.getByTestId('addon-detail-tab-settings')).toHaveCount(0)
|
|
await expect(page.getByTestId('addon-settings')).toHaveCount(0)
|
|
})
|
|
|
|
test('the Settings page lists the add-ons with settings in an Add-ons group, and only when there is one', async ({ page }) => {
|
|
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [withSettings(), app({ id: 'notes', title: 'Notes' })] })
|
|
await mockSettings(page)
|
|
await page.goto('/app/settings')
|
|
const group = page.locator('.st-group[data-group="addons"]')
|
|
await expect(group).toBeVisible()
|
|
await expect(group).toContainText('1')
|
|
await group.click()
|
|
const rows = page.getByTestId('settings-addon')
|
|
await expect(rows).toHaveCount(1)
|
|
await expect(rows.first()).toHaveAttribute('data-app', 'sandbox')
|
|
await expect(rows.first().getByTestId('settings-addon-link')).toHaveAttribute('href', /\/app\/addons\?detail=sandbox&view=settings$/)
|
|
// The old anchor of the section opens the Settings tab.
|
|
await page.goto('/app/settings#ad-section-sandbox-settings')
|
|
await expect(page).toHaveURL(/\/app\/addons\?detail=sandbox&view=settings$/)
|
|
})
|
|
|
|
test('the Settings page has no Add-ons group when no add-on has settings', async ({ page }) => {
|
|
await setup(page, { pins: [], studio: false, user: ADMIN, apps: [app({ id: 'notes', title: 'Notes' })] })
|
|
await mockSettings(page)
|
|
const listed = page.waitForResponse(r => /\/api\/addons\/apps$/.test(new URL(r.url()).pathname))
|
|
await page.goto('/app/settings')
|
|
await listed
|
|
await expect(page.getByTestId('settings-page')).toBeVisible()
|
|
await expect(page.locator('.st-group').first()).toBeVisible()
|
|
await expect(page.locator('.st-group[data-group="addons"]')).toHaveCount(0)
|
|
})
|
|
|
|
test('a member sees the pane without Remove and without the source', async ({ page }) => {
|
|
await setup(page, { pins: [], studio: false, apps: [app({ id: 'notes', title: 'Notes' })] })
|
|
await page.goto('/app/addons?detail=notes')
|
|
const pane = page.getByTestId('addon-detail')
|
|
await expect(pane.getByRole('heading', { name: 'Notes' })).toBeVisible()
|
|
await expect(pane.getByRole('button', { name: 'Remove' })).toHaveCount(0)
|
|
await expect(pane.getByTestId('preview-source')).toHaveCount(0)
|
|
})
|
|
|
|
test('the pane menu is the same sidebar control as the card menu', async ({ page }) => {
|
|
const server = await setup(page, { pins: ['notes'], studio: false, apps: [app({ id: 'notes', title: 'Notes' }), app({ id: 'zeta', title: 'Zeta', enabled: false, state: 'stopped' })] })
|
|
await page.setViewportSize({ width: 1360, height: 860 })
|
|
await page.goto('/app/addons?detail=zeta')
|
|
const detail = page.getByTestId('addon-detail')
|
|
await detail.getByRole('button', { name: 'Actions for Zeta' }).click()
|
|
await page.getByRole('menuitem', { name: 'Show in sidebar' }).click()
|
|
await expect.poll(() => server.saved()).toEqual(['notes', 'zeta'])
|
|
await detail.getByRole('button', { name: 'Actions for Zeta' }).click()
|
|
await page.getByRole('menuitem', { name: 'Remove from sidebar' }).click()
|
|
await expect.poll(() => server.saved()).toEqual(['notes'])
|
|
})
|
|
|
|
test('the first-run card offers three featured add-ons and a way to find more', async ({ page }) => {
|
|
await setup(page, { user: ADMIN, studio: false, pins: [], releases: ['notes', 'tables', 'voice', 'extra'].map(id => release(id, { description: `${id} does a thing.`, ui: id === 'notes' ? { mainPage: '/app/addons/notes/home' } : undefined })) })
|
|
await page.goto('/app/addons')
|
|
const card = page.getByTestId('addons-first-run')
|
|
await expect(card).toBeVisible()
|
|
await expect(card.getByRole('heading', { name: 'No add-ons yet' })).toBeVisible()
|
|
const featured = card.getByTestId('featured').getByRole('listitem')
|
|
await expect(featured).toHaveCount(3)
|
|
await expect(featured.first()).toContainText('does a thing.')
|
|
await expect(featured.first()).toContainText('Adds a page')
|
|
await expect(card.getByRole('button', { name: /^Install / })).toHaveCount(3)
|
|
// One way into Find add-ons while the card shows: the bar's button waits.
|
|
await expect(page.getByRole('link', { name: 'Find add-ons' })).toHaveCount(1)
|
|
await expect(page.getByTestId('addons-add')).toHaveCount(0)
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(0)
|
|
await card.getByTestId('addons-first-run-browse').click()
|
|
await expect(page).toHaveURL(/tab=browse/)
|
|
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
|
|
})
|
|
|
|
test('Install and turn on asks once, installs, then turns the add-on on', async ({ page }) => {
|
|
const state = await setup(page, { user: ADMIN, studio: false, pins: [], releases: [release('notes', { name: 'Notes' })] })
|
|
await page.goto('/app/addons')
|
|
await page.getByRole('button', { name: 'Install Notes' }).click()
|
|
const dialog = page.getByRole('alertdialog').or(page.getByRole('dialog'))
|
|
await expect(dialog).toContainText('Install and turn on Notes?')
|
|
await dialog.getByRole('button', { name: 'Install and turn on' }).click()
|
|
await expect.poll(() => state.dispatchLog().filter(d => ['install', 'enable'].includes(d.action)).map(d => d.action)).toEqual(['install', 'enable'])
|
|
})
|
|
|
|
test('a search with no result says no add-on matches, not that none are installed', async ({ page }) => {
|
|
await setup(page, { user: ADMIN, studio: false, pins: [], apps: [app({ id: 'notes', title: 'Notes' })] })
|
|
await page.goto('/app/addons')
|
|
await page.getByTestId('addon-search').fill('zzz')
|
|
await expect(page.getByRole('heading', { name: 'No add-on matches' })).toBeVisible()
|
|
await expect(page.getByText('No add-ons yet')).toHaveCount(0)
|
|
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
|
|
await page.getByRole('button', { name: 'Clear search' }).click()
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
|
|
})
|
|
|
|
test('the first-run card is hidden once an add-on is installed', async ({ page }) => {
|
|
await setup(page, { user: ADMIN, pins: [] })
|
|
await page.goto('/app/addons')
|
|
await expect(page.getByTestId('addon-tile').first()).toBeVisible()
|
|
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
|
|
await expect(page.getByTestId('addons-add')).toBeVisible()
|
|
})
|
|
|
|
test('the first-run card is hidden from a caller who cannot install add-ons', async ({ page }) => {
|
|
await setup(page, { studio: false, pins: [] })
|
|
await page.goto('/app/addons')
|
|
await expect(page.getByRole('heading', { name: 'No add-ons yet' })).toBeVisible()
|
|
await expect(page.getByTestId('addons-first-run')).toHaveCount(0)
|
|
})
|
|
|
|
test('a stopped tile opens the detail view and never the main page', async ({ page }) => {
|
|
await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', mainPage: undefined })] })
|
|
await page.goto('/app/addons')
|
|
await page.getByTestId('addon-tile').filter({ hasText: 'Notes' }).getByRole('button', { name: /^Notes,/ }).click()
|
|
await expect(page.getByTestId('addon-detail')).toBeVisible()
|
|
expect(page.url()).toContain('/app/addons?detail=notes')
|
|
})
|
|
|
|
test('a pinned tile can be moved later and the new order is saved', async ({ page }) => {
|
|
const server = await setup(page, { pins: ['studio', 'talk'] })
|
|
await page.goto('/app/addons')
|
|
await page.getByTestId('addon-tile').filter({ hasText: 'Studio' }).getByRole('button', { name: /^Actions for / }).click()
|
|
await page.getByRole('menuitem', { name: /^Move .* later$/ }).click()
|
|
await expect.poll(() => server.saved()).toEqual(['talk', 'studio'])
|
|
})
|
|
|
|
test('the ninth pin asks the user to unpin one', async ({ page }) => {
|
|
const nine = Array.from({ length: 9 }, (_, i) => app({ id: `a${i}`, title: `App ${i}`, state: 'running', mainPage: `/app/addons/a${i}/home` }))
|
|
const eight = nine.slice(0, 8).map(a => a.id)
|
|
await setup(page, { apps: nine, pins: eight })
|
|
await page.goto('/app/addons')
|
|
await page.getByTestId('addon-tile').filter({ hasText: 'App 8' }).getByRole('button', { name: /^Actions for / }).click()
|
|
await page.getByRole('menuitem', { name: 'Show in sidebar' }).click()
|
|
await expect(page.getByRole('alertdialog').or(page.getByRole('dialog'))).toContainText(/unpin/i)
|
|
})
|
|
|
|
test('member without the add-ons feature sees the built-in tiles and no error', async ({ page }) => {
|
|
let appsCalled = false
|
|
// No page is built in today, so the spec makes one up.
|
|
await setup(page, { addons: false, builtin: [{ id: 'guide', kind: 'page', titleKey: 'items.chat', icon: 'chat', mainPath: '/app/chat' }] })
|
|
// Registered after setup, so it wins over the mocked Apps route.
|
|
await page.route(/\/api\/addons\/apps$/, route => { appsCalled = true; return route.fulfill({ status: 403, json: {} }) })
|
|
await page.goto('/app/addons')
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
|
|
await expect(page.getByTestId('addons-add')).toHaveCount(0)
|
|
await expect(page.getByRole('alert')).toHaveCount(0)
|
|
expect(appsCalled).toBe(false)
|
|
})
|
|
|
|
// The icon of a built-in tile sat in the top-left corner of its 36px box, at the
|
|
// size of the page text, because only the empty-icon box was laid out as a grid.
|
|
test('a built-in tile draws its icon centred in the icon box', async ({ page }) => {
|
|
await setup(page, { builtin: [{ id: 'guide', kind: 'page', titleKey: 'items.chat', icon: 'palette', mainPath: '/app/chat' }], studio: false })
|
|
await page.goto('/app/addons')
|
|
const box = page.locator('.ad-icon').first()
|
|
await expect(box.locator('svg[data-icon="palette"]')).toBeVisible()
|
|
const offsets = await box.evaluate(el => {
|
|
const outer = el.getBoundingClientRect()
|
|
const inner = el.querySelector('svg').getBoundingClientRect()
|
|
return { dx: (inner.left + inner.width / 2) - (outer.left + outer.width / 2), dy: (inner.top + inner.height / 2) - (outer.top + outer.height / 2), width: inner.width, box: outer.width }
|
|
})
|
|
expect(Math.abs(offsets.dx)).toBeLessThan(1)
|
|
expect(Math.abs(offsets.dy)).toBeLessThan(1)
|
|
expect(offsets.width).toBeGreaterThan(16)
|
|
expect(offsets.box).toBe(36)
|
|
})
|
|
|
|
test('member without the add-ons feature and with no built-in tile goes home, and Studio and Talk are not built in', async ({ page }) => {
|
|
await setup(page, { addons: false })
|
|
await page.goto('/app/addons')
|
|
await expect(page).toHaveURL(/\/app$/)
|
|
await expect(page.getByRole('navigation').locator('a.nav-item[href="/app/addons"]')).toHaveCount(0)
|
|
})
|
|
|
|
test('the legacy flag brings the old sidebar back', async ({ page }) => {
|
|
await setup(page)
|
|
await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } }))
|
|
await page.goto('/app')
|
|
await expect(page.getByTestId('apps-show-all')).toHaveCount(0)
|
|
// Home, Models and Chat head the old sidebar; Studio and Talk are add-ons and are not in it.
|
|
await expect(page.getByRole('navigation').getByRole('link', { name: 'Home' })).toBeVisible()
|
|
await expect(page.getByRole('navigation').getByRole('link', { name: 'Studio' })).toHaveCount(0)
|
|
await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0)
|
|
})
|
|
|
|
test('the layout holds at phone width', async ({ page }) => {
|
|
await page.setViewportSize({ width: 375, height: 700 })
|
|
await setup(page)
|
|
await page.goto('/app/addons')
|
|
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)
|
|
expect(overflow).toBe(false)
|
|
})
|
|
|
|
test("the server's admin block is what shows a Start action for a stopped add-on in the detail view", async ({ page }) => {
|
|
await setup(page, { apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped', admin: { canStart: true, canStop: false, managementUrl: '/app/addons?manage=notes' } })] })
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) }))
|
|
await page.goto('/app/addons?detail=notes')
|
|
await expect(page.getByTestId('addon-detail').getByRole('button', { name: 'Turn on' })).toBeVisible()
|
|
})
|
|
|
|
test('the card menu has a visible focus state and a name', async ({ page }) => {
|
|
await setup(page)
|
|
await page.goto('/app/addons')
|
|
const trigger = page.getByRole('button', { name: /^Actions for / }).first()
|
|
await trigger.focus()
|
|
await expect(trigger).toBeFocused()
|
|
await expect(trigger).toHaveAttribute('aria-haspopup', 'menu')
|
|
await expect(trigger).toHaveAccessibleName(/^Actions for .+/)
|
|
})
|
|
|
|
test('a pin of an add-on that is no longer listed is not drawn in the drawer and shows no error', async ({ page }) => {
|
|
await setup(page, { pins: ['studio', 'removed-addon'] })
|
|
// The Home page raises its own alert when the model list or a staging
|
|
// operation fails. Both come from the shared test server, so a slow answer
|
|
// or a failed install left there would count as an alert about the pin.
|
|
// Fix their answers so the alert count tells only about the pin.
|
|
await page.route('**/v1/models', route => route.fulfill({ json: { object: 'list', data: [] } }))
|
|
await page.route('**/api/operations', route => route.fulfill({ json: { operations: [] } }))
|
|
// Hold the Apps list back so the assertions run after it has arrived and
|
|
// been drawn from, not before, when a missing pin proves nothing.
|
|
await page.route(/\/api\/addons\/apps$/, async route => {
|
|
await new Promise(resolve => setTimeout(resolve, 4000))
|
|
await route.fulfill({ json: { apps: [packagedApp('studio')] } })
|
|
})
|
|
const listed = page.waitForResponse(res => /\/api\/addons\/apps$/.test(res.url()))
|
|
await page.goto('/app/addons')
|
|
await listed
|
|
// Two frames: the list reaches React and the drawer redraws from it.
|
|
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(1)
|
|
await expect(page.getByRole('alert')).toHaveCount(0)
|
|
})
|
|
|
|
test('a stopped pinned add-on in the drawer opens the detail view', async ({ page }) => {
|
|
await setup(page, { pins: ['notes'], apps: [app({ id: 'notes', title: 'Notes', enabled: false, state: 'stopped' })] })
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor({ ...MEMBER, role: 'admin' }, { addons: true }) }))
|
|
await page.goto('/app/addons')
|
|
const tile = page.locator('[data-testid="addon-tile"][data-app="notes"]')
|
|
await expect(tile).toHaveAttribute('data-state', 'stopped')
|
|
await tile.locator('.ad-tile__open').click()
|
|
await expect(page).toHaveURL(/\/app\/addons\?detail=notes$/)
|
|
await expect(page.getByTestId('addon-detail')).toBeVisible()
|
|
})
|
|
|
|
test('core pages that share a primitive with the SDK draw the same header', async ({ page }) => {
|
|
await setup(page)
|
|
await page.goto('/app/addons')
|
|
const header = page.locator('header.page-header').first()
|
|
await expect(header.locator('h1.page-title')).toHaveText('Add-ons')
|
|
expect(await header.getAttribute('class')).toBe('page-header page-header--editorial')
|
|
})
|
|
})
|
|
|
|
test.describe('Add-on navigation: the legacy Add-ons hub tab and the route in both layouts', () => {
|
|
test('the new layout has no Add-ons hub tab and serves the drawer at /app/addons', async ({ page }) => {
|
|
await setup(page)
|
|
await page.goto('/app/addons')
|
|
await expect(page.getByTestId('addon-tile')).toHaveCount(2)
|
|
await expect(page.locator('.dk-hubtabs')).toHaveCount(0)
|
|
})
|
|
|
|
test('the legacy flag brings the Add-ons hub tab back and /app/addons opens under it', async ({ page }) => {
|
|
await setup(page, { apps: [app({ id: 'notes', title: 'Notes', state: 'running', mainPage: '/app/addons/notes/home' })] })
|
|
await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } }))
|
|
await page.goto('/app')
|
|
// The legacy sidebar lists the Add-ons hub, which opens the drawer.
|
|
await page.locator('.sidebar-nav a.nav-item[href="/app/addons"]').click()
|
|
const hubTab = page.locator('.dk-hubtabs [data-hub-tab="addons"]')
|
|
await expect(hubTab).toHaveAttribute('href', '/app/addons')
|
|
await hubTab.click()
|
|
await expect(page).toHaveURL(/\/app\/addons$/)
|
|
await expect(hubTab).toHaveAttribute('aria-current', 'page')
|
|
await expect(page.getByTestId('addon-tile').filter({ hasText: 'Notes' })).toBeVisible()
|
|
await page.goto('/app/addons')
|
|
await expect(page.locator('.dk-hubtabs [data-hub-tab="addons"]')).toHaveAttribute('aria-current', 'page')
|
|
})
|
|
|
|
test('a canonical generator link keeps its model, query and hash', async ({ page }) => {
|
|
await setup(page)
|
|
await page.goto('/app/addons/studio/studio/images?x=1&model=stable%2Fdiffusion#top')
|
|
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?x=1&model=stable%2Fdiffusion#top$/)
|
|
})
|
|
|
|
test('the canonical Studio tab address stays on its declared page', async ({ page }) => {
|
|
await setup(page)
|
|
await page.goto('/app/addons/studio/studio/tts')
|
|
await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/tts$/)
|
|
})
|
|
|
|
test('a viewer without the native 3d feature stays in Studio overview without the gated tab', 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/addons/studio/studio/threed')
|
|
await expect(page.locator('.studio-tab-active[data-tab="overview"]')).toBeVisible()
|
|
await expect(page.locator('[data-tab="threed"]')).toHaveCount(0)
|
|
})
|
|
})
|
|
|
|
// 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('a canonical 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/addons/voice/voice/recognition')
|
|
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/addons/voice/voice/recognition?model=org%2Fname')
|
|
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recognition\?model=org%2Fname$/)
|
|
await expect(page.getByTestId('voices-page')).toBeVisible()
|
|
})
|
|
|
|
test('a viewer without voice_recognition gets the off notice in the add-on', async ({ page }) => {
|
|
await setup(page, { apps: [voiceApp()] })
|
|
// An administrator holds every feature, so the viewer is a member.
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: false }) }))
|
|
await serveVoice(page)
|
|
await page.goto('/app/addons/voice/voice/recognition')
|
|
await expect(page.getByTestId('voice-off')).toBeVisible()
|
|
await expect(page.getByTestId('voices-page')).toHaveCount(0)
|
|
})
|
|
|
|
test('the sections are tabs of one page and each tab keeps its address', async ({ page }) => {
|
|
await setup(page, { apps: [voiceApp()] })
|
|
await asAdmin(page)
|
|
await serveVoice(page)
|
|
await mockIdentity(page)
|
|
await page.goto('/app/addons/voice/voice')
|
|
// No tab in the address opens the default tab.
|
|
await expect(page.getByTestId('voices-page')).toBeVisible()
|
|
await expectVoiceTabs(page, 'recognition')
|
|
|
|
const tabs = page.getByTestId('voice-tabs')
|
|
await tabs.getByRole('tab', { name: /^Library/ }).click()
|
|
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library$/)
|
|
await expect(page.getByTestId('voice-library')).toBeVisible()
|
|
await expectVoiceTabs(page, 'library')
|
|
await expectOneVoiceTitle(page, 'Voices')
|
|
|
|
await page.getByTestId('voice-library-new').first().click()
|
|
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/library\/new$/)
|
|
await expect(page.getByTestId('voice-profile-create')).toBeVisible()
|
|
await expectVoiceTabs(page, 'library')
|
|
await expectOneVoiceTitle(page, /Add a speech voice/)
|
|
|
|
await tabs.getByRole('tab', { name: /^Text to speech$/ }).click()
|
|
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/speech$/)
|
|
await expect(page.getByTestId('voice-speech')).toBeVisible()
|
|
await expectVoiceTabs(page, 'speech')
|
|
await expectOneVoiceTitle(page, 'Voices')
|
|
|
|
await tabs.getByRole('tab', { name: /^From a recording$/ }).click()
|
|
await expect(page).toHaveURL(/\/app\/addons\/voice\/voice\/recording$/)
|
|
await expect(page.getByTestId('recording-tab')).toBeVisible()
|
|
await expectVoiceTabs(page, 'recording')
|
|
await expectOneVoiceTitle(page, 'Voices')
|
|
})
|
|
|
|
test('the Text to speech tab says where to get a model when none is installed', async ({ page }) => {
|
|
await setup(page, { apps: [voiceApp()] })
|
|
await asAdmin(page)
|
|
await serveVoice(page)
|
|
await mockIdentity(page)
|
|
// Registered last, so it wins over the fixture's model list.
|
|
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [] } }))
|
|
await page.goto('/app/addons/voice/voice/speech')
|
|
const hint = page.getByTestId('speech-no-models')
|
|
await expect(hint).toBeVisible({ timeout: 15_000 })
|
|
await expect(hint.getByRole('link', { name: /model gallery/ })).toHaveAttribute('href', /\/app\/models$/)
|
|
await expectOneVoiceTitle(page, 'Voices')
|
|
})
|
|
|
|
test('a viewer who is not an administrator gets no Library tab', async ({ page }) => {
|
|
await setup(page, { apps: [voiceApp()] })
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, voice_recognition: true }) }))
|
|
await serveVoice(page)
|
|
await mockIdentity(page)
|
|
await page.goto('/app/addons/voice/voice/recognition')
|
|
const tabs = page.getByTestId('voice-tabs')
|
|
await expect(tabs.getByRole('tab', { name: /^Recognition/ })).toBeVisible()
|
|
await expect(tabs.getByRole('tab', { name: /^Text to speech$/ })).toBeVisible()
|
|
await expect(tabs.getByRole('tab', { name: /^Library/ })).toHaveCount(0)
|
|
await page.goto('/app/addons/voice/voice/library')
|
|
await expect(page.getByTestId('voice-admin-only')).toBeVisible()
|
|
await expect(page.getByTestId('voice-library')).toHaveCount(0)
|
|
})
|
|
|
|
test('the older page addresses of the add-on open the matching tab', async ({ page }) => {
|
|
await setup(page, { apps: [voiceApp()] })
|
|
await asAdmin(page)
|
|
await serveVoice(page)
|
|
await mockIdentity(page)
|
|
const cases = [
|
|
['/app/addons/voice/recognition', /\/voice\/voice\/recognition$/, 'recognition'],
|
|
['/app/addons/voice/recognition?model=wespeaker-resnet34', /\/voice\/voice\/recognition\?model=wespeaker-resnet34$/, 'recognition'],
|
|
['/app/addons/voice/recognition?tab=recording', /\/voice\/voice\/recording$/, 'recording'],
|
|
['/app/addons/voice/library?selected=prof-1', /\/voice\/voice\/library\?selected=prof-1$/, 'library'],
|
|
['/app/addons/voice/create', /\/voice\/voice\/library\/new$/, 'library'],
|
|
['/app/addons/voice/speech?voice=prof-1', /\/voice\/voice\/speech\?voice=prof-1$/, 'speech'],
|
|
]
|
|
for (const [from, to, active] of cases) {
|
|
await page.goto(from)
|
|
await expect(page, from).toHaveURL(to)
|
|
await expectVoiceTabs(page, active)
|
|
}
|
|
})
|
|
|
|
test('there is no Build hub to hold a Voices tab: its address opens the models', async ({ page }) => {
|
|
await setup(page, { apps: [] })
|
|
await asAdmin(page)
|
|
await page.goto('/app/build')
|
|
await expect(page).toHaveURL(/\/app\/models/)
|
|
await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"], .dk-hubtabs [data-hub-tab="faces"]')).toHaveCount(0)
|
|
})
|
|
|
|
test('the add-on page identifies, enrols from a side panel and holds a removal behind Undo', async ({ page }) => {
|
|
await setup(page, { apps: [voiceApp()] })
|
|
await asAdmin(page)
|
|
await serveVoice(page)
|
|
const calls = await mockIdentity(page, { stored: people, identify: { matches: [match('v-marta', 'Marta Rossi', 0.18), match('v-jonas', 'Jonas Weber', 0.41)] } })
|
|
await page.goto('/app/addons/voice/voice/recognition')
|
|
await expect(page.getByTestId('voices-page')).toBeVisible()
|
|
|
|
await page.locator('#voice-probe-audio-file').setInputFiles(clipFile())
|
|
await expect(page.getByTestId('voice-probe-clip')).toHaveAttribute('data-state', 'filled')
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
|
|
await expect(page.getByTestId('distance-scale')).toBeVisible()
|
|
expect(calls.identify).toHaveLength(1)
|
|
expect(calls.identify[0].model).toBe('wespeaker-resnet34')
|
|
|
|
await page.getByTestId('enrol-open').click()
|
|
const sheet = page.getByTestId('enrol-sheet')
|
|
await expect(sheet).toBeVisible()
|
|
await sheet.locator('#voice-enrol-audio-file').setInputFiles(clipFile('new.wav', 6))
|
|
await sheet.locator('#voice-enrol-name').fill('Ada Byron')
|
|
await sheet.getByLabel(/This person agreed/).check()
|
|
await sheet.getByRole('button', { name: /^Enrol/ }).click()
|
|
await expect.poll(() => calls.register.length).toBe(1)
|
|
expect(calls.register[0].name).toBe('Ada Byron')
|
|
await expect(sheet).toHaveCount(0)
|
|
await expect(page.getByTestId('known-list')).toContainText('Ada Byron')
|
|
|
|
await page.clock.install()
|
|
await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
|
|
await expect(page.getByRole('status').filter({ hasText: 'Jonas Weber' })).toBeVisible()
|
|
await page.getByRole('button', { name: 'Undo' }).first().click()
|
|
// Past the 10 second delay: a cancelled removal must send nothing.
|
|
await page.clock.fastForward(15000)
|
|
await page.waitForTimeout(300)
|
|
expect(calls.forget).toHaveLength(0)
|
|
await expect(page.getByTestId('known-list')).toContainText('Jonas Weber')
|
|
})
|
|
})
|
|
|
|
// The built Face module, served the same way. dist/ is not committed, so the
|
|
// global setup builds it once per run with the builder of the add-on.
|
|
const FT_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../addons/fine-tuning/ui/dist')
|
|
const FACE_DIST = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../addons/face/ui/dist')
|
|
const FACE_BASE = `/api/addons/face/assets/${'e'.repeat(64)}`
|
|
|
|
test.beforeAll(async () => {
|
|
// The global setup has built the bundles already. A build rewrites the
|
|
// dist folder that every worker serves, so a worker must not start one.
|
|
if (process.env.E2E_ADDON_BUNDLES_BUILT) return
|
|
await buildAddonBundles(['face', 'fine-tuning'])
|
|
})
|
|
|
|
const faceApp = () => app({
|
|
id: 'face', name: 'face', title: 'Face', icon: undefined, generation: 1, version: '0.1.0', mainPage: '/app/addons/face/recognition',
|
|
ui: { entry: `${FACE_BASE}/ui/face.js`, css: [`${FACE_BASE}/ui/face.css`], sdk: { min: 2, max: 2 }, pages: ['recognition'], navigation: [], chatActions: [] },
|
|
})
|
|
|
|
const FT_BASE = `/api/addons/fine-tuning/assets/${'f'.repeat(64)}`
|
|
const ftApp = () => app({
|
|
id: 'fine-tuning', name: 'fine-tuning', title: 'Fine-tuning', icon: undefined, generation: 1, version: '0.1.0', mainPage: '/app/addons/fine-tuning/jobs',
|
|
ui: { entry: `${FT_BASE}/ui/fine-tuning.js`, css: [`${FT_BASE}/ui/fine-tuning.css`], sdk: { min: 2, max: 2 }, pages: ['jobs'], navigation: [], chatActions: [] },
|
|
})
|
|
|
|
async function serveFineTuning(page) {
|
|
await page.route(/\/api\/addons\/fine-tuning\/assets\//, route => {
|
|
const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/')
|
|
const known = { 'ui/fine-tuning.js': 'text/javascript', 'ui/fine-tuning.css': 'text/css' }
|
|
if (!known[file]) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
|
|
return route.fulfill({ status: 200, contentType: known[file], headers: { 'cache-control': 'no-store' }, body: fs.readFileSync(path.join(FT_DIST, file)) })
|
|
})
|
|
}
|
|
|
|
async function serveFace(page) {
|
|
await page.route(/\/api\/addons\/face\/assets\//, route => {
|
|
const file = new URL(route.request().url()).pathname.split('/').slice(6).join('/')
|
|
const known = { 'ui/face.js': 'text/javascript', 'ui/face.css': 'text/css' }
|
|
if (!known[file]) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } })
|
|
return route.fulfill({ status: 200, contentType: known[file], headers: { 'cache-control': 'no-store' }, body: fs.readFileSync(path.join(FACE_DIST, file)) })
|
|
})
|
|
}
|
|
|
|
async function asFaceAdmin(page, features = {}) {
|
|
await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(ADMIN, { addons: true, face_recognition: true, ...features }) }))
|
|
}
|
|
|
|
// The page draws its own title "Faces", once, in the core and in the add-on.
|
|
async function expectFacesPage(page, title = 'Faces') {
|
|
await expect(page.getByTestId('faces-page')).toBeVisible()
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveCount(1)
|
|
await expect(page.getByRole('heading', { name: title, exact: true, level: 1 })).toBeVisible()
|
|
}
|
|
|
|
test.describe('Add-on navigation: the Face recognition page', () => {
|
|
// Where the add-on page mounts, the add-on titles it and the shell adds
|
|
// nothing; where it cannot mount, the shell names the add-on and nothing
|
|
// else does. Either way the page has exactly one title.
|
|
test('the Face page that is not enabled on this server has one title, the add-on\'s', async ({ page }) => {
|
|
await setup(page, { apps: [faceApp()] })
|
|
await asFaceAdmin(page)
|
|
await serveFace(page)
|
|
await mockIdentity(page, { faceModels: [] })
|
|
await page.goto('/app/addons/face/recognition')
|
|
await expectFacesPage(page)
|
|
await expect(page.getByText('Not enabled on this server')).toBeVisible()
|
|
await expect(page.getByTestId('addon-unavailable')).toHaveCount(0)
|
|
await expect(page.locator('h1')).toHaveCount(1)
|
|
})
|
|
|
|
test('the Face page of a stopped add-on has one title, the shell\'s, because the add-on cannot draw', async ({ page }) => {
|
|
await setup(page, { apps: [{ ...faceApp(), enabled: false, state: 'stopped' }] })
|
|
await asFaceAdmin(page)
|
|
await serveFace(page)
|
|
await mockIdentity(page)
|
|
await page.goto('/app/addons/face/recognition')
|
|
await expect(page.getByTestId('addon-unavailable')).toHaveAttribute('data-reason', 'disabled')
|
|
await expect(page.getByTestId('faces-page')).toHaveCount(0)
|
|
await expect(page.locator('h1')).toHaveCount(1)
|
|
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Face')
|
|
})
|
|
|
|
test('the Face add-on draws the same page and test ids as the core page', async ({ page }) => {
|
|
await setup(page, { apps: [faceApp()] })
|
|
await asFaceAdmin(page)
|
|
await serveFace(page)
|
|
await mockIdentity(page)
|
|
await page.goto('/app/addons/face/recognition')
|
|
await expectFacesPage(page)
|
|
await expect(page.locator('[data-testid="addon-surface"][data-addon="face"]')).toBeVisible()
|
|
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
|
|
})
|
|
|
|
test('a canonical 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/addons/face/recognition')
|
|
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/addons/face/recognition?model=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('a canonical 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/addons/fine-tuning/jobs')
|
|
await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\//)
|
|
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
|
|
})
|
|
|
|
test('a canonical 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/addons/fine-tuning/jobs')
|
|
await expect(page).toHaveURL(/\/app\/addons\/fine-tuning\/jobs$/)
|
|
})
|
|
|
|
test('a canonical 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/addons/quantization/jobs')
|
|
await expect(page).toHaveURL(/\/app\/addons\/quantization\//)
|
|
await expect(page.getByTestId('addon-unavailable')).toBeVisible()
|
|
})
|
|
|
|
test('a canonical 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/addons/quantization/jobs')
|
|
await expect(page).toHaveURL(/\/app\/addons\/quantization\/jobs$/)
|
|
})
|
|
|
|
test('there is no Build hub with a fine-tuning or quantization tab', async ({ page }) => {
|
|
await setup(page)
|
|
await asFaceAdmin(page)
|
|
await page.goto('/app/build')
|
|
await expect(page).toHaveURL(/\/app\/models/)
|
|
await expect(page.getByRole('tab', { name: /Fine-tun/ })).toHaveCount(0)
|
|
await expect(page.getByRole('tab', { name: /Quantiz/ })).toHaveCount(0)
|
|
await expect(page.locator('.dk-hubtabs [data-hub-tab="fine-tune"], .dk-hubtabs [data-hub-tab="quantize"]')).toHaveCount(0)
|
|
})
|
|
|
|
test('there is no Build hub to hold a Faces tab', async ({ page }) => {
|
|
await setup(page, { apps: [] })
|
|
await asFaceAdmin(page)
|
|
await page.goto('/app/build')
|
|
await expect(page).toHaveURL(/\/app\/models/)
|
|
await expect(page.locator('.dk-hubtabs [data-hub-tab="faces"]')).toHaveCount(0)
|
|
})
|
|
|
|
test('the add-on page identifies, enrols from a side panel and holds a removal behind Undo', async ({ page }) => {
|
|
await setup(page, { apps: [faceApp()] })
|
|
await asFaceAdmin(page)
|
|
await serveFace(page)
|
|
const calls = await mockIdentity(page, { storedFaces: faces, faceIdentify: { matches: [match('f-anna', 'Anna Kowalski', 0.21), match('f-tomas', 'Tomas Ribeiro', 0.52)] } })
|
|
await page.goto('/app/addons/face/recognition')
|
|
await expect(page.getByTestId('faces-page')).toBeVisible()
|
|
|
|
await page.locator('#face-probe-image-file').setInputFiles(photoFile())
|
|
await page.getByTestId('identify-go').click()
|
|
await expect(page.getByTestId('verdict')).toContainText('Probably Anna Kowalski')
|
|
expect(calls.faceIdentify).toHaveLength(1)
|
|
expect(calls.faceIdentify[0].model).toBe(FACE_MODEL)
|
|
expect(calls.faceIdentify[0].img).toMatch(/^data:image\//)
|
|
|
|
await page.getByTestId('enrol-open').click()
|
|
const sheet = page.getByTestId('enrol-sheet')
|
|
await expect(sheet).toBeVisible()
|
|
await sheet.locator('#face-enrol-image-file').setInputFiles(photoFile('new.png'))
|
|
await sheet.locator('#face-enrol-name').fill('Ada Byron')
|
|
await sheet.getByLabel(/This person agreed/).check()
|
|
await sheet.getByRole('button', { name: /^Enrol/ }).click()
|
|
await expect.poll(() => calls.faceRegister.length).toBe(1)
|
|
expect(calls.faceRegister[0]).toMatchObject({ model: FACE_MODEL, name: 'Ada Byron' })
|
|
await expect(sheet).toHaveCount(0)
|
|
await expect(page.getByTestId('known-list')).toContainText('Ada Byron')
|
|
|
|
await page.clock.install()
|
|
await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click()
|
|
await expect(page.locator('.vc-undo').filter({ hasText: 'Anna Kowalski' })).toBeVisible()
|
|
await page.getByRole('button', { name: 'Undo' }).first().click()
|
|
// Past the 10 second delay: a cancelled removal must send nothing.
|
|
await page.clock.fastForward(15000)
|
|
await page.waitForTimeout(300)
|
|
expect(calls.forget).toHaveLength(0)
|
|
await expect(page.getByTestId('known-list')).toContainText('Anna Kowalski')
|
|
})
|
|
|
|
test('with the removal left to run, the add-on sends the face forget call', async ({ page }) => {
|
|
await setup(page, { apps: [faceApp()] })
|
|
await asFaceAdmin(page)
|
|
await serveFace(page)
|
|
await page.clock.install()
|
|
const calls = await mockIdentity(page, { storedFaces: faces })
|
|
await page.goto('/app/addons/face/recognition')
|
|
await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click()
|
|
await page.clock.runFor(11000)
|
|
await expect.poll(() => calls.forget.length).toBe(1)
|
|
expect(calls.forget[0]).toMatchObject({ face: true, id: 'f-anna' })
|
|
})
|
|
})
|
|
|
|
test.describe('Add-on navigation: the addons feature gate', () => {
|
|
const OFF = { addon_navigation: true, addons: false, fine_tuning: false, quantization: false }
|
|
|
|
// With add-ons off the server answers 503 on every add-on route, so a request
|
|
// to the pins or built-in routes is a failure of the page, not of the server.
|
|
async function watch(page) {
|
|
const calls = []
|
|
page.on('request', request => {
|
|
const { pathname } = new URL(request.url())
|
|
if (pathname === '/api/addons/pins' || pathname === '/api/addons/builtin') calls.push(pathname)
|
|
})
|
|
return calls
|
|
}
|
|
|
|
test('with add-ons off the sidebar has no Add-ons entry, no pinned app and no fine-tuning or quantization entry', async ({ page }) => {
|
|
const calls = await watch(page)
|
|
await setup(page, { features: OFF, user: ADMIN })
|
|
await page.goto('/app')
|
|
const nav = page.getByRole('navigation')
|
|
await expect(nav.getByRole('link', { name: 'Home' })).toBeVisible()
|
|
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(0)
|
|
await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(0)
|
|
await expect(nav.getByRole('link', { name: /Fine-?tun/i })).toHaveCount(0)
|
|
await expect(nav.getByRole('link', { name: /Quantiz/i })).toHaveCount(0)
|
|
expect(calls).toEqual([])
|
|
})
|
|
|
|
test('with add-ons off there is no Add-ons, fine-tuning or quantization entry, in either layout', async ({ page }) => {
|
|
for (const addon_navigation of [true, false]) {
|
|
await setup(page, { features: { ...OFF, addon_navigation }, user: ADMIN })
|
|
await page.goto('/app')
|
|
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/agents"]')).toBeVisible()
|
|
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/addons"]')).toHaveCount(0)
|
|
for (const name of [/^Add-ons$/, /Fine-?tun/i, /Quantiz/i]) {
|
|
await expect(page.getByRole('tab', { name })).toHaveCount(0)
|
|
await expect(page.locator('.hub-tabs, [role="tablist"]').getByRole('link', { name })).toHaveCount(0)
|
|
}
|
|
await page.unroute('**/api/features')
|
|
}
|
|
})
|
|
|
|
for (const address of ['/app/addons', '/app/addons/studio/home', '/app/addons/fine-tuning/jobs', '/app/addons/quantization/jobs']) {
|
|
test(`with add-ons off ${address} ends at /app and calls no add-on route`, async ({ page }) => {
|
|
const calls = await watch(page)
|
|
await setup(page, { features: OFF, user: ADMIN })
|
|
await page.goto(address)
|
|
await expect(page).toHaveURL(/\/app\/?$/)
|
|
await expect(page.getByRole('navigation').getByRole('link', { name: 'Home' })).toBeVisible()
|
|
expect(calls).toEqual([])
|
|
})
|
|
}
|
|
|
|
test('an older server without the addons key keeps today\'s behaviour', async ({ page }) => {
|
|
await setup(page)
|
|
await page.goto('/app')
|
|
const nav = page.getByRole('navigation')
|
|
await expect(nav.getByRole('link', { name: 'Add-ons', exact: true })).toHaveCount(1)
|
|
await expect(nav.getByTestId('apps-pinned-link')).toHaveCount(2)
|
|
})
|
|
})
|