feat(ui): redesign the web UI around a shared kit and a calm palette (#12526)

* build(ui): vendor the shared UI kit snapshot at 0.2.0

The restyle needs the kit's tokens, motion layer and component classes.
Take a pinned snapshot instead of depending on the kit at build time,
and keep a lock file with the version and per-file checksums so a later
update shows exactly what changed. The product theme stays outside the
vendored directory.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the ink and teal theme and bridge the old variables

Define the product colours as the shared UI kit's roles, for light and
dark, in theme-localai.css. The kit's contrast check passes on every
pair. theme.css keeps the existing --color-* and --shadow-* names but
now points each at a role, so App.css and the pages get the new palette
without edits. Radii move to the kit scale.

index.html now sets data-theme before first paint with the same rule as
ThemeContext (stored choice, otherwise dark), because the contract
layout of the theme file no longer defaults to dark by itself.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): restyle the shared chrome with the UI kit grammar

Adjust the shared classes so every page picks up the same interaction
language without per-page edits:

- Sidebar sits on the canvas and the current row lifts onto a card.
  Section labels are tracked uppercase, the badge is a soft pill, and
  the phone drawer leaves the tab order when closed.
- Buttons are flat: hover swaps the surface, press scales to .97, focus
  is a 2px ring with a 2px offset, danger is a tinted wash.
- Inputs use the card surface and the control edge; switches, tabs,
  filter chips, badges and cards follow the same rules. Cards no longer
  lift on hover; only linked or button cards react.
- Menus and popovers scale in from the trigger corner with 40px items.
  Dialogs get a veil fade and a spring settle. Toasts become pills at
  the bottom centre.
- The page transition is a 250 ms fade with a 6px rise. It fills
  backwards so a finished animation no longer leaves a transform that
  confined dialog veils to the main column.

The focus-ring test now checks the outline instead of a box shadow, and
new specs cover the theme roles, the first-paint theme and the sidebar
lift.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): move leftover hard-coded colours onto the theme roles

The YAML editor restated the old blue palette in JavaScript, and a few
pages kept literal blues, indigo and violet tints, or fallbacks that
only applied because a variable was never defined. Point them at the
theme variables so they follow light and dark and the new palette.

The status badges in the account pages built their tint by appending
"22" to a variable, which is not valid once the variable is defined, so
they had no background. Use the wash roles instead.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): raise the type scale and control size toward the kit

Body and list text moves to 15px and the rest of the scale follows the
kit's 12/13/15/17/21/32/44 steps. Page titles, section headings and
stat values are bold with tighter tracking; titles are 32px.

Buttons, inputs, selects, tabs and nav rows are 40px high with the 12px
radius, compact controls 32px. Tabs become a segmented control. The
sidebar widens to 240px (64px collapsed) and nav rows get more room.
Identifiers and counts in the split views use the mono face, and the
stat grid becomes separate inset tiles.

The Geist stack stays: it is bundled, and the thin look came from the
size, weight and negative tracking, not the face.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): separate cards, panes and floating surfaces from the canvas

Cards, the Models and Installed split panes, the composers and the
confirm dialog use a stronger card edge, the rest shadow and the 20px
radius, so they read as layers in dark as well as light. Menus and
popovers move to a float surface (the hover tone in dark) with the
float shadow.

The selected rail row gets an accent wash and a 3px accent edge. The
send buttons are a clear accent when there is something to send and a
quiet inset when not; the Home button carries data-empty for that, since
submitting an empty box does nothing. The assistant card becomes an
accent wash with a square icon.

New surfaces spec checks the pane edge, the selected row, both send
buttons and the popover in both themes. The voice library empty-state
spec now waits for the layout to settle before comparing two boxes.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): tidy the sidebar header and mark the current row with a dot

The header gives the configured horizontal logo a fixed width and
centres it in a 72px band, lined up with the nav icons. The collapsed
rail shows the configured icon logo centred, and its nav rows become
40px tiles centred in the 64px rail. The current row gets the kit's
accent dot, hidden in the rail.

The theme, language and account controls stay in the sidebar footer:
the app has no global search or command palette to put in a top bar, so
a bar would only hold controls that already have a place.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): centre the avatar in the collapsed sidebar rail

The collapsed avatar link was set to "flex: 0", which gives it a zero
flex basis; with min-width: 0 the link shrank to its padding and the
icon overflowed from the link's left edge, about 14px right of the
icon column. Use "flex: 0 0 auto" in the collapsed and tablet rail.

The footer controls now share the nav icon column in the expanded
sidebar too (6px footer padding, 40px control boxes), and the tablet
rail gets the same footer padding and hidden language code as the
collapsed one.

New spec measures the centre x of the nav icons, mark, avatar,
language, theme and collapse icons in the collapsed, expanded and
tablet states, in both themes, and asserts they agree within 1px.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): size the console and settings rails and stack settings on phones

The Operate console rail, the Settings section rail and the account tab
bar still used 13px text and the old underline tabs. They now use the
15px nav size, 40px rows and the segmented tab control. Form row labels
are 15px with 13px hints.

On a phone the Settings section rail sat beside the form and squeezed
every row into a few characters. Below 720px the rail stacks above the
content as a scrolling row and form rows wrap their control below the
label. The save button no longer carries the icon font class, which
drew a missing glyph before its label. The language menu is wide enough
to keep Bahasa Indonesia on one line.

Assisted-by: Claude Code:claude-sonnet-5-5

* build(ui): update the vendored UI kit snapshot to 0.3.0

Take the 0.3.0 snapshot: the sprite now carries the full outline icon set,
and the new icons/fa-map.json maps Font Awesome names to icon ids. The map
lets the app move off Font Awesome in the following commits. The lock file
is regenerated with the new checksums.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add an Icon component backed by the kit sprite

Icon draws an inline svg that points into the kit's outline sprite. The
sprite is inlined into the page once, so the references resolve under any
base path and in the embedded build without a request. Icons size with the
font (1em), take currentColor, hide from assistive tech unless given a
title, and spin on request. An unknown id draws a neutral circle.

FaIcon and iconFromFa resolve Font Awesome names through the kit's map,
for names that arrive at run time. iconHtml does the same for markup built
as a string. The GitHub and Apple marks are small local glyphs, as the kit
ships no brand marks.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw shared components and helpers with Icon

Replace the Font Awesome elements in the shared components and in the
utility modules with the Icon component. Lookup tables now hold kit icon
ids instead of class strings. Code-block copy buttons and artifact cards,
which build HTML strings, use iconHtml and a sanitizer-safe slot.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw model, backend and account pages with Icon

Replace the Font Awesome elements on the home, models, backends, import,
settings, login, account and users pages with the Icon component.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw chat, studio and recognition pages with Icon

Replace the Font Awesome elements on the chat, media generation, talk and
face and voice pages with the Icon component. The talk status table keeps
its spin and pulse states as Icon props. The connected and error states
now use a dotted circle and an alert circle, so they differ from the idle
ring by shape as well as by colour.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): draw agent, node and operate pages with Icon

Replace the Font Awesome elements on the agents, skills, collections,
jobs, fine-tune, quantize, nodes, swarm, usage, traces and activity
pages with the Icon component. Two class strings on layout elements held
leftover button and icon classes from an earlier merge; they are cleaned
up so the elements keep only their own classes.

Assisted-by: Claude Code:claude-sonnet-5-5

* style(ui): size and align icons for the svg component

Icon rules that targeted the font element now target the svg: the
descendant "i" selectors in App.css and auth.css become ".lai-icon". The
svg is 1.2em with a 2 unit line so it matches the visual size of the old
glyphs at the 12 to 16px sizes the app uses, sits on the text baseline,
and follows the context font size. Large empty-state marks get a lighter
line. Menu icons get a 16px box and the readiness badge icons keep their
20px circle with padding. Add the pulse used by the talk status.

Assisted-by: Claude Code:claude-sonnet-5-5

* build(ui): remove Font Awesome

No source file references the icon font any more. Drop the package and its
stylesheet import. The build no longer ships the solid, regular and brand
font files.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep focus traps off the svg use references

The dialog and drawer focus traps collect focusable elements with a
"[href]" selector. An icon's use element carries an href, so it became the
first "focusable" element and Tab at the end of the dialog stopped there
instead of wrapping to the first button. Match "a[href]" instead.

Assisted-by: Claude Code:claude-sonnet-5-5

* style(ui): keep icon sizes overridable and set the line width per svg

Give the icon base rule zero specificity so a rule that sizes one icon
(nav column, menu box, avatar, language switcher) wins whatever its order
in the file. The sprite symbols fix their own line width; the inlined copy
drops it so the width set on each svg applies, as the --lai-stroke custom
property, and large marks can use a lighter line. Pin the avatar and the
language globe to the boxes the sidebar alignment spec expects. Import the
map as JSON with an import attribute so Node can load it in the spec.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): select icons by the svg markup and cover the sprite

Specs that found icons by their Font Awesome class now select the svg by
its data-icon. The dead-icon audit checks that every svg resolves to a
sprite symbol and has a size. The class hygiene spec fails on any
remaining Font Awesome class. A new spec checks every mapped icon id has
a symbol, that the sprite is inlined once, that an icon paints at the root
and under a forwarded path prefix, and that Font Awesome names map as
documented.

Assisted-by: Claude Code:claude-sonnet-5-5

* build(ui): update the vendored UI kit snapshot to 0.4.0

Take the 0.4.0 snapshot: hub tabs with count and attention badges, the six
chart series tokens and the grid colour in the theme contract, and sample
themes on a calmer palette. The kit headers are renamed and the lock file
is regenerated with the new checksums, as for the earlier snapshots.

Assisted-by: Claude Code:claude-sonnet-5-5

* style(ui): switch the theme to the calm palette

Rewrite the LocalAI theme on the calm palette: a muted teal accent on a
near-neutral green-grey canvas, desaturated status colours, no glow and no
coloured shadows. The theme fills every role of the shared UI kit's 0.4.0
theme contract for light and dark, including the six chart series and the
grid line. The bridge in theme.css keeps the old --color-* names working,
adds the dark surface ladder (card, raised, float) and a strong edge, and
points the fixed data hues at the chart series.

Two values differ from the first sketch. The dark text on the accent fill is
#021512 instead of #04201d: it reads 5.58:1 on the fill at rest and 6.4:1 on
the hover fill, against 5.08:1 at rest for the lighter value. The light
control edge is #6b7d7a. The kit's contrast script passes for all text pairs
(4.5:1), control and focus pairs (3:1) and series colours (3:1).

Leftovers that no longer fit the palette are fixed: the usage chart takes
the six series colours in order, the audio and animation canvases fall back
to the new accent, the face box loses its glow, and two gradient fills are
now flat. The theme tests expect the new canvas colours.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): replace the console rail with hub tab bars

Build and Operate no longer open a second navigation rail beside the page.
Each is a hub: one row of the kit's hub tabs above the page, with count and
attention badges that scroll sideways on a phone. Every URL and route stays
as it was, plus a new /app/build landing page that lists the Build tools
with a line each.

Build tabs: Overview, Agents, Skills, Memory, Jobs, Fine-Tune, Quantize,
Import, Voices (recognition and library) and Faces. Operate tabs: Status,
This machine, Swarm (distributed mode only), Runtime (backends, activity,
failover), Traffic (usage, traces, middleware) and Settings (settings,
users), plus the API link. A tab that holds several pages shows a second row
of links, and a sub-page such as a node detail keeps its tab highlighted.
The feature and admin gates decide which tabs are drawn, and badges show only
values the Operate summary already has.

The sidebar lists Build and Operate under a Workspace label next to the
Create group. The voice library moves under Build and the model import page
gains the Build tab bar. The old rail styles, the rail signals and the
console config are removed, and the Operate overview docs describe the tab
bar. The specs that drove the rail now drive the tabs, and a new spec covers
the tab for each route, gating, badges and the phone layout.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Home as a calm console

Home now opens on one command bar: the model chip shows which models are
warm, the MCP chip and attach buttons sit beside it, and Send is a solid
button with an Enter glyph. Typing "/" opens a grouped, keyboard-driven
action list built on the kit command list; every action has a destination
in the product.

Memory use folds into a one-line strip that opens into the loaded models,
with Stop per model and Stop all. It opens by itself while a model is being
staged and after a failure, and shows nodes and aggregate memory in a
cluster. The list of resident models carries no per-model size because the
API reports none.

"Jump back in" lists the conversations stored in the browser, one card per
day, with j and k to move, Enter to resume and delete with an undo toast.
First run keeps the install steps and the recommended models. The assistant
prompt is a dismissible line, the library links are one quiet row and the
API section is collapsed. Chat accepts an empty new-chat hand-off for /new.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Home console

Update the Home specs for the new structure and add specs for the slash
menu, the model chip, the memory strip (expand, stop, staging, failure,
cluster), the resume list (grouping, j/k, Enter, delete with undo), first
run, the send hand-off, a non-admin user and the phone layout.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the fit, disk and cleanup helpers for the models page

Pure functions and hooks that the rebuilt Models page reads, with node
tests for the rules.

modelLedger turns an estimate and the memory budget into one of three
verdicts (fits, spills to CPU, over) with the headroom in bytes, and
reads the models disk from the resources reading. The disk counts as low
under 10 percent or under 20 GB free, and is absent when the server
reports none or runs as a cluster controller.

cleanupPlan ranks installed models from what the API reports: loaded,
pinned, or named by an agent, a task, a failover chain or an alias keeps
a model protected; another installed build of the same gallery model is a
duplicate; disabled models rank above idle ones. The API records no last
use or use count, so none is used. When a lookup fails, nothing is called
safe.

useModelRemoval holds a removal in the browser for an undo window and
sends the existing delete call only when the window ends. Leaving the page
drops the batch without deleting anything. The undo toast takes optional
labels so other pages can reuse it.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Models as a ledger with a disk strip and cleanup review

Explore is one dense table. Each row carries the size, a solid memory bar
and the headroom in words ("3.7 free", "+1.5 on CPU", "0.9 over"), worked
out from the estimate at the chosen context length. Capability chips show
the server's count for each facet, search keeps its meaning and "/" jumps
to it, and a density switch (also "d") picks comfortable or compact rows.
Selection is a surface step and a check, never a rail. Arrow keys move,
Enter installs and Esc closes the inspector, which keeps the fit summary,
VRAM by context chart, variants, files, links, tags and licence. A failed
install shows its error in the row with a Retry that dismisses the old
failure first. A failed or empty listing says which it is, and a host with
no GPU is measured against memory and says so.

Installed uses the same table with state filters that carry counts, a
state per row, Load or Stop on the row, the row menu and the sort by size.
Sizes come from the files the gallery lists, so a model it does not know
shows a dash.

A strip in the header shows the free space on the models disk. It turns
amber under 10 percent or under 20 GB free, hides when the server reports
no disk or runs as a cluster controller, and opens the cleanup review.
Explore says how much an install leaves free.

The review ranks installed models as Safe to remove, Probably safe and
Your call from real facts only, lists protected models with the reason,
and says plainly that usage history is not recorded. A sticky bar shows
what a choice frees. Confirming runs a dry run that checks again and lists
what will go. Removal waits 30 seconds with an undo; nothing is deleted
before that, and leaving the page deletes nothing.

The old rail, filter band and popover styles are removed.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Models ledger, Installed table and cleanup review

Update the Models, lifecycle, cluster fit, height, search focus and
surfaces specs for the table and inspector, keeping what each one checks.

New specs, on a shared 41-model gallery stub with three machine profiles:
the fit bar and headroom words for a 24 GB card, an 8 GB laptop and a host
with no GPU; facet counts, search, "/" and Escape; selection, arrow keys,
Enter to install, density; the disk strip when normal, low and hidden; and
the states (loading, empty, offline, install failed, phone). Installed
covers filters with counts, row actions, the row menu, sizes and sort.
The cleanup specs cover grouping, protected models, the honest-data note,
the effect bar, the dry run, the undo window, a failed delete, leaving the
page, and the phone sheet.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the placement helpers and the estimate hooks

The Placement section and the model page need the same few rules, so they
sit in plain functions that can be read and tested alone.

placement.js holds what gpu_layers, tensor_split and main_gpu mean (unset
asks for every layer and the llama.cpp engine trims it, zero is CPU only,
99999999 is the value LocalAI itself writes for all layers), the device
list taken from the resources reading, the split by free memory, the part
of an estimate that grows with context (read from two lengths, since that
term is linear), the fit states with their limit (95 percent of free
memory, and the leftover has to fit in system memory too), and a bisection
for the largest layer count whose estimate fits. The estimate returns one
total and no layer count, so the search runs over 1 to 256 and stops at
the first count that no longer changes it.

modelWalk.js keeps the order of the list a model page was opened from, in
memory and in session storage, for the previous and next buttons.

usePlacementEstimate reads /api/models/vram-estimate for a choice, again at
twice the context, and with every layer, and keeps readings for the
session. useModelPage reads a gallery entry by name, an estimate by
context size (from the model's own files when the gallery does not list
it), the builds and the loaded models. usePlacementConfig edits the four
placement keys of an installed model and saves only what changed.
useModelActions is the Load, Stop, disable, pin and remove logic of the
Installed table, shared with the model page. MemoryBar is one solid bar
with a tick at the capacity of its pool; over capacity it grows past the
tick and the tick turns red.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the Placement section to the model editor

Run this model on: CPU only (gpu_layers: 0), Auto (the key stays unset) or
Custom. Custom takes a number, has an All layers button that writes
99999999, and shows a slider only when the estimate reports the model's
layer count, which it does not today. Context size has presets and a
number field because the KV cache follows it. With two or more GPUs there
is a split (written as percentages, with a button that takes them from the
free memory of each card) and a main GPU.

A bar per GPU and one for system memory show what other programs use, the
model's weights and working memory, and the part that grows with context,
with the room left or how far over it is. Under them a verdict in plain
words: Fits in GPU, Spills to CPU, Too many layers for the GPU, Runs on CPU
only, No GPU found, Not enough memory. It says "slower" and never a
multiplier, because the estimate has none. Fit it for me asks the estimate
for the largest layer count that fits the free GPU memory and says what it
set, with Undo; it is hidden when the estimate is unavailable or the host
has no GPU. Loading shows skeletons, an unavailable estimate shows a note
with Retry, and a server that schedules onto other machines shows no bars,
because its device list is the controller's.

The editor shows the section for an installed model, with a link in its
section rail. Auto sends null for the key, since a patch only merges, and
a null read back opens as Auto. The docs describe the section and what each
mode writes.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): open a model on its own page

A model has an address, /app/models/<name>, for an installed model and a
gallery entry alike. Open it from the arrow at the end of a row, a double
click, "o" on the selected row, the inspector's Open details button, or a
tap on a phone. The title block holds the main action: Install with a
chevron that chooses the build, or Load and Stop with a menu (disable, pin,
edit configuration, logs, delete with a confirm). A strip answers whether
it fits, what it does and what installing leaves free.

Tabs: Overview (about, a memory bar, state, the pages it opens in, and the
agents, tasks, chains and aliases that name it); Fit and memory (verdict,
context sizes, the bar split into weights and context, and memory by
context against the limit, with a data table); Variants and files (builds
with size and fit, install any, the files of the chosen build). For an
installed model also Usage and history, which says what the API does not
record instead of drawing an empty chart, Configuration, which is the
Placement section with the file it writes and a link to the full editor,
and Logs, the backend log viewer without its page. Keys 1 to 6 switch
tabs, [ ] and j k walk the list the page was opened from, Esc or Backspace
go back.

The list stays mounted behind the page, so Back finds its view, search,
filters, selection and scroll as they were, and focus returns to the row's
arrow. The page covers loading, an unknown name with the closest matches,
the gallery being out of reach, an install in progress with Cancel, and a
failed install with Retry.

The docs describe the page and its keys.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the model page and the Placement section

New specs for the model page: reaching it from Explore, Installed, a
double click, "o", a pasted link and a phone tap; the walker and Back with
the search, a filter, the selection, the Installed view and the scroll
kept, and no second read of the gallery; the title block, the answer strip,
tabs by click, keys and arrows; Fit and memory, builds and files with the
install call each one makes; an installed model's actions, used-by,
the honest usage tab, configuration and logs; loading, an unknown name,
offline, an install in flight and a failed one; and the phone.

New specs for Placement: every mode and the keys it writes, the slider
only when a layer count exists, the context presets, the bars and every
verdict, two GPUs, no GPU, a cluster, an unread machine, a loading and an
unavailable estimate, Fit it for me and Undo, and the section in the model
editor with its save.

The phone tap on a row now opens the page, so the two phone specs that
expected the inspector as the page check the page and keep the inspector
check for a window between a phone and a desk.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): link Studio results and open workspaces from a prompt

Each workspace now records the result it was made from (parentId and an
edge kind such as take, animate or to-3d) and reads a prompt, model, size,
count and source from the query string, so one page can hand work to
another. A source result is fetched from the server's own output file and
becomes the start image, the picture for 3D, or the audio file. A note on
the page says when the source loaded or could not be loaded.

Diarization had no history; it now keeps the file name, the model and a
speaker count, never the recording. Prompts are cut at 2000 characters
when stored. The pure helpers (type suggestion, grouping, lineage layout,
favourites, clearing) have node tests.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Studio front page as a composer with your work

The front page is a prompt box with a chip per type, a type suggestion
from the words, starters, and the options each workspace accepts. Generate
opens the workspace with those filled in. A type with no model is a dashed
chip that shows a gallery model, its size, memory need and an Install
button only when picked; the typed words stay while it installs.

Under it, Your work lists results from every workspace as a masonry with
filters, counts, favourites and a Clear history action. Results made from
each other stack into a project tile and open as a lineage board with a
dock for running a new take or branching to the next step; steps the
destination cannot start from yet are disabled with the reason.

The docs describe the page, what is stored in the browser, and the query
parameters a workspace accepts.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Studio composer, your work and the lineage view

Specs for the type suggestion, the keys, hand-off to each workspace, the
install path for a missing model, the masonry filters, favourites and
clearing, stacking, the lineage board, new take and branch, steps that
are disabled with a reason, and the phone layout. Existing Studio specs
move from lanes to chips with the same intent.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the shared Studio workspace frame and move Images onto it

The seven Studio workspaces get one layout: a row of type tabs, a compose
card (optional sources as chips, a prompt with starters, a model chip,
the essential options as chips, an Advanced fold that names what is
inside, the memory the model needs, and one action with the reason when
it cannot run), a run area, and a strip of recent results of the type.

The run area shows a job card with the time that has passed and an
indeterminate bar, because these endpoints report no phase or percentage;
a failure with what the server said and one action; or the result with a
toolbar: Favourite (the list the front page keeps), Download, Use in (the
hand-off targets, disabled with the reason when a destination cannot
start from the result), Re-run with edits (the take's values go back in
the form, changed fields are outlined and listed) and Lineage. A type
with no model shows the install note from the front page.

Images is the first workspace on the frame. It keeps its size, count,
steps, seed, negative prompt, source image and reference images, and its
history writes, including the parent link and edge of a hand-off run.
useMediaHistory.addEntry now returns the id of the entry it stored. The
docs describe the workspace page.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Video onto the workspace frame

Video keeps its size list, duration, frame rate, steps, seed, CFG scale,
frame count, negative prompt, start and end image and avatar audio.
The start and end image are source chips, the avatar audio opens the
recording and paste input from a chip, and the rest sit in the Advanced
fold. A start image from a hand-off shows as a chip with its picture.
Results play in the video player with the shared toolbar.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move TTS onto the workspace frame

TTS keeps the saved-voice picker for cloning models, the typed voice for
the others, the voice library deep link, and the delivery instructions,
which now sit in the Advanced fold. The result is the waveform player
with the words under it. The stored entry also keeps the voice id so
Re-run with edits can select the same saved voice.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Sound onto the workspace frame

Sound keeps its Simple and Advanced modes and every field of both: the
description, instrumental, vocal language, caption, lyrics, BPM,
duration, key, language, time signature and think mode. The mode switch,
instrumental and duration are in the compose card, the rest in a More
options fold. The stored entry keeps all of the fields, so Re-run with
edits restores the form as it was.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Transform onto the workspace frame

Transform keeps its audio and reference inputs with upload and record,
the echo test, the key=value parameters (now in the Advanced fold), the
input and output spectra and the three waveform players. The audio that
was chosen shows before the run, waiting to be transformed. Re-run with
edits puts back the model and parameters and fetches the audio and
reference the server kept for that run.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move 3D onto the workspace frame

3D keeps the picture input with paste and webcam, the animation
operations a model declares, quality and background, the shape and
material steps, guidance and seed, the GLB and animation viewers, the
remesh control and the download. A 3D result now has a title from the
motion prompt when it has no label, so the strip and the front page name
animation results by what was asked. Re-run with edits is shown disabled
with the reason, because only a small thumbnail of the picture is kept.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move Diarization onto the workspace frame

Diarization keeps its model and recording inputs, the option to prepare
speakers to remember, the clean-speech previews, naming and remembering a
speaker, and the history entry with only the file name, model and
counts. The result now shows a timeline with one lane per speaker, the
talk time of each speaker, and the segments with their start time and
text. RTTM, SRT (only when the run has text) and JSON are built in the
browser from the result. The helpers for talk time, axis ticks and the
two text formats have node tests.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): remove the styles and lists the old workspace layout used

Nothing renders the two-column workbench, the control column, the old
history lists, the generation progress tiles, the TTS voice picker or the
result echo any more. The inline-style baseline drops with them.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the workspace frame and one run per type

Specs for the type tabs, the compose card and its reason when Generate
cannot run, starters, the Advanced fold, the job card with no invented
progress, a failed run and its one action, the install note, the strip
with its favourites filter, Use in with its disabled steps, Lineage, the
parent link, Re-run with edits and its list of changes, deleting and
clearing, and the hand-off note. One run through each of Video, TTS,
Sound, Transform, 3D and Diarization, the phone layout of all seven, and
reduced motion. Existing Studio specs move from the old control column to
the compose card with the same intent.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the chat thread with raised user turns, prose replies and one-line activity

Your messages are raised blocks on the right at a 760 px measure and the
model's replies are plain prose under its name and a warm or not loaded
dot. Reasoning, tool calls and their results fold into one quiet line
that opens inline into steps. Code blocks carry a Copy button and a
Canvas button that opens that block in the canvas, image attachments are
thumbnails that open in the lightbox, and files are chips. Per-message
actions show on hover, on focus and on the last turn, and a turn takes
focus so the arrow keys and C, E, R and B work. A failed reply keeps the
text written so far and shows the reason with one Retry action.

The Agent chat page keeps the older rules: the new styles are scoped to
the chat page and use their own class names.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): use the Home command bar as the Chat composer

Chat now ends in the same object as Home: the model chip, the MCP chip,
a Canvas chip, the message box with attach buttons, a solid Send and the
hint line, with the slash menu on the kit command list. The slash menu
lists what Chat can do today (switch model, new chat, conversations,
manage mode, canvas, find, settings, export, clear). While a reply is
streaming Send becomes Stop, which Esc also presses, and Up in an empty
box edits your last message. Attached images show as thumbnails and a
line under the bar carries the speed and the token count.

HomeComposer takes optional props for this (extra chips, its own slash
list, Stop, paste, a stricter Enter); Home passes none of them.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): open conversations from a Ctrl K menu with day groups, undo and a slim header

The conversations list opens as a centred menu on Ctrl or Cmd K. It
groups chats by day like the Home resume list, shows the model that
answered and the time, searches names and message text, and moves with
the arrow keys. Enter opens a chat, F2 renames it and Delete removes it.
Removing a chat hides the row and shows the kit undo toast; the chat is
deleted for good only when the undo time ends. Rename, duplicate, copy
and export are on each row, as before.

The header is one slim bar: the Chats button, the chat name (click to
rename), a context meter when the context size is known, settings and a
More menu with rename, duplicate, copy, export, model info, keyboard
shortcuts and clear. A dialog lists the shortcuts the page answers to.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): show loaded state, capabilities and fit in the Chat model switcher

The model chip in Chat opens the same list as Home, grouped as Loaded
now and Installed. Each row says warm or not loaded and marks models that
understand images. When the list opens, the page reads the host memory
once and asks the server to estimate each listed model at the chat's
context size (up to twelve, three at a time), then shows what the model
needs and whether it fits: free memory, how much would run on the CPU, or
how far over the machine it is. A model with no estimate shows no fit
text, and no load time is shown because the API does not report one. A
memory bar closes the list.

The picker takes the model list from the page when it has one, and
useModels can skip its own request.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): move chat settings into a sheet and add find in chat, jump to latest and a wider canvas

Settings open as a kit sheet: the system prompt, temperature, top P and
top K (each says "model default" until it is changed and has a Reset),
the context size with quick sizes and a note that it only drives the
meter, Manage mode and Focus mode, the model info for admins with its
Edit config button, and Clear conversation behind a confirmation. The old
slide-out drawer and the model info panel are gone.

Ctrl or Cmd Shift F (or the search button, or /find) opens a search bar
over the thread. It marks matches in the messages already on the page,
shows "n of m" and steps with Enter and Shift+Enter. Nothing is sent to
the server. Jump to latest is a pill above the composer. Esc stops a
reply, then closes the search, then closes the canvas.

The canvas panel gets the kit look: tabs, a Code and Preview switch, Copy
and Download, a full-page layout on narrow windows, and translated
labels. The Agent chat page shares it and gets the same look.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add the empty, no-model, loading and phone states to Chat

An empty chat opens with the composer under one line, starters to try,
whether the model is loaded, and the Jump back in list: the same rows as
Home, read from the chats the page holds. With no chat model installed,
an install card offers the starter models for this hardware, the gallery
and import, and the composer stays so the text is not lost.

While a reply waits for a model, a load card shows what the page knows:
the phase the server names, the node, the bytes and the time left when the
server reports them, and a progress bar. A model that is just not loaded
yet gets a plain note, with no invented phases or estimates. The foot
warns when the context is nearly full.

On a phone the header drops its labels, the model list and the settings
open as sheets from the bottom, per-message actions stay in view and the
canvas takes the whole page.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Talk as a calm voice page over what the connection really does

Talk is one stage and one transcript. The stage has the pipeline chip,
the voice and language chips, an outline orb that follows the real
microphone and playback levels, a heading and a sentence for the current
state, and the controls. The transcript lists You, Reply, Tool and Result
lines and can be copied. Session settings (instructions, voice, language,
tools, Manage mode and the pipeline's parts) open in a sheet.

The states are the ones the code reaches: no pipeline model, idle,
connecting, listening, thinking (also while a tool runs), speaking, an
interrupted reply (the server cancelled it; a note marks the cut), a
blocked microphone, a link that failed during a session, and any other
error with its reason and a link to the traces. Push to talk and
hands-free are not on the page, so they are not shown. Diagnostics keep
their waveform, spectrum and stats, drawn in theme colours.

The page text moves into the talk namespace, and the old Talk and
visualizer styles and the inline-style count go down with the rebuild.

Assisted-by: Claude Code:claude-sonnet-5-5

* refactor(ui): remove the chat styles and strings the rebuilt page replaced

The settings drawer, the model info panel, the bubble avatars, the
conversation menu popover, the context bar, the recent strip, the
staging bar, the file badges and the focus-mode rules have no user now.
Their rules, the Chat page's focus class and seven unused empty-state
strings are removed. The Agent chat page keeps the shared message,
sidebar and input rules it still renders with.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Chat and Talk pages

Add a Chat page under features (thread, message actions and keys, the
message box and its slash actions, the model list with loaded state and
fit, conversations on Ctrl K, settings, find, canvas and the empty,
no-model and loading states) and a Talk section to the realtime API page
with the states the page shows. Manage mode now turns on from the chat
settings or /assistant, and the client MCP steps point at the MCP chip.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): settle the rough edges of the new Chat and Talk pages

The undo toast sat under the conversations menu, so the Undo button could
not be pressed while the menu was open; the menu, the sheets and the
fullscreen canvas now stay below the toast layer. Esc in a rename box
saved the text through the blur that follows it; it now cancels. The
image viewer closed on Esc only when the page did not re-render on the
same key, so its key listener is registered once and reads the latest
handlers. Keys on a focused message no longer type their letter into the
editor they open, "/" from outside a text field starts a command as it
does on Home, and Esc leaves the page's own dialogs alone.

Code in the canvas is highlighted for languages that have no preview.
The conversations menu drops its key hints on a phone so Clear all
stays in view. Talk hides Test tone while connecting and calls a server
error "Something went wrong", since the call can still be open.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the rebuilt Chat and Talk pages

Specs for the thread layout and the activity fold, code blocks, image
thumbnails and the viewer, per-message actions and their keys, a failed
reply with its one Retry, Stop and Esc while streaming, the composer and
every slash action, the conversations menu (groups, search, resume,
rename, delete with undo that ends by itself, one chat left), the model
switcher with loaded state, vision and fit text from stubbed estimates,
the settings sheet, the canvas panel, find in chat, Jump to latest, the
empty, no-model and loading states, the phone layout and reduced motion.
Talk is driven over a fake WebRTC link through idle, connecting,
listening, thinking, speaking, interrupted, blocked, lost, error and no
pipeline, its settings sheet and its phone layout. Node tests cover the
message text helpers and the conversation grouping.

The existing chat specs move to the new structure with the same intent:
the transcript spec now describes the raised turn and the prose reply, and
the render smoke accepts Talk's own header.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): keep a bounded run log for agents in the browser

The server keeps no run history for an agent, so a run is one task and
the events until the agent answers, written to browser storage while the
page watches the stream: up to 50 runs per agent, task, step and answer
text only. Stored chats from the earlier agent chat page read as runs
with stable ids. A run still marked running five minutes after its last
event reads as stopped. Helpers read an agent's config into chips, build
the list of changed fields against the saved config, hide secret values
and offer starting points.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Agents area around runs

The Agents page shows what needs a look (work in flight, a run that
failed in the last day), then each agent with its model, attached memory
and skills, and a strip of its last 14 runs. An agent has its own page: model,
tools, memory, skills, instructions, a task box and its runs. A run has
an address, shows the thread while it works (steps folded into one line,
the tool in use, the answer as it arrives) and settles into a report
about a second and a half after the agent answers: task, outcome,
follow-ups, evidence and steps, with wide tables opening wider on demand.
A failure says in plain words what happened and offers Run again.

Create and edit fold into sections with a ready mark and a one-line
summary, start from a template or an optional model-written draft, and
open a preview sheet with the config as saved and the changes against
the saved agent. Status becomes a quiet panel in the same language, and
the old chat link opens the agent page.

There is no Stop, approval, steer, version or dry-run control, because
the agent API has no call behind them.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Agents launcher, agent page, runs and editor

Specs for the Now strip and run strip, search and the empty state, the
agent page, starting a run, the live thread, settling into the report,
the run address across a reload and for a run from another browser,
follow-ups with their history, failures, the folding editor with ready
marks, templates, the preview sheet with hidden secrets and changes, the
status page, and the phone, 1440 and 2560 layouts.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe runs and the new agent create flow

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for tasks, schedules and job outcomes

Reads a cron expression the way the server does (five fields or an @
shortcut), checks it, and puts the common shapes in words. The next run
is left out on purpose, because the schedule follows the server clock,
which the browser cannot read. Also groups jobs by day, sums the last
seven days, and gives each job one outcome line from its result or
error. A rerun call starts a new job with the same parameters and media.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Jobs area around runs

The Jobs page opens with one sentence about the last seven days, then
the tasks (model, schedule in words, last 14 jobs, enabled switch, Run
now) and a run history grouped by day. Each row has an outcome sentence
and opens to the error or the start of the result with one next action.
Deleting a task waits 30 seconds with an undo button.

A task opens as a page with its recent runs, its prompt with the gaps
marked and its schedule. The task form folds into sections, takes a
schedule as a preset or a checked cron expression, warns about prompt
gaps the schedule does not fill, and has a preview sheet. A job opens
as a document: task, outcome, delivery and the recorded steps; a failed
job says what happened and offers Run again.

Run now now sends attached media through the job call, which is the
only one that takes it. "Clear History" only ever cancelled running
jobs, so it is now called Stop running jobs. Webhook headers of a saved
task show as JSON instead of [object Object].

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Jobs page, task pages and job pages

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Jobs page and the task form

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers that say who uses a skill or a collection

An agent loads a skill when skills are on and the skill is in its selection (an empty selection means every skill). It reads the one collection that carries its own name, when its knowledge base is on. The helpers derive that from the saved agent configs, build the config that adds or removes a skill or a collection, and estimate tokens as characters divided by four. Removing the last selected skill switches skills off, because an empty selection would mean every skill.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Skills and Memory as one library

Skills and collections sit in a list with an open item beside it. Each row says who uses it, read from the saved agent configs, or says it is not used yet. Chat reads neither, so it is never named. An item opens in a pane with a Used by strip (names link to the agent, a small x removes it, with undo) and an Add to menu that shows what the addition costs. A collection can be added only to the agent that carries its name.

The Memory pane searches the collection alone and shows ranked passages with scores, lists web sources with their refresh interval and the files, shows the server message when an upload fails, and names the endpoints and where files stay. The Simulate a message sheet runs a collection search and shows an agent's skills with a token estimate. It runs no model. The collection details route now opens the same page.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): show use and cost in the agent form pickers

Each skill in the agent form says which other agents use it and what it adds to every message, with a total for the selection. The memory section names the collection the agent reads.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Skills and Memory libraries

Specs for the used-by lines (including an agent that uses every skill), the filters, search, add to agent, remove with undo, the last-skill case, an unreadable agent list, the empty states, git repositories, the Memory question box, sources, uploads that fail, the Simulate sheet with the parts the API can run, the agent form hints and the phone layout.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Skills and Memory libraries

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for the Operate status page and backend rows

Pure functions for the parts that need rules. They work out the memory
pool the page measures (GPU memory, system memory, or the workers of a
cluster that are answering), which pools are too full, the headline and
the four ledger rows, the geometry of the capacity chart, and what
removing a backend would leave without a runtime (models name their
backend, and a meta backend names the concrete one it points at). A
second set says what a backend row states: installing, queued, removing,
failed, update available, current or absent.

LocalAI keeps no memory history, so the chart reads a bounded buffer of
readings the page took itself and says so. A reading with no total is
dropped rather than drawn as zero.

Two hooks are shared by the pages that need them. One retries a failed
operation after moving the failure into the record. The other holds a
cancel for an undo window, because the server cannot take a cancel back.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Operate Status, This machine, Backends, Activity and Logs

Status opens with one sentence ("2 things need you", or "Everything is
running") and four rows: Needs you, Capacity, Running now and Recent
failures. A row with a problem opens by itself and holds the button that
deals with it: Update a backend, Retry or Dismiss a failed operation,
Unload a model. A quiet row stays one line. A new installation gets a
first-run screen, a cluster sums the memory of the workers that are
answering, and a page still waiting for an answer says so. The chart
under the rows is drawn from readings the page took while it was open
and is labelled that way, because LocalAI keeps no memory history.

This machine leads with GPU memory as one bar, then host memory split by
running model, then VRAM, RAM, CPU and disk with a bar each. The running
models become a kit table with the same menu and stop dialog.

Backends is one list with Installed and Catalog views. A row says what
the backend is doing (a progress bar with Cancel, Queued, Failed with
Retry, Update 1.2.0, Current), carries the one button that matters, and
opens in place. Removing a backend names the models and the meta
backends that would stop working. Check for updates, Update all, From
URL and a first-run recommendation for llama-cpp are in the header.

Activity keeps its three sections as quiet rows. Cancel waits eight
seconds with an undo toast, because the server cannot take a cancel
back; a cancelled install can be started again from the record. Logs
gets a process list, a picker, stream and text filters, Follow and
Times switches, and a Clear with an undo window.

Not shown, because the API has no data for them: GPU temperature and
power, a size per backend, an earlier version to roll back to, a
dependency lookup beyond the models and meta backends that name a
backend, and models that failed to load.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover Operate Status, This machine, Backends, Activity and Logs

New specs for the Status headline and ledger (healthy, needs attention,
one thing, a full memory pool alone, loading, first run, cluster), its
actions (Update, Retry, Dismiss, Unload with its dialog), the capacity
chart built from readings taken while the page is open and bounded, the
phone layout, no coloured edge on a row, and reduced motion.

The Backends specs cover the two views, install progress with Cancel and
its undo window, Retry on a failed install, Update, Update all, Check for
updates, removal with the models and meta backends it would break,
Install from URL, the first-run recommendation, a cluster, and a phone.
Activity gains cancel with undo, Cancel now, a second cancel, leaving the
page, progress, and starting a cancelled install again. Logs covers the
stream and text filters, Follow, Times, Export, Clear with undo, the
process picker and list. This machine covers the GPU strip, several GPUs,
no GPU and Add a machine.

Existing specs keep their intent and follow the new structure: rows open
in place instead of in a pane, Update replaces Upgrade, the notice spec
now pins that an update is a row state and not a banner or a rail, and a
cancel waits for its undo window.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Operate Status, Backends and Activity pages

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for the Swarm pages

Pure functions for what the pages work out from the cluster API: a node's
state in words, which nodes a placement rule may use, what a rule would
ask for, what a drain or a lost node would leave without service, the
nodes a bulk backend update reaches, and the join commands for a worker,
a peer instance and a memory shard. Hooks read the roster, the loaded
replicas and the rules.

Everything runs in the browser from data the page already holds, and
says when it cannot see free memory or disk.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Swarm hub: nodes, node page, placement rules, failover

Nodes is a sortable table with comfortable and compact rows, a Needs
attention filter by reason, a map of the cluster that is not drawn on a
phone, the running models, and a bulk backend update for the nodes that
drifted. A node is a page: state, vitals, a drain preview computed from
the loaded replicas and the rules, tabs for models, backends, logs and
capacity and labels, and Remove that asks for the node's name.

Placement rules are written as sentences, show where each model is
loaded now, and edit in a side sheet with a preview of the nodes a draft
could use. Deleting a rule waits a few seconds so it can be taken back.
Failover keeps its chains, adds what the router does when a worker stops
answering and a per-node preview of what would stop. Add a node covers a
registered worker, a peer instance and a memory shard, with a command to
copy and a live line that says when the machine arrived. P2P keeps its
page in the same vocabulary, and the node logs page follows the local
logs page.

Previews are labelled as worked out in the browser. Per-GPU readings and
node events are not drawn because the API does not return them. Failover
moves to Swarm when distributed mode is on. Legacy fleet components and
their styles are removed.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Swarm hub

New specs for adding a node (each join method, the command, copy,
waiting and found, approve, a single install, P2P, a phone), placement
rules (sentences, where models are loaded, the preview matrix, the sheet
and its preview, delete with undo) and failover on a cluster. Node
detail covers its tabs, the drain preview and its dialog, resume, remove
with the typed name, a node that stopped answering, and unload.

The nodes specs follow the new structure and keep their intent: the
table, filters, grouping, pagination, bulk actions, the map, and running
models with stop, logs and the loading, error and empty states. The
scheduling, failover, P2P, hub and smoke specs follow the renames.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Swarm pages

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): add helpers for the Traffic pages

Pure functions for what the pages work out from the usage ledger, the
trace summary, the trace buffers and the resources reading: the shared
time window, grouping, sorting and filtering of usage rows, chart series
and axes that start at zero, the overview figures, per-model statistics,
the state of a trace and the words for a failure, the backend operations
that ran during a request, CSV export, the Prometheus metric list and
scrape config, and a bounded buffer of host readings.

A figure whose source cannot say is null, never zero. The trace summary
call takes the window in hours, and a helper reads /metrics with its
status.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Traffic hub: overview, usage, models, host, traces, middleware

Traffic opens on an overview: five figures (requests, failed, p95, tokens
in and out) and three charts, each naming its source. A second row of
links reaches Usage, Models, GPU and host, Traces, Middleware and
Prometheus, and one time window is shared by the first three.

Usage groups by model, user or API key, filters, sorts, opens a row on its
own chart, exports the rows it holds as CSV or JSON in the browser, and
keeps the opt-in cost estimate and the quota forecast. A user who is not
an admin sees only their own numbers. Models joins the ledger, the
backend-operation buffer and the loaded models. GPU and host shows the
current reading and two charts of readings taken since the page opened.

Traces gets filters, a settings strip and an explained off state. An API
request is a page: the error LocalAI recorded, a timeline with the backend
operations that ran meanwhile, and bodies that stay closed until revealed.
Middleware draws the pipeline as five steps and shows the rules of the
selected step. Prometheus documents /metrics, checks it against the
server and gives a scrape config to copy.

Alerts is not built: LocalAI has no alert rules. Per-model latency
percentiles, GPU utilisation and compare with the previous period are not
drawn because the API does not return them. Legacy usage, trace and
middleware styles and the usage source components are removed.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Traffic hub

New specs for the overview (figures, charts with a data table and arrow
key readout, failed and first-run and tracing-off states, the shared
window, a phone), usage (group by, filters, sort, export, cost, quotas, a
non-admin, empty and loading), models, GPU and host (snapshot, the
since-opened labelling, a cluster), the traces list, a trace page (the
real error, the timeline, reveal, no headers, a trace that left the
buffer), Prometheus and the Middleware pipeline, with shared fixtures.

The usage, traces, middleware, hub and smoke specs follow the new
structure and keep their intent.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Traffic hub

Add an operations page for the Traffic tab: which record each page reads,
what it leaves out and why, the trace page and its reveal, the GPU and
host readings kept since the page opened, and the Prometheus endpoint.
Link it from the operations index, the tracing page and the middleware
page.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): let metric names wrap in the Prometheus table on a phone

The long metric names pushed the type and "on this server" columns out of
view. Names now wrap inside the table.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Settings with groups by intent, search, a pending bar and history

The fifteen sections become eight groups by intent: memory and models,
speed and defaults, backends and galleries, access and security,
debugging and traces, agents and responses, swarm and sharing, look and
feel. Search covers names, descriptions, keys and the old section name, and
says where a result used to be.

Edits wait in a bar with Discard, Show diff and Apply. The diff lists old and
new values and the checks the browser can make: durations parse the way Go
parses them, a GPU memory budget is one the server accepts, a gallery box
holds JSON, and warnings repeat what the handler and the field text say.
Apply sends only the changed keys. Undo saves the previous values again; it is
a new save, not a rollback. History lists the changes applied from this
browser, since LocalAI keeps no settings log, and Revert stages the old value.

A value is marked as changed only where the built-in default is known from
the CLI defaults. A row says "Applies now" or "Needs restart" only where the
handler or the docs say so.

Three things were wrong before and are fixed with the rebuild: the gallery
boxes and the shared API keys box were sent under names the server ignores,
the "Enable CSRF Protection" switch showed the disable flag the wrong way
round, and every save restarted peer-to-peer networking because every field
was sent.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Users and keys, Account, sign-in, invite and the 404 page

Users and keys is a tabbed page under the Settings tab: people, invites and
API keys. The people table filters by state and role, sorts, approves or
disables (disabling offers an undo that sets the status back), and opens a
side sheet for one person's features, model allow-list and limits. Role,
password reset and delete sit in the row menu; delete asks for the name.
Invites choose a lifetime of 1, 7 or 30 days and show the link once. API
keys can be created with a lifetime, are shown once in full, can be paused,
and are revoked after a ten second undo window in which nothing is sent.
LocalAI lists keys only to their owner, so the tab shows the signed-in
person's own keys and says so.

Account has Profile, Security, API keys and Usage. Usage shows the last 30
days, tokens by model and the limits an admin set. The Security tab now
shows for a GitHub or SSO account and says the password is not theirs to
change.

Sign-in asks for one field per step and draws a provider button only for a
provider /api/auth/status lists. It has the notice for a sign-up that waits
for approval, the first-admin screen, the key-only screen and the invite
page. An address outside the app now gets the 404 page too, which names the
address and lists the places the sidebar lists, with the same gates.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover Settings, Users and keys, Account, sign-in and the 404 page

Settings: groups, search by name, key and old section, the changed marker
only where a default is known, apply hints, the pending bar and diff, the
checks, apply sending only changed keys, undo as a second save, discard,
history, the CSRF inversion and the gallery and API key wire forms, and the
phone layout.

Users and keys: the table, filters, sort, approve, disable with undo, the row
menu, the access sheet, invites, key creation with a one-time reveal, the
ten second revoke with undo and with a page leave, and the non-admin redirect.
Account, each sign-in variant (error, pending, first admin, key-only, invite,
provider buttons) and the 404 page have specs too. Fixtures are shared with
the screenshot scripts. Existing specs follow the new structure.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the rebuilt Settings, Users and keys, Account and sign-in pages

Runtime settings: the eight groups and where each old section went, search,
the pending bar, the diff and its checks, apply, undo, the history, and which
settings show a default or an apply note and why. Authentication: the
sign-in screen variants, the Account tabs, key lifetimes, the one-time key
reveal, the revoke undo window, and the fact that keys are listed only to
their owner.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): let the Settings undo toast stand alone and read back a generated P2P token

The saved message and the undo toast sat on the same spot at the bottom of the
page. The undo toast now carries the saved message.

A new P2P token is made by the server when the page sends 0. The page reads
it back after the save so the field shows the token and not the placeholder.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): fit the users table, API keys and Account figures on a phone

On a phone the users table dropped its Role and Status columns off the screen
edge with the row actions. The role and state now sit under the name, so the
actions stay in view. API key rows no longer put the key icon on a line of its
own, and the three Account figures keep one row.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the phone users table, reduced motion and the empty Account state

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): drop the apply note from three settings the save handler does not mention

Size-aware eviction, automatic backend upgrades and development backends said
Applies now, but nothing in the handler or the docs says when they take effect.
A row now carries a note only where the code or the docs say so.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild Voices and Faces as one identity family

Voices is one page with three tabs: Speakers (voiceprints for recognising
who is speaking), Speech voices (the text-to-speech reference library,
kept apart because it is a different store) and From a recording (a link
into the diarization workspace). Faces uses the same layout.

Who is this and Same person? give the answer in a sentence with the real
distance and cut-off, a word for how far inside the cut-off it sits, and
a distance scale with the cut-off drawn on it. The cut-off slider re-reads
the answer in the browser; the identify call sends the cut-off, and verify
uses the threshold the model returns. The old confidence percentage is
gone because it is not a probability.

The server has no list call, so the people list stays in the browser and
the page says so. After a search that asked for more people than it got
back, a saved person the server did not return is marked, and people the
server returned that the browser does not know are listed. Nothing is
claimed from a short or cut-off search.

Enrolling is a sheet: sample, name, labels, permission. A copy of the
sample in the browser is opt-in, and an administrator can also keep the
recording as a speech voice in the same step. Removing a person waits ten
seconds behind an Undo toast and sends nothing before then.

Errors say what happened (no face found, model missing, call failed), a
blocked or missing microphone is explained, and a missing model or a
missing permission renders a page that says what turns the feature on
instead of a redirect. Analyze, detect and raw embedding move under
More tools, with attribute guesses off by default.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Voices and Faces pages

Specs for who is this (match, no match, working, failed, missing model),
the cut-off slider, same person, a blocked, allowed and insecure
microphone, the registry notes and the not-on-the-server marks, the
enrol sheet and its opt-in copy, delete with undo on a fake clock, the
disabled and no-permission states, the phone layout, reduced motion and
Faces. Existing library and diarization specs follow the new structure
and keep their intent. Node tests cover the distance words, scale
layout, stored list and error mapping.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Voices and Faces pages

Add a WebUI section to the voice and face recognition pages: the two
tools, the cut-off, what the people list is and why it can be stale, the
undo window, and what is stored where. Point the Voice Library and
Fish Audio notes at Build, Voices, Speech voices.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): rebuild the Build landing, Fine-tune, Quantize, Import and Explorer

The Build landing says what each tool is for and what it needs from the
machine: the installed backend, the GPU memory, RAM and disk the server
reports, and a job that is running or the newest one when it failed. A
tool that cannot run says why and what enables it.

Fine-tune and Quantize share one page: set up, a check list that is
redrawn as the form changes, a run view with progress, stages and a log,
and a result with real next steps (export, import, chat, Models). The
checks state only what the server reports. A job needs no estimate the
server cannot make, so none is invented. Stop on a fine-tuning job asks
whether to keep a checkpoint, a failed job shows the server's message,
and a memory failure offers two changes that are applied to a copy of
the setup.

Import is a guided flow: source, review, import, done. The server
returns no preview before an import starts, so the review reads the
spelling of the source, prints the request the form will send and runs
the checks that can be made early. The estimate that arrives when the
import starts is set against free memory and disk. The ambiguity picker
and the Write YAML tab stay.

Explorer shows what GET /networks returns and lists a swarm with POST
/network/add, with a join sheet that carries the token and commands.
Build tools the account may not use say so instead of redirecting.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the Build landing, the tool pages, Import and Explorer

Specs for the landing (a tool ready, missing a backend, with no GPU, a
running or failed job, a feature switched off, a member without admin,
phone, reduced motion), the shared tool pattern for Fine-tune and
Quantize (set up, live checks, start request, running with progress,
chart and log, the stop choice, failure with the server message, finish
with next steps, earlier jobs, the account-disabled page, phone), Import
(source detection, review, checks, ambiguity, running with the estimate
against free memory, done, Write YAML, phone) and Explorer (list, join,
list a swarm, empty, not an explorer, retry, phone).

Existing specs follow the new structure and keep their intent. Node
tests cover the machine facts, tool status, checks, log lines, source
detection, the import request and the join commands.

Assisted-by: Claude Code:claude-sonnet-5-5

* docs: describe the Build tool pages, the import flow and the Explorer

Fine-tuning and quantization now describe the set up, check, run and
result steps and what the check list can and cannot say. The import
section explains the review step and why the size and memory appear only
after the import starts. The distributed page describes the Explorer
list, the join sheet and what listing a swarm publishes.

Assisted-by: Claude Code:claude-sonnet-5-5

* feat(ui): turn the hardware recommendations into a "Best for this machine" shelf

The shelf in the Models inspector put five columns into a 400 px pane,
so long model ids wrapped letter by letter underneath the size and the
memory figures. Each row now stacks the tag, the id and the size and
memory facts beside one Install button, and the id wraps inside its own
column.

Once a model is installed the shelf narrows to the best fit and keeps
the others behind a "N more that fit" toggle. Specs cover the ranking,
the layout, the narrowing and the install request against a gallery
fixture that carries the 4K estimate the shelf sizes against.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): quiet the Studio tab markers and say their state in words

The type tabs drew a saturated green dot for every modality that has a
model. The dot now uses a text colour, filled when a model is installed
and hollow when none is, and each tab carries "(model installed)" or
"(no model installed)" as hidden text so the state is not only a colour.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): stack the model editor empty state and drop its section hues

"No fields configured" sat in a flex row, so the icon, the title and
the text ran together. It now uses the stacked empty-state layout. The
section icons took a different status colour each (amber, red, green);
they now share one quiet colour, with the accent on the current section.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep the Home memory sentence whole on a phone and drop side rails

On a 390 px screen the memory strip clipped "2 models loaded" to make
room for the figure. The sentence now takes the first line and the
figure and device wrap under it.

The sweep also removed coloured left rails from the editor section
rail, the skill editor list, the install strip and the audio transform
notice (now an outlined note), plus unused chat rules that carried
rails and two glow animations that nothing referenced.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): line up hub pages, list the model templates, and stop clipped text

Medium-width pages inside Build and Operate were centred while the tab
bar above them was flush left, so the title started 60 px right of the
first tab. They now start at the bar's edge.

Add Model offered nine templates as a grid of identical cards with chip
clouds and inline styles. It is now one list of rows, each with the
field names it fills in on a single muted line.

Two clipped strings are fixed: the Studio voice field cut its
placeholder mid-word, and the phone job list ended the schedule line in
an ellipsis. The recommendation shelf also separates size and memory
with a dot, and the docs describe the shelf.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep the hidden Studio tab state inside its tab

The hidden state text added to each type tab was absolutely positioned
against the page, so on a phone it sat outside the scrolling tab row and
widened the page by hundreds of pixels. The tab is now the containing
block.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): cover the on-disk sizes on the Installed table, model page and cleanup sheet

The fixtures stub GET /api/models/storage. The default report is empty,
so existing specs keep the gallery estimates. makeStorage() builds a
report from files and the models that use them, the way the server
does, and storageSpec() is a models directory with shared and missing
files.

New specs cover the Size column and its shared line, the fallback when
the call fails or the user is not an admin, the files list on the model
page, a missing file, the bytes a removal frees with shared files, and
the cleanup findings. Node tests cover the storage helpers and the
batch arithmetic.

Assisted-by: Claude Code:claude-sonnet-5-5

* test(ui): wait for the page before pressing keys and ticking the clock

Two specs failed in loaded full runs and passed alone. The Alt+1 to
Alt+7 spec pressed a key before the composer had armed its key
handler. The capacity chart spec advanced the fake clock before the
poller had mounted, so it counted fewer readings than it expected.

Both now wait for the page to mount. The key spec retries a press that
lands during a re-render, and the clock spec advances in small steps and
polls for the row count.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): hide the Installed footer when the storage report is empty

An empty report from the storage call made the footer read "0.0 GB on
disk" next to sizes taken from the gallery estimate. An empty report
says nothing about the disk, so the footer now shows only the model
count. A spec covers it.

Assisted-by: Claude Code:claude-sonnet-5-5

* fix(ui): keep Explore pane actions inside the pane

The inspector actions sat in a non-wrapping flex row beside the title,
so the buttons ran past the pane edge once it got narrow. The row now
takes its own line and wraps.

The primary action (Install, Retry, Open) comes first. Manage
installation becomes a ghost button, and Open details moves to the end
of the row, so one action stands out and the others are quiet. No
action or test id is removed.

Add a spec that checks, in light and dark at several widths and with a
pane forced to 320 px, that every action stays inside the pane box and
that the pane keeps its inner padding.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor(ui): drop the type chips from the Studio composer

The Studio tabs and the composer's type chips listed the same seven
modes, so the page said the same thing twice. Keep the tabs as the one
place to switch modes.

The composer now shows the type it will open as a small label in its
header. The type suggestion from the typed words stays as the quiet
hint line under the prompt, and Alt+1 to Alt+7 still pick a type. The
composer root carries data-type, data-types and data-missing so tests
can read the state.

Specs pick a type through a shared Alt+digit helper and read a
missing model from the tab dot instead of a chip. Remove the unused
chip locale strings and CSS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor(ui): remove the section crumb above page titles

Page headers drew a small uppercase crumb with a short rule before it
above the title. On the hub pages it repeated the hub name, so Build
sat above a heading that also said Build.

PageHeader now renders only the title, the supporting line and the
actions. Drop the eyebrow prop, the route-derived section name, its CSS
and the unused section helper, and remove the explicit eyebrow props
from the pages that passed one. Pages stay reachable through the
sidebar and the hub tab bar.

Add a spec that checks several pages show their title with nothing
ahead of it in the header.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* refactor(ui): remove left accent rails from tiles, rows and quotes

Several surfaces marked state with a coloured strip on the left edge.
Replace each one with a cue that is not a rail:

- Stat cards lose the strip; the icon and value still carry the colour.
- The highlighted card is a raised surface with a firmer edge.
- The selected rail row is an accent wash with a hairline outline.
- The status stripe on rail items is a small status dot.
- The active failover row is a tinted row.
- Quotes in markdown and chat prose are italic instead of barred.
- The variant detail panel has a full hairline border.

Add a spec that walks the main routes in light and dark and fails on a
left border thicker than 1px, a sideways inset shadow, a narrow
absolute strip in ::before or ::after, or a narrow tall child pinned to
a left edge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-08 22:03:07 +02:00
1 parent 66d046edee
commit 4d7bdc6ff2
579 files changed
+77619 -30833

No files matched your search

-2
View File
@@ -15,7 +15,6 @@
"@codemirror/view": "^6.36.8",
"@fontsource-variable/geist": "^5.2.8",
"@fontsource-variable/geist-mono": "^5.2.7",
"@fortawesome/fontawesome-free": "^6.7.2",
"@lezer/highlight": "^1.2.1",
"@modelcontextprotocol/ext-apps": "^1.2.2",
"@modelcontextprotocol/sdk": "^1.30.0",
@@ -188,7 +187,6 @@
"@fontsource-variable/geist-mono": ["@fontsource-variable/geist-mono@5.2.7", "", {}, "sha512-ZKlZ5sjtalb2TwXKs400mAGDlt/+2ENLNySPx0wTz3bP3mWARCsUW+rpxzZc7e05d2qGch70pItt3K4qttbIYA=="],
"@fortawesome/fontawesome-free": ["@fortawesome/fontawesome-free@6.7.2", "", {}, "sha512-JUOtgFW6k9u4Y+xeIaEiLr3+cjoUPiAuLXoyKOJSia6Duzb7pq+A76P9ZdPDoAoxHdHzq6gE9/jKBGXlZT8FbA=="],
"@gulpjs/to-absolute-glob": ["@gulpjs/to-absolute-glob@4.0.0", "", { "dependencies": { "is-negated-glob": "^1.0.0" } }, "sha512-kjotm7XJrJ6v+7knhPaRgaT6q8F8K2jiafwYdNHLzmV0uGLuZY43FK6smNSHUPrhq5kX2slCUy+RGG/xGqmIKA=="],
+156
View File
@@ -0,0 +1,156 @@
// Fixtures for Users and keys, Account, sign-in and the invite page: auth
// status in each variant the server can answer, five users, invites, keys,
// quota rules and a month of usage, and one function that answers the calls
// those pages make and records the writes.
//
// Pure of Playwright imports so a script that is not a test can use it too.
const iso = (offsetMs) => new Date(Date.now() + offsetMs).toISOString()
const DAY = 86_400_000
const HOUR = 3_600_000
export const ADMIN = { id: 'u-alice', email: 'alice@lab.example', name: 'alice', role: 'admin', provider: 'local' }
export const MEMBER = { id: 'u-bob', email: 'bob@lab.example', name: 'bob', role: 'user', provider: 'github' }
export function statusFixture(variant = 'admin', extra = {}) {
const base = { authEnabled: true, staticApiKeyRequired: false, providers: ['local', 'github'], hasUsers: true, registrationMode: 'approval', user: null }
const variants = {
admin: { user: { ...ADMIN, permissions: {} } },
member: { user: { ...MEMBER, provider: 'local', permissions: { chat: true } } },
oauthMember: { user: { ...MEMBER, permissions: { chat: true } } },
signedOut: {},
localOnly: { providers: ['local'] },
oauthOnly: { providers: ['github', 'oidc'] },
firstAdmin: { providers: ['local'], hasUsers: false, registrationMode: 'approval' },
keyOnly: { authEnabled: false, staticApiKeyRequired: true, providers: [], hasUsers: false, registrationMode: '' },
inviteOnly: { providers: ['local'], registrationMode: 'invite' },
}
return { ...base, ...variants[variant], ...extra }
}
export const FEATURES = {
api_features: [
{ key: 'chat', label: 'Chat completions', default: true },
{ key: 'images', label: 'Image generation', default: true },
{ key: 'audio_speech', label: 'Audio speech', default: true },
{ key: 'embeddings', label: 'Embeddings', default: true },
],
agent_features: [
{ key: 'agents', label: 'Agents', default: false },
{ key: 'skills', label: 'Skills', default: false },
],
general_features: [{ key: 'fine_tuning', label: 'Fine-tuning', default: false }],
models: ['qwen3-8b-instruct', 'bge-m3', 'kokoro-82m', 'gemma-3-12b-it'],
}
const allOn = { chat: true, images: true, audio_speech: true, embeddings: true, agents: false, skills: false, fine_tuning: false }
export function usersFixture() {
return [
{ id: 'u-alice', email: 'alice@lab.example', name: 'alice', role: 'admin', status: 'active', provider: 'local', createdAt: iso(-90 * DAY), permissions: allOn },
{ id: 'u-bob', email: 'bob@lab.example', name: 'bob', role: 'user', status: 'active', provider: 'github', createdAt: iso(-60 * DAY),
permissions: { ...allOn, agents: true, skills: true }, allowed_models: { enabled: true, models: ['qwen3-8b-instruct', 'bge-m3'] }, quotas: [] },
{ id: 'u-carol', email: 'carol@lab.example', name: 'carol', role: 'user', status: 'active', provider: 'oidc', createdAt: iso(-30 * DAY),
permissions: { ...allOn, agents: true }, allowed_models: { enabled: false, models: [] },
quotas: [{ id: 'q1', model: '', max_requests: null, max_total_tokens: 2_000_000, window: '1d', current_requests: 0, current_total_tokens: 1_180_000 }] },
{ id: 'u-dave', email: 'dave@lab.example', name: 'dave', role: 'user', status: 'pending', provider: 'local', createdAt: iso(-1 * DAY), permissions: allOn, allowed_models: { enabled: false, models: [] } },
{ id: 'u-erin', email: 'erin@lab.example', name: '', role: 'user', status: 'disabled', provider: 'local', createdAt: iso(-120 * DAY), permissions: allOn, allowed_models: { enabled: false, models: [] } },
]
}
export function invitesFixture() {
return [
{ id: 'i1', codePrefix: '3f9a1c2d', createdAt: iso(-1 * DAY), expiresAt: iso(6 * DAY), usedAt: null, usedBy: null, createdBy: { id: 'u-alice', name: 'alice' } },
{ id: 'i2', codePrefix: '77be0a14', createdAt: iso(-5 * DAY), expiresAt: iso(2 * DAY), usedAt: iso(-3 * DAY), usedBy: { id: 'u-dave', name: 'dave' }, createdBy: { id: 'u-alice', name: 'alice' } },
{ id: 'i3', codePrefix: '9c11d5e0', createdAt: iso(-9 * DAY), expiresAt: iso(-2 * DAY), usedAt: null, usedBy: null, createdBy: { id: 'u-alice', name: 'alice' } },
]
}
export function keysFixture() {
return [
{ id: 'k1', name: 'ci-bot', keyPrefix: 'lai-3f9a1c', role: 'admin', createdAt: iso(-35 * DAY), lastUsed: iso(-2 * HOUR), disabled: false, expiresAt: iso(55 * DAY) },
{ id: 'k2', name: 'home-assistant', keyPrefix: 'lai-71bc09', role: 'admin', createdAt: iso(-270 * DAY), lastUsed: iso(-3 * DAY), disabled: true },
{ id: 'k3', name: 'notebook', keyPrefix: 'lai-a02d44', role: 'admin', createdAt: iso(-200 * DAY), lastUsed: null, disabled: false },
]
}
export const QUOTAS = [
{ id: 'q1', model: '', max_requests: null, max_total_tokens: 2_000_000, window: '1d', current_requests: 0, current_total_tokens: 1_180_000, resets_at: iso(5 * HOUR) },
{ id: 'q2', model: '', max_requests: 300, max_total_tokens: null, window: '1h', current_requests: 212, current_total_tokens: 0, resets_at: iso(HOUR / 2) },
{ id: 'q3', model: 'qwen3-8b-instruct', max_requests: null, max_total_tokens: 1_100_000, window: '1d', current_requests: 0, current_total_tokens: 1_030_000, resets_at: iso(5 * HOUR) },
]
export function myUsageFixture() {
const usage = []
const models = [['qwen3-8b-instruct', 181_000, 120], ['bge-m3', 44_000, 90], ['kokoro-82m', 31_000, 30], ['gemma-3-12b-it', 35_000, 35]]
for (const [model, total, requests] of models) {
usage.push({ bucket: '2026-10-07', model, prompt_tokens: Math.round(total * 0.74), completion_tokens: Math.round(total * 0.26), total_tokens: total, request_count: requests })
}
const totals = usage.reduce((t, b) => ({
prompt_tokens: t.prompt_tokens + b.prompt_tokens, completion_tokens: t.completion_tokens + b.completion_tokens,
total_tokens: t.total_tokens + b.total_tokens, request_count: t.request_count + b.request_count,
}), { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0, request_count: 0 })
return { usage, totals }
}
// Answers the auth calls the pages make. `writes` collects every call that
// changes something, as { method, path, body }. Pass `status` to pick the
// auth status variant.
export async function mockAccess(page, { status = 'admin', statusExtra = {}, users = usersFixture(), invites = invitesFixture(), keys = keysFixture(), quotas = QUOTAS } = {}) {
const state = { writes: [], users, invites, keys, quotas, failNext: null }
const body = (route) => { try { return route.request().postDataJSON() } catch { return null } }
const record = (route, path) => state.writes.push({ method: route.request().method(), path, body: body(route) })
const json = (route, data, status = 200) => route.fulfill({ status, json: data })
await page.route('**/api/auth/status', route => json(route, statusFixture(status, statusExtra)))
await page.route('**/api/auth/me', route => json(route, { user: statusFixture(status).user }))
await page.route('**/api/auth/quota', route => json(route, { quotas: state.quotas }))
await page.route('**/api/auth/usage?*', route => json(route, myUsageFixture()))
await page.route('**/api/auth/admin/features', route => json(route, FEATURES))
await page.route('**/api/auth/admin/users', route => json(route, { users: state.users }))
await page.route('**/api/auth/admin/users/*/**', async route => {
const req = route.request()
const path = new URL(req.url()).pathname
if (req.method() === 'GET' && path.endsWith('/quotas')) return json(route, state.quotas.slice(0, 1))
record(route, path)
if (state.failNext) { const error = state.failNext; state.failNext = null; return json(route, { error }, 400) }
return json(route, { message: 'ok' })
})
await page.route('**/api/auth/admin/users/*', async route => {
const req = route.request()
if (req.method() === 'GET') return route.fallback()
record(route, new URL(req.url()).pathname)
return json(route, { message: 'ok' })
})
await page.route('**/api/auth/admin/invites', async route => {
if (route.request().method() === 'POST') {
record(route, '/api/auth/admin/invites')
return json(route, { id: 'i-new', code: 'c0ffee00c0ffee00c0ffee00c0ffee00', expiresAt: iso(7 * DAY), createdAt: iso(0) }, 201)
}
return json(route, { invites: state.invites })
})
await page.route('**/api/auth/admin/invites/*', async route => {
record(route, new URL(route.request().url()).pathname)
return json(route, { message: 'ok' })
})
await page.route('**/api/auth/api-keys', async route => {
if (route.request().method() === 'POST') {
record(route, '/api/auth/api-keys')
return json(route, { key: 'lai-9d41f0c27be84a0c92aa5d6b', id: 'k-new', name: body(route)?.name || 'key', keyPrefix: 'lai-9d41f0', role: 'user', createdAt: iso(0) }, 201)
}
return json(route, { keys: state.keys })
})
await page.route('**/api/auth/api-keys/*', async route => {
const req = route.request()
record(route, new URL(req.url()).pathname)
if (req.method() === 'DELETE') state.keys = state.keys.filter(k => !req.url().endsWith(`/${k.id}`))
return json(route, { message: 'ok' })
})
await page.route('**/api/auth/profile', async route => { record(route, '/api/auth/profile'); return json(route, { message: 'ok' }) })
await page.route('**/api/auth/password', async route => {
record(route, '/api/auth/password')
if (state.failNext) { const e = state.failNext; state.failNext = null; return json(route, e, 400) }
return json(route, { message: 'ok' })
})
return state
}
@@ -44,7 +44,7 @@ test.describe('Account API keys pause', () => {
})
await page.goto('/app/account')
await page.getByRole('button', { name: /API Keys/ }).click()
await page.getByRole('tab', { name: 'API keys' }).click()
})
test('shows paused badges and resume buttons', async ({ page }) => {
+133
View File
@@ -0,0 +1,133 @@
import { test, expect } from './coverage-fixtures.js'
import { mockAccess } from './access-fixtures.js'
test.describe('Account', () => {
let state
test.beforeEach(async ({ page }) => {
state = await mockAccess(page)
await page.goto('/app/account')
await expect(page.getByTestId('account-profile')).toBeVisible()
})
test('has four tabs, opens on the profile and keeps the tab in the address', async ({ page }) => {
const tabs = await page.getByRole('tab').allTextContents()
expect(tabs).toEqual(['Profile', 'Security', 'API keys', 'Usage'])
await expect(page.getByRole('tab', { name: 'Profile' })).toHaveAttribute('aria-selected', 'true')
await page.getByRole('tab', { name: 'Usage' }).click()
await expect(page).toHaveURL(/tab=usage/)
await page.goBack().catch(() => {})
await page.goto('/app/account?tab=keys')
await expect(page.getByTestId('api-keys-panel')).toBeVisible()
})
test('profile saves the name and the picture address', async ({ page }) => {
const save = page.getByRole('button', { name: 'Save' })
await expect(save).toBeDisabled()
await page.getByLabel('Display name').fill('Alice L')
await page.getByLabel('Avatar URL').fill('https://example.org/a.png')
await expect(save).toBeEnabled()
await save.click()
await expect(page.getByText('Profile updated')).toBeVisible()
expect(state.writes[0]).toMatchObject({ method: 'PUT', path: '/api/auth/profile', body: { name: 'Alice L', avatar_url: 'https://example.org/a.png' } })
await expect(page.getByTestId('account-profile')).toContainText('alice@lab.example')
await expect(page.getByTestId('account-profile')).toContainText('admin · local sign-in')
})
test('security changes a local password and checks the confirmation', async ({ page }) => {
await page.getByRole('tab', { name: 'Security' }).click()
const form = page.getByTestId('account-security')
await form.getByLabel('Current password').fill('old-password-123')
await form.getByLabel('New password').fill('new-password-1234')
await form.getByLabel('Confirm password').fill('different-1234')
await form.getByRole('button', { name: 'Change password' }).click()
await expect(page.getByText('Passwords do not match')).toBeVisible()
expect(state.writes).toEqual([])
await form.getByLabel('Confirm password').fill('new-password-1234')
await form.getByRole('button', { name: 'Change password' }).click()
await expect(page.getByText('Password changed')).toBeVisible()
expect(state.writes[0].body).toEqual({ current_password: 'old-password-123', new_password: 'new-password-1234', acknowledge_weak_password: false })
await expect(form.getByLabel('Current password')).toHaveValue('')
})
test('a weak password can be sent anyway after the person says so', async ({ page }) => {
state.failNext = { error: 'Password is weak', overridable: true }
await page.getByRole('tab', { name: 'Security' }).click()
const form = page.getByTestId('account-security')
await form.getByLabel('Current password').fill('old-password-123')
await form.getByLabel('New password').fill('password1234')
await form.getByLabel('Confirm password').fill('password1234')
await form.getByRole('button', { name: 'Change password' }).click()
await expect(form.getByRole('alert')).toContainText('Password is weak')
await form.getByLabel('Use this password anyway').check()
await form.getByRole('button', { name: 'Change password' }).click()
await expect(page.getByText('Password changed')).toBeVisible()
expect(state.writes[1].body.acknowledge_weak_password).toBe(true)
})
test('usage shows the last 30 days, tokens by model and the limits an admin set', async ({ page }) => {
await page.getByRole('tab', { name: 'Usage' }).click()
const usage = page.getByTestId('account-usage')
await expect(usage.getByTestId('usage-requests')).toHaveText('275')
await expect(usage).toContainText('Tokens in')
await expect(usage.getByRole('list', { name: 'Tokens by model' }).locator('li')).toHaveCount(4)
await expect(usage.getByRole('list', { name: 'Tokens by model' }).locator('li').first()).toContainText('qwen3-8b-instruct')
const limits = page.getByTestId('account-limits').locator('li')
await expect(limits).toHaveCount(3)
await expect(limits.first()).toContainText('All models, tokens per day')
await expect(limits.first()).toContainText('1.2M of 2M')
await expect(limits.nth(1)).toContainText('requests per hour')
await expect(limits.nth(2).getByRole('progressbar')).toHaveAttribute('aria-valuenow', '94')
await expect(usage.getByRole('link', { name: 'Open full usage' })).toHaveAttribute('href', '/app/usage')
})
})
test.describe('Account: other people and states', () => {
test('a person who signs in with GitHub is told the password is not theirs to change', async ({ page }) => {
await mockAccess(page, { status: 'oauthMember' })
await page.goto('/app/account?tab=security')
await expect(page.getByTestId('account-oauth-only')).toContainText('not available for github accounts')
await expect(page.getByLabel('Current password')).toHaveCount(0)
})
test('a person who is not an admin sees their own account, usage and limits', async ({ page }) => {
await mockAccess(page, { status: 'member' })
await page.goto('/app/account?tab=usage')
await expect(page.getByTestId('account-usage')).toBeVisible()
await expect(page.getByTestId('account-page')).toContainText('bob · user')
await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toHaveCount(0)
})
test('without authentication there is no account', async ({ page }) => {
await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: false, staticApiKeyRequired: false, providers: [] } }))
await page.goto('/app/account')
await expect(page.getByText('Account unavailable')).toBeVisible()
})
test('the usage tab says so when the call fails', async ({ page }) => {
await mockAccess(page)
await page.route('**/api/auth/usage?*', route => route.fulfill({ status: 500, json: { error: 'boom' } }))
await page.goto('/app/account?tab=usage')
await expect(page.getByRole('alert')).toContainText('Failed to load your usage')
})
test('a person with no limits and no requests sees calm empty states', async ({ page }) => {
await mockAccess(page, { quotas: [] })
await page.route('**/api/auth/usage?*', route => route.fulfill({ json: { usage: [], totals: {} } }))
await page.goto('/app/account?tab=usage')
await expect(page.getByText('No requests from you in the last 30 days.')).toBeVisible()
await expect(page.getByText('No limits are set on your account.')).toBeVisible()
})
})
test.describe('Account: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('every tab fits the screen', async ({ page }) => {
await mockAccess(page)
for (const tab of ['profile', 'security', 'keys', 'usage']) {
await page.goto(`/app/account?tab=${tab}`)
await expect(page.getByRole('tabpanel')).toBeVisible()
await page.waitForTimeout(150)
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth), tab).toBe(false)
}
})
})
+133 -1
View File
@@ -40,7 +40,139 @@ test('lists live operations and cancels one from a labelled button', async ({ pa
await expect(card).toContainText('22%')
await card.locator('.operation-card__cancel').click()
expect(cancelledPath).toBe('/api/operations/job-gemma/cancel')
// A cancel waits for its undo window. Nothing has been stopped yet.
await expect(page.getByTestId('activity-undo-toast')).toContainText('Cancelling gemma-3-27b-it')
await expect(card).toContainText('Cancelling unless you undo')
expect(cancelledPath).toBe('')
await expect.poll(() => cancelledPath, { timeout: 15_000 }).toBe('/api/operations/job-gemma/cancel')
})
const download = (id, over = {}) => ({
id,
name: id,
jobID: `job-${id}`,
progress: 22,
taskType: 'installation',
isBackend: false,
isQueued: false,
isDeletion: false,
cancellable: true,
phase: 'downloading',
...over,
})
test('undoing a cancel keeps the download running and never calls the server', async ({ page }) => {
await stub(page, { operations: [download('gemma-3-27b-it')] })
const calls = []
await page.route('**/api/operations/job-gemma-3-27b-it/*', (route) => {
calls.push(new URL(route.request().url()).pathname)
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.goto('/app/activity')
const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' })
await card.locator('.operation-card__cancel').click()
await page.getByTestId('activity-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(page.getByTestId('activity-undo-toast')).toHaveCount(0)
await expect(card.locator('.operation-card__cancel')).toBeVisible()
await expect(card).not.toContainText('Cancelling unless you undo')
// The window is 8 seconds; wait it out to be sure nothing was queued.
await page.waitForTimeout(9_000)
expect(calls).toEqual([])
})
test('closing the undo toast cancels at once', async ({ page }) => {
await stub(page, { operations: [download('gemma-3-27b-it')] })
const calls = []
await page.route('**/api/operations/job-gemma-3-27b-it/cancel', (route) => {
calls.push('cancel')
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.goto('/app/activity')
await page.locator('.operation-card__cancel').click()
await page.getByTestId('activity-undo-toast').getByRole('button', { name: 'Cancel now' }).click()
await expect.poll(() => calls).toEqual(['cancel'])
})
test('cancelling a second download ends the first one\'s window', async ({ page }) => {
await stub(page, { operations: [download('alpha'), download('beta')] })
const calls = []
await page.route('**/api/operations/*/cancel', (route) => {
calls.push(new URL(route.request().url()).pathname)
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.goto('/app/activity')
await page.locator('.operation-card').filter({ hasText: 'alpha' }).locator('.operation-card__cancel').click()
await page.locator('.operation-card').filter({ hasText: 'beta' }).locator('.operation-card__cancel').click()
// One toast at a time, and the first cancel went through.
await expect(page.getByTestId('activity-undo-toast')).toHaveCount(1)
await expect(page.getByTestId('activity-undo-toast')).toContainText('Cancelling beta')
await expect.poll(() => calls).toEqual(['/api/operations/job-alpha/cancel'])
})
test('leaving the page runs a cancel that was still waiting', async ({ page }) => {
await stub(page, { operations: [download('gemma-3-27b-it')] })
const calls = []
await page.route('**/api/operations/job-gemma-3-27b-it/cancel', (route) => {
calls.push('cancel')
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.goto('/app/activity')
await page.locator('.operation-card__cancel').click()
await expect(page.getByTestId('activity-undo-toast')).toBeVisible()
await page.getByRole('link', { name: 'Backends', exact: true }).first().click()
await expect.poll(() => calls).toEqual(['cancel'])
})
test('a download shows its progress, its size and the time left', async ({ page }) => {
await stub(page, {
operations: [download('gemma-3-27b-it', { progress: 38, currentBytes: 1.4 * 1024 ** 3, totalBytes: 3.8 * 1024 ** 3 })],
})
await page.goto('/app/activity')
const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' })
await expect(card.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '38')
await expect(card).toContainText('1.4 GB / 3.8 GB')
await expect(card.getByRole('button', { name: 'Pause gemma-3-27b-it and keep downloaded data' })).toBeVisible()
})
test('a cancelled install can be started again from the record', async ({ page }) => {
await stub(page, {
history: [{
id: 'whisper-large-v3', name: 'whisper-large-v3', jobID: 'job-w', isBackend: false, taskType: 'installation',
outcome: 'cancelled', startedAt: '2026-07-28T13:40:00Z', finishedAt: '2026-07-28T13:41:00Z',
}, {
id: 'piper', name: 'piper', jobID: 'job-p', isBackend: true, taskType: 'deletion',
outcome: 'cancelled', startedAt: '2026-07-28T13:40:00Z', finishedAt: '2026-07-28T13:41:00Z',
}],
})
const calls = []
await page.route('**/api/models/install/**', (route) => {
calls.push(new URL(route.request().url()).pathname)
return route.fulfill({ contentType: 'application/json', body: '{}' })
})
await page.goto('/app/activity')
// A cancelled removal is not an install, so it offers nothing to start.
await expect(page.locator('.activity-row__resume')).toHaveCount(1)
await page.locator('.activity-row').filter({ hasText: 'whisper-large-v3' }).locator('.activity-row__resume').click()
await expect.poll(() => calls).toEqual(['/api/models/install/whisper-large-v3'])
})
test('the empty state names the next step', async ({ page }) => {
await stub(page)
await page.goto('/app/activity')
await expect(page.locator('.activity-empty')).toContainText('No operations since startup')
await expect(page.locator('.activity-empty').getByRole('link', { name: 'Browse models' })).toHaveAttribute('href', '/app/models')
})
test('fits a phone, with the actions under the name', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await stub(page, { operations: [download('gemma-3-27b-it')] })
await page.goto('/app/activity')
const card = page.locator('.operation-card').filter({ hasText: 'gemma-3-27b-it' })
await expect(card.locator('.operation-card__cancel')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
})
test('pauses a model download without invoking destructive cancel', async ({ page }) => {
+163
View File
@@ -0,0 +1,163 @@
import { test, expect } from './coverage-fixtures.js'
import { mockSwarm, clusterNodes } from './swarm-fixtures.js'
// "Add a node": which way the machine joins, the one command to run on it, and
// a live line that says when it arrived. "Found" is a real node that was not in
// the roster when the page opened, never a timer.
const command = page => page.getByTestId('command-block').last()
test.describe('Add a node', () => {
test('starts on a registered worker with a copyable command that carries no secret', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/nodes/add')
await expect(page.getByRole('heading', { name: 'Add a node' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('radio', { name: 'Registered worker' })).toHaveAttribute('aria-checked', 'true')
await expect(command(page)).toContainText('LOCALAI_REGISTER_TO="http://127.0.0.1:8089"')
await expect(command(page)).toContainText('LOCALAI_REGISTRATION_TOKEN="$TOKEN"')
await expect(command(page)).toContainText('localai/localai:latest-cpu worker')
await expect(page.getByText('This page never shows it.')).toBeVisible()
await expect(page.getByRole('link', { name: 'All nodes' })).toHaveAttribute('href', '/app/nodes')
// A cluster needs no step for turning distributed mode on.
await expect(page.getByText('Turn on distributed mode first')).toHaveCount(0)
})
test('changes the command with the hardware, the way to run it and the worker type', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/nodes/add')
await page.getByRole('radio', { name: 'CUDA 12', exact: true }).click()
await expect(command(page)).toContainText('--gpus all')
await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 worker')
await page.getByRole('radio', { name: 'Agent worker' }).click()
await expect(command(page)).toContainText('latest-gpu-nvidia-cuda-12 agent-worker')
await page.getByRole('button', { name: 'Dev' }).click()
await expect(command(page)).toContainText('master-gpu-nvidia-cuda-12 agent-worker')
await page.getByRole('radio', { name: 'CLI' }).click()
await expect(command(page)).toContainText('local-ai agent-worker')
await expect(command(page)).toContainText('--registration-token "$LOCALAI_REGISTRATION_TOKEN"')
// Hardware means nothing to a plain command.
await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0)
})
test('copies the command and says so', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']).catch(() => {})
await mockSwarm(page)
await page.goto('/app/nodes/add')
const copy = page.getByRole('button', { name: 'Copy command' }).last()
await copy.click()
await expect(copy).toContainText('Copied')
const text = await command(page).locator('code').innerText()
expect(text).toContain('LOCALAI_REGISTER_TO')
})
test('waits for a worker, then shows it when one registers, and approves it', async ({ page }) => {
let polls = 0
const known = clusterNodes()
const arrived = { id: 'n-new', name: 'gpu-box-9', node_type: 'backend', address: '10.0.4.19:50051', status: 'pending', version: 'v3.9.0', labels: {}, last_heartbeat: new Date().toISOString() }
const log = await mockSwarm(page, { onNodes: () => { polls += 1; return polls < 3 ? known : [...known, arrived] } })
await page.goto('/app/nodes/add')
const wait = page.getByRole('status').filter({ hasText: 'Listening for a worker' })
await expect(wait).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('found-node')).toHaveCount(0)
const found = page.getByTestId('found-node')
await expect(found).toBeVisible({ timeout: 20_000 })
await expect(found).toContainText('gpu-box-9')
await expect(found).toContainText('Waiting for approval')
await expect(page.getByRole('heading', { name: 'A machine arrived' })).toBeVisible()
await expect(wait).toHaveCount(0)
await expect(found.getByRole('link', { name: 'Open node' })).toHaveAttribute('href', '/app/nodes/n-new')
await found.getByRole('button', { name: 'Approve' }).click()
await expect.poll(() => log.filter(entry => entry.path === '/api/nodes/n-new/approve').length).toBe(1)
await expect(page.getByText('gpu-box-9 approved')).toBeVisible()
})
test('does not treat the nodes it already knew as arrivals', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/nodes/add')
await expect(page.getByRole('status').filter({ hasText: 'Listening for a worker' })).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(3500)
await expect(page.getByTestId('found-node')).toHaveCount(0)
})
test('a single install starts with turning distributed mode on, as text it can copy', async ({ page }) => {
await mockSwarm(page, { nodes: null, features: { distributed: false } })
await page.goto('/app/nodes/add')
await expect(page.getByRole('heading', { name: 'Turn on distributed mode first' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('command-block').first()).toContainText('local-ai run --distributed')
await expect(page.getByTestId('command-block').first()).toContainText('--distributed-nats')
await expect(page.getByRole('link', { name: 'distributed mode documentation' })).toHaveAttribute('href', 'https://localai.io/features/distributed-mode/')
await expect(page.getByRole('heading', { name: 'Run this on the new machine' })).toBeVisible()
})
test.describe('over P2P', () => {
const p2p = { token: 'net-token-1', stats: { federated: { online: 1, total: 1 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } } }
test('a peer instance joins with the network token', async ({ page }) => {
await mockSwarm(page, { p2p })
await page.goto('/app/nodes/add?join=peer')
await expect(page.getByRole('radio', { name: 'Peer instance' })).toHaveAttribute('aria-checked', 'true', { timeout: 15_000 })
await expect(command(page)).toContainText('-e TOKEN="net-token-1"')
await expect(command(page)).toContainText('run --federated --p2p')
await expect(page.getByText('1 peers online')).toBeVisible()
})
test('a memory shard offers llama.cpp RPC and MLX', async ({ page }) => {
await mockSwarm(page, { p2p })
await page.goto('/app/nodes/add?join=shard')
await expect(command(page)).toContainText('worker p2p-llama-cpp-rpc', { timeout: 15_000 })
await page.getByRole('radio', { name: 'MLX (Apple Silicon)' }).click()
await expect(command(page)).toContainText('worker p2p-mlx')
await expect(command(page)).toContainText('latest-metal-darwin-arm64')
// MLX runs on a Mac: GPU build targets do not apply.
await expect(page.getByRole('radio', { name: 'CUDA 12', exact: true })).toHaveCount(0)
})
test('says to turn P2P on first when it is off', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/nodes/add?join=peer')
await expect(page.getByRole('heading', { name: 'Turn on P2P first' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('command-block').first()).toContainText('run --p2p')
await expect(page.getByTestId('command-block').last()).toContainText('your-token-here')
})
test('shows a new peer when the online count goes up', async ({ page }) => {
let calls = 0
await mockSwarm(page)
await page.route('**/api/p2p/token', route => route.fulfill({ status: 200, contentType: 'text/plain', body: 'net-token-1' }))
await page.route('**/api/p2p/stats', route => {
calls += 1
const online = calls < 3 ? 1 : 2
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ federated: { online, total: 2 }, llama_cpp_workers: { online: 0, total: 0 }, mlx_workers: { online: 0, total: 0 } }) })
})
await page.goto('/app/nodes/add?join=peer')
await expect(page.getByTestId('found-peer')).toContainText('A new peer joined: 2 online', { timeout: 20_000 })
})
})
test('first run of a cluster carries the same steps in place', async ({ page }) => {
await mockSwarm(page, { nodes: [] })
await page.goto('/app/nodes')
await expect(page.getByTestId('swarm-first-run')).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('heading', { name: 'How should it join?' })).toBeVisible()
await expect(command(page)).toContainText('LOCALAI_REGISTER_TO')
})
test('a phone scrolls the command sideways instead of the page', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockSwarm(page)
await page.goto('/app/nodes/add')
const block = command(page).locator('pre')
await expect(block).toBeVisible({ timeout: 15_000 })
const { scrollWidth, clientWidth } = await block.evaluate(el => ({ scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }))
expect(scrollWidth).toBeGreaterThan(clientWidth)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
const copy = await page.getByRole('button', { name: 'Copy command' }).last().boundingBox()
expect(copy.x + copy.width).toBeLessThanOrEqual(390)
// The method switch scrolls too; none of its choices is cut off for good.
await page.getByRole('radio', { name: 'Memory shard' }).scrollIntoViewIfNeeded()
await expect(page.getByRole('radio', { name: 'Memory shard' })).toBeInViewport()
})
})
+16 -13
View File
@@ -1,30 +1,33 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Admin console', () => {
test('admin pages render inside the grouped console rail', async ({ page }) => {
test('admin pages render under the Operate tab bar', async ({ page }) => {
await page.goto('/app/backends')
const rail = page.locator('.console-rail')
await expect(rail).toBeVisible()
// Four groups since the overview landed: Inference folded into Runtime
// (both are "the runtime right now"), Access and System into Administration.
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
const bar = page.locator('.dk-hubtabs')
await expect(bar).toBeVisible()
// Six tabs for the single-node case; Swarm only appears with distributed
// mode on. The tab that owns the current page carries aria-current.
for (const name of ['Status', 'This machine', 'Runtime', 'Traffic', 'Settings']) {
await expect(bar.getByRole('link', { name, exact: false })).toBeVisible()
}
await expect(bar.locator('[data-hub-tab="runtime"]')).toHaveAttribute('aria-current', 'page')
})
test('console rail cross-navigates between admin pages', async ({ page }) => {
test('the tab bar cross-navigates between admin pages', async ({ page }) => {
await page.goto('/app/backends')
const settings = page.locator('.console-rail a.nav-item[href="/app/settings"]')
const settings = page.locator('.dk-hubtabs a[href="/app/settings"]')
await expect(settings).toBeVisible()
await settings.click()
await expect(page).toHaveURL(/\/app\/settings/)
// Rail persists across admin navigation (layout route, not per-page chrome)
await expect(page.locator('.console-rail')).toBeVisible()
// The bar persists across admin navigation (layout route, not per-page chrome)
await expect(page.locator('.dk-hubtabs')).toBeVisible()
await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"]')).toHaveAttribute('aria-current', 'page')
})
test('rail links to external API docs', async ({ page }) => {
test('the bar links to the external API docs', async ({ page }) => {
await page.goto('/app/settings')
const api = page.locator('.console-rail a[href$="/swagger/index.html"]')
const api = page.locator('.dk-hubtabs a[href$="/swagger/index.html"]')
await expect(api).toBeVisible()
await expect(api).toHaveAttribute('target', '_blank')
})
})
@@ -0,0 +1,101 @@
import { test, expect } from './coverage-fixtures.js'
import { mockJobs } from './jobs-fixtures.js'
test.describe('A job run', () => {
test('a finished job reads as a document: task, outcome, delivery and steps', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-done-0002')
const doc = page.getByTestId('job-page')
await expect(doc.getByRole('heading', { level: 1 })).toContainText('repo-triage, today')
await expect(doc).toContainText('Done')
await expect(doc).toContainText('Started by the schedule')
await expect(doc).toContainText('Took 52 s')
await expect(doc).toContainText('job-done-0002')
await expect(doc.getByRole('heading', { name: 'Task' })).toBeVisible()
await expect(doc.locator('.aj-prompt')).toContainText('Read the new issues in example/app and label each one.')
await expect(doc.getByRole('heading', { name: 'Outcome' })).toBeVisible()
await expect(doc).toContainText('Labelled 12 issues and routed 3 of them to the person on call.')
await expect(doc.locator('.cx-prose table')).toBeVisible()
await expect(doc.getByTestId('job-webhook')).toContainText('Webhook delivered')
await expect(doc.getByRole('button', { name: 'Run again' })).toBeEnabled()
await expect(doc.getByRole('link', { name: 'Open task' })).toHaveAttribute('href', '/app/agent-jobs/tasks/t2')
})
test('the steps the server recorded are rows that open into what they hold', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-done-0002')
const rows = page.getByTestId('job-traces').locator('li')
await expect(rows).toHaveCount(2)
await expect(rows.nth(1)).toContainText('list_issues')
await expect(rows.nth(1)).toContainText('Tool call')
await expect(rows.nth(1).locator('.aj-trace__body')).toHaveCount(0)
await rows.nth(1).getByRole('button').click()
await expect(rows.nth(1).locator('.aj-trace__body')).toContainText('example/app')
await expect(rows.nth(1).locator('.aj-trace__body')).toContainText('Listing issues')
})
test('a job with no recorded steps says so', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-done-0004')
await expect(page.getByText('The server recorded no steps for this job.')).toBeVisible()
await expect(page.getByTestId('job-traces')).toHaveCount(0)
})
test('a failed job says what happened in plain words and offers one next action', async ({ page }) => {
const { seen } = await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-fail-0001')
const fail = page.getByTestId('job-failed')
await expect(fail).toContainText('This job failed.')
await expect(fail).toContainText('read_page: timeout after 20 s (3 of 3 attempts)')
await expect(page.locator('.aj-prompt .aj-gap[data-filled]')).toHaveCount(3)
await fail.getByRole('button', { name: 'Run again now' }).click()
expect(seen.jobExecutes).toEqual([{ task_id: 't1', parameters: { topic: 'local inference', format: 'bullet points', items: '8' } }])
await expect(page).toHaveURL(/\/app\/agent-jobs\/jobs\/job-new-0006$/)
await expect(page.getByTestId('job-working')).toContainText('waiting to start')
})
test('a running job says it is working and Cancel stops it', async ({ page }) => {
const { seen } = await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-run-0003')
await expect(page.getByTestId('job-working')).toContainText('still running')
await expect(page.getByRole('button', { name: 'Run again' })).toHaveCount(0)
await page.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByText('Job cancelled')).toBeVisible()
expect(seen.cancels).toEqual(['job-run-0003'])
await expect(page.getByText('This job was cancelled before it finished.')).toBeVisible({ timeout: 6000 })
})
test('does not invent a duration when the job has no end time', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-stop-0005')
await expect(page.getByTestId('job-page')).not.toContainText('Took')
await expect(page.getByTestId('job-page')).toContainText('Cancelled')
await expect(page.getByTestId('job-page')).toContainText('Started by the API')
})
test('says so when the job does not exist', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/nope')
await expect(page.getByTestId('job-missing')).toContainText('Job not found')
})
test('copies its address', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-done-0002')
await page.getByRole('button', { name: 'Copy link' }).click()
await expect(page.getByText('Link copied')).toBeVisible()
})
})
test.describe('A job run on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('fits the width', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/jobs/job-fail-0001')
await expect(page.getByTestId('job-failed')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
})
@@ -0,0 +1,227 @@
import { test, expect } from './coverage-fixtures.js'
import { mockJobs } from './jobs-fixtures.js'
test.describe('A task', () => {
test('shows what it is: model, schedule in words, runs, prompt with its gaps', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/tasks/t1')
const facts = page.getByTestId('task-facts')
await expect(page.getByRole('heading', { name: 'daily-digest' })).toBeVisible()
await expect(facts).toContainText('qwen3-8b-instruct')
await expect(facts).toContainText('Every day at 07:00')
await expect(facts).toContainText('0 7 * * *')
await expect(facts).toContainText('1 webhook')
await expect(facts.getByRole('switch')).toHaveAttribute('aria-checked', 'true')
const runs = page.getByTestId('task-runs')
await expect(runs.locator('a')).toHaveCount(2)
await expect(runs.locator('a').first()).toContainText('read_page: timeout after 20 s')
await expect(runs.locator('a').first()).toContainText('Started by the schedule, took 1 min 06 s')
const gaps = page.locator('.aj-prompt .aj-gap')
await expect(gaps).toHaveCount(3)
await expect(gaps.first()).toHaveText('local inference')
await expect(page.getByText('Audience: engineers who run models on their own hardware.')).toBeVisible()
await expect(page.getByText('Times follow the clock of the machine that runs LocalAI.')).toBeVisible()
await expect(page.locator('.dk-hubtabs [data-hub-tab="jobs"]')).toHaveAttribute('aria-current', 'page')
})
test('a task with no schedule says it runs when started; the API examples stay one click away', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/tasks/t4')
await expect(page.getByText('This task has no schedule.')).toBeVisible()
await expect(page.getByTestId('task-facts').getByRole('switch')).toHaveAttribute('aria-checked', 'false')
await expect(page.getByText('This task has not run yet.')).toBeVisible()
await page.getByText('Use it from the API').click()
await expect(page.getByText(/api\/agent\/tasks\/ad-hoc-research\/execute/).first()).toBeVisible()
})
test('the switch saves the task; Run now opens the dialog; a run row opens the job', async ({ page }) => {
const { seen } = await mockJobs(page)
await page.goto('/app/agent-jobs/tasks/t1')
await page.getByTestId('task-facts').getByRole('switch').click()
await expect(page.getByText('daily-digest is off')).toBeVisible()
expect(seen.puts[0].body.enabled).toBe(false)
await page.getByRole('button', { name: 'Run now' }).click()
await expect(page.getByTestId('run-task-dialog')).toContainText('Run daily-digest now')
await page.keyboard.press('Escape')
await page.getByTestId('task-runs').locator('a').first().click()
await expect(page).toHaveURL(/\/app\/agent-jobs\/jobs\/job-fail-0001$/)
})
test('a task that does not exist says so', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/tasks/nope')
await expect(page.getByTestId('task-missing')).toContainText('Task not found')
})
})
test.describe('Edit a task', () => {
async function edit(page, options) {
const mocked = await mockJobs(page, options)
await page.goto('/app/agent-jobs/tasks/t1/edit')
await expect(page.getByTestId('task-edit')).toBeVisible()
return mocked
}
test('sections fold, with a ready mark and a one-line summary', async ({ page }) => {
await edit(page)
const basics = page.locator('[data-section="basics"]')
await expect(basics.locator('.ag-fold__sum')).toHaveText('daily-digest, qwen3-8b-instruct')
await expect(basics.locator('.ag-ready')).toHaveAttribute('data-state', 'ready')
await expect(basics.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('[data-section="schedule"] .ag-fold__sum')).toHaveText('Every day at 07:00, 0 7 * * *')
await expect(page.locator('[data-section="webhooks"] .ag-fold__sum')).toHaveText('1 webhook')
await expect(page.locator('[data-section="media"] .ag-fold__sum')).toHaveText('None')
await basics.locator('.ag-fold__button').click()
await expect(page.getByLabel('Task name')).toHaveValue('daily-digest')
})
test('the prompt lists the parameters it uses', async ({ page }) => {
await edit(page)
await expect(page.getByTestId('prompt-params')).toContainText('{{.topic}}')
await expect(page.getByTestId('prompt-params').locator('.aj-gap')).toHaveCount(3)
await page.getByLabel('Prompt template').fill('Plain prompt')
await expect(page.getByTestId('prompt-params')).toContainText('No parameters in the prompt.')
})
test('the schedule is said in words, with presets that write the cron expression', async ({ page }) => {
await edit(page)
const words = page.getByTestId('schedule-words')
await expect(words).toContainText('Every day at 07:00.')
await expect(page.locator('[data-preset="daily"]')).toHaveAttribute('aria-checked', 'true')
await expect(page.getByLabel('Time')).toHaveValue('07:00')
await page.getByLabel('Time').fill('18:30')
await expect(words).toContainText('Every day at 18:30.')
await page.locator('[data-preset="weekdays"]').click()
await expect(words).toContainText('Weekdays at 18:30.')
await page.locator('[data-preset="hourly"]').click()
await expect(words).toContainText('Every hour.')
await expect(page.getByLabel('Time')).toHaveCount(0)
await page.locator('[data-preset="none"]').click()
await expect(words).toHaveCount(0)
await expect(page.getByLabel('Parameters, one key=value per line')).toHaveCount(0)
await page.locator('[data-preset="custom"]').click()
await page.getByLabel('Cron expression').fill('*/15 * * * *')
await expect(words).toContainText('Every 15 minutes.')
await page.getByLabel('Cron expression').fill('0,30 9-17 * * 1-5')
await expect(words).toContainText('Custom schedule.')
})
test('a cron expression that is not valid says why and blocks the save', async ({ page }) => {
const { seen } = await edit(page)
await page.locator('[data-preset="custom"]').click()
await page.getByLabel('Cron expression').fill('* * * *')
await expect(page.getByRole('alert').filter({ hasText: 'Use five fields' })).toContainText('You wrote 4.')
await expect(page.getByLabel('Cron expression')).toHaveAttribute('aria-invalid', 'true')
await expect(page.locator('[data-section="schedule"] .ag-ready')).toHaveAttribute('data-state', 'needs')
await page.getByLabel('Cron expression').fill('61 * * * *')
await expect(page.getByRole('alert').filter({ hasText: 'minute field' })).toBeVisible()
await page.getByRole('button', { name: 'Save task' }).first().click()
await expect(page.getByText('Fix the schedule first')).toBeVisible()
expect(seen.puts).toEqual([])
})
test('warns when a scheduled prompt has a gap the schedule does not fill', async ({ page }) => {
await edit(page)
await expect(page.getByTestId('schedule-gaps')).toHaveCount(0)
await page.getByLabel('Parameters, one key=value per line').fill('topic=news')
await expect(page.getByTestId('schedule-gaps')).toContainText('{{.format}}, {{.items}}')
})
test('saves the task as the API takes it: parameters as a map, headers as objects', async ({ page }) => {
const { seen } = await edit(page)
await page.getByLabel('Time').fill('08:15')
await page.getByLabel('Parameters, one key=value per line').fill('topic=chips\nformat=prose\nitems=5')
await page.locator('[data-section="webhooks"] .ag-fold__button').click()
await expect(page.getByLabel('Headers (JSON)')).toHaveValue('{"X-Token":"abc"}')
await page.getByRole('button', { name: 'Save task' }).first().click()
await expect(page.getByText('Task updated')).toBeVisible()
await expect(page).toHaveURL(/\/app\/agent-jobs$/)
expect(seen.puts).toHaveLength(1)
const { id, body } = seen.puts[0]
expect(id).toBe('t1')
expect(body.cron).toBe('15 8 * * *')
expect(body.cron_parameters).toEqual({ topic: 'chips', format: 'prose', items: '5' })
expect(body.webhooks[0].headers).toEqual({ 'X-Token': 'abc' })
expect(body.webhooks[0].url).toBe('https://hooks.example.org/digest')
expect(body.enabled).toBe(true)
})
test('a webhook header that is not JSON stops the save and names the webhook', async ({ page }) => {
const { seen } = await edit(page)
await page.locator('[data-section="webhooks"] .ag-fold__button').click()
await page.getByLabel('Headers (JSON)').fill('{oops')
await page.getByRole('button', { name: 'Save task' }).first().click()
await expect(page.getByText('Headers must be valid JSON: Webhooks 1')).toBeVisible()
expect(seen.puts).toEqual([])
})
test('the preview shows the prompt as a scheduled run sends it, and what changed', async ({ page }) => {
await edit(page)
await page.getByLabel('Time').fill('08:00')
await page.getByTestId('task-preview-open').click()
const sheet = page.getByTestId('task-preview')
await expect(sheet.getByTestId('task-preview-prompt')).toContainText('Summarise overnight news about local inference in bullet points format. Keep it to 8 items.')
await sheet.getByRole('tab', { name: /Changes/ }).click()
const changes = sheet.getByTestId('task-preview-changes')
await expect(changes.locator('[data-key="cron"]')).toContainText('0 7 * * *')
await expect(changes.locator('[data-key="cron"]')).toContainText('0 8 * * *')
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
})
test('leaving with unsaved changes asks first', async ({ page }) => {
await edit(page)
await page.getByLabel('Time').fill('09:00')
await page.getByRole('button', { name: 'Back', exact: true }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
})
test('media sources and webhooks add and remove', async ({ page }) => {
await edit(page, { tasks: undefined })
await page.locator('[data-section="media"] .ag-fold__button').click()
await page.getByRole('button', { name: 'Add source' }).click()
await expect(page.getByTestId('media-source')).toHaveCount(1)
await page.getByLabel('URL').fill('https://example.org/clip.mp4')
await page.getByLabel('Type').selectOption('video')
await expect(page.locator('[data-section="media"] .ag-fold__sum')).toHaveText('1 source')
await page.getByRole('button', { name: 'Remove source' }).click()
await expect(page.getByTestId('media-source')).toHaveCount(0)
})
})
test.describe('Create a task', () => {
test('starts with sections that need a name, and creates the task', async ({ page }) => {
const { seen } = await mockJobs(page)
await page.goto('/app/agent-jobs/tasks/new')
await expect(page.getByRole('heading', { name: 'New task' })).toBeVisible()
await expect(page.locator('[data-section="basics"] .ag-ready')).toHaveAttribute('data-state', 'needs')
await expect(page.locator('[data-section="basics"] .ag-fold__sum')).toHaveText('Needs a name')
await page.getByRole('button', { name: 'Create task' }).first().click()
await expect(page.getByText('Task name is required')).toBeVisible()
expect(seen.posts).toEqual([])
await page.getByLabel('Task name').fill('nightly')
await page.getByLabel('Prompt template').fill('Check {{.thing}}.')
await page.locator('[data-preset="weekdays"]').click()
await expect(page.getByTestId('schedule-words')).toContainText('Weekdays at 09:00.')
await page.getByRole('button', { name: 'Create task' }).first().click()
await expect(page.getByText('Task created')).toBeVisible()
expect(seen.posts).toHaveLength(1)
expect(seen.posts[0].name).toBe('nightly')
expect(seen.posts[0].cron).toBe('0 9 * * 1-5')
expect(seen.posts[0].enabled).toBe(true)
expect(seen.posts[0].model).toBe('qwen3-8b-instruct')
})
})
test.describe('The task form on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('fits the width', async ({ page }) => {
await mockJobs(page)
await page.goto('/app/agent-jobs/tasks/t1/edit')
await expect(page.getByTestId('task-edit')).toBeVisible()
await expect(page.locator('[data-preset="daily"]')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
})
+291
View File
@@ -0,0 +1,291 @@
import { test, expect } from './coverage-fixtures.js'
import { mockJobs, fixtureData } from './jobs-fixtures.js'
async function open(page, options) {
const mocked = await mockJobs(page, options)
await page.goto('/app/agent-jobs')
await expect(page.getByTestId('jobs-page')).toBeVisible()
return mocked
}
test.describe('Jobs page: the week and the tasks', () => {
test('one sentence says what the week held, from the jobs the API returned', async ({ page }) => {
await open(page)
const sum = page.getByTestId('jobs-summary')
await expect(sum).toContainText('5 runs in the last 7 days.')
await expect(sum).toContainText('2 finished, 1 failed, 1 cancelled, 1 still going.')
await expect(sum).toContainText('The last run of daily-digest failed.')
})
test('a week with no runs says so, and a page with no jobs at all says nothing ran', async ({ page }) => {
const old = fixtureData().jobs.filter(j => j.id === 'job-stop-0005').map(j => ({ ...j, created_at: new Date(Date.now() - 10 * 86400000).toISOString(), started_at: undefined }))
await open(page, { jobs: old })
await expect(page.getByTestId('jobs-summary')).toHaveText('No runs in the last 7 days.')
const none = await page.context().newPage()
await mockJobs(none, { jobs: [] })
await none.goto('/app/agent-jobs')
await expect(none.getByTestId('jobs-summary')).toHaveText('Nothing has run yet.')
await expect(none.getByText('No runs yet')).toBeVisible()
})
test('each task shows its model, its schedule in words, its last runs and its last outcome', async ({ page }) => {
await open(page)
const digest = page.locator('[data-task="daily-digest"]')
await expect(digest).toContainText('qwen3-8b-instruct')
await expect(digest).toContainText('Every day at 07:00')
await expect(digest.locator('.aj-sched__cron')).toHaveText('0 7 * * *')
await expect(digest).toContainText('last run failed')
await expect(digest.locator('.ag-pip[data-state="failed"]')).toHaveCount(1)
await expect(digest.locator('.ag-pip[data-state="done"]')).toHaveCount(1)
await expect(page.locator('[data-task="repo-triage"]')).toContainText('Every 30 minutes')
await expect(page.locator('[data-task="weekly-report"]')).toContainText('Every Monday at 09:00')
const manual = page.locator('[data-task="ad-hoc-research"]')
await expect(manual).toContainText('Runs when you start it')
await expect(manual).toContainText('Not run yet')
await expect(manual.getByRole('switch')).toHaveAttribute('aria-checked', 'false')
})
test('a task name opens the task, and the model opens its editor', async ({ page }) => {
await open(page)
await expect(page.locator('[data-task="daily-digest"] .aj-model')).toHaveAttribute('href', '/app/model-editor/qwen3-8b-instruct')
await page.locator('[data-task="daily-digest"]').getByRole('link', { name: 'daily-digest' }).click()
await expect(page).toHaveURL(/\/app\/agent-jobs\/tasks\/t1$/)
})
test('the task list folds away and comes back', async ({ page }) => {
await open(page)
await page.getByRole('button', { name: 'Hide' }).click()
await expect(page.getByTestId('jobs-tasks')).toHaveCount(0)
await page.getByRole('button', { name: 'Show' }).click()
await expect(page.getByTestId('jobs-tasks')).toBeVisible()
})
test('the enabled switch saves the task and says so', async ({ page }) => {
const { seen } = await open(page)
await page.locator('[data-task="daily-digest"]').getByRole('switch').click()
await expect(page.getByText('daily-digest is off')).toBeVisible()
expect(seen.puts).toHaveLength(1)
expect(seen.puts[0].id).toBe('t1')
expect(seen.puts[0].body.enabled).toBe(false)
expect(seen.puts[0].body.cron).toBe('0 7 * * *')
expect(seen.puts[0].body.cron_parameters).toEqual({ topic: 'local inference', format: 'bullet points', items: '8' })
})
test('the switch goes back when the save fails', async ({ page }) => {
await open(page, { updateStatus: 500 })
const toggle = page.locator('[data-task="daily-digest"]').getByRole('switch')
await toggle.click()
await expect(page.getByText(/Could not change the task/)).toBeVisible()
await expect(toggle).toHaveAttribute('aria-checked', 'true')
})
test('with no tasks the page teaches what a task is and how to make one', async ({ page }) => {
await open(page, { tasks: [], jobs: [] })
const empty = page.getByTestId('jobs-empty')
await expect(empty).toContainText('A task is a prompt a model runs for you')
await expect(empty.locator('li')).toHaveCount(3)
await expect(empty).toContainText('{{.name}}')
await empty.getByRole('button', { name: 'Create task' }).click()
await expect(page).toHaveURL(/\/app\/agent-jobs\/tasks\/new$/)
})
test('without a model it says Jobs need one, and without MCP it shows how to add it', async ({ page }) => {
await mockJobs(page, { models: [] })
await page.goto('/app/agent-jobs')
await expect(page.getByTestId('jobs-no-models')).toContainText('No models installed')
const other = await page.context().newPage()
await mockJobs(other, { tasks: [], jobs: [], mcp: false })
await other.goto('/app/agent-jobs')
await expect(other.getByTestId('jobs-no-mcp')).toContainText('MCP is not set up')
await expect(other.getByTestId('jobs-no-mcp')).toContainText('command: /path/to/tool')
})
})
test.describe('Jobs page: run history', () => {
test('is grouped by day, newest first, one outcome sentence per run', async ({ page }) => {
await open(page)
const days = page.locator('.aj-day')
await expect(days.nth(0)).toHaveText('Today')
await expect(days.nth(1)).toHaveText('Yesterday')
await expect(days).toHaveCount(3)
const rows = page.locator('[data-testid="jobs-history"] tr[data-job]')
await expect(rows).toHaveCount(5)
await expect(rows.nth(0)).toHaveAttribute('data-job', 'job-run-0003')
await expect(rows.nth(1)).toHaveAttribute('data-job', 'job-fail-0001')
await expect(rows.nth(2)).toHaveAttribute('data-job', 'job-done-0002')
await expect(rows.nth(3)).toHaveAttribute('data-job', 'job-done-0004')
await expect(rows.nth(4)).toHaveAttribute('data-job', 'job-stop-0005')
await expect(page.locator('[data-job="job-fail-0001"]')).toContainText('read_page: timeout after 20 s')
await expect(page.locator('[data-job="job-fail-0001"]')).toContainText('1 min 06 s')
await expect(page.locator('[data-job="job-done-0002"]')).toContainText('Labelled 12 issues and routed 3 of them')
await expect(page.locator('[data-job="job-run-0003"]')).toContainText('Working since')
await expect(page.locator('[data-job="job-stop-0005"]')).toContainText('Cancelled before it finished')
})
test('shows no duration for a run the server has no end time for', async ({ page }) => {
await open(page)
const cells = page.locator('[data-job="job-run-0003"] td.dk-num')
await expect(cells.nth(1)).toHaveText('')
})
test('status chips filter, with the counts, and a second press clears', async ({ page }) => {
await open(page)
await expect(page.locator('[data-filter="all"]')).toContainText('5')
await expect(page.locator('[data-filter="failed"]')).toContainText('1')
await expect(page.locator('[data-filter="completed"]')).toContainText('2')
await expect(page.locator('[data-filter="pending"]')).toHaveCount(0)
await page.locator('[data-filter="completed"]').click()
await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(2)
await page.locator('[data-filter="completed"]').click()
await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(5)
})
test('the task filter narrows the history and the counts follow it', async ({ page }) => {
await open(page)
await page.getByLabel('Task', { exact: true }).selectOption({ label: 'repo-triage' })
await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(2)
await expect(page.locator('[data-filter="all"]')).toContainText('2')
await expect(page.locator('[data-filter="failed"]')).toHaveCount(0)
})
test('a filter with no match says so and clears', async ({ page }) => {
await open(page)
await page.locator('[data-filter="failed"]').click()
await page.getByLabel('Task', { exact: true }).selectOption({ label: 'weekly-report' })
await expect(page.getByTestId('jobs-history-empty')).toContainText('No runs match these filters.')
await page.getByRole('button', { name: 'Clear filters' }).click()
await expect(page.locator('[data-testid="jobs-history"] tr[data-job]')).toHaveCount(5)
})
test('a row opens into its error and one next action', async ({ page }) => {
const { seen } = await open(page)
const toggle = page.getByRole('button', { name: 'Details for the run of daily-digest' }).first()
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-expanded', 'true')
const detail = page.getByTestId('job-detail')
await expect(detail.getByTestId('job-excerpt')).toContainText('read_page: timeout after 20 s (3 of 3 attempts)')
await detail.getByRole('button', { name: 'Run again' }).click()
await expect(page.getByText('Started a new run')).toBeVisible()
expect(seen.jobExecutes).toEqual([{ task_id: 't1', parameters: { topic: 'local inference', format: 'bullet points', items: '8' } }])
await expect(detail.getByRole('link', { name: 'Open the run' })).toHaveAttribute('href', '/app/agent-jobs/jobs/job-fail-0001')
})
test('a run in flight offers Cancel, and Cancel calls the API', async ({ page }) => {
const { seen } = await open(page)
await page.locator('[data-job="job-run-0003"]').getByRole('button', { name: /Details for/ }).click()
await page.getByTestId('job-detail').getByRole('button', { name: 'Cancel' }).click()
await expect.poll(() => seen.cancels).toEqual(['job-run-0003'])
})
test('the page menu stops every running job after a confirm', async ({ page }) => {
const { seen } = await open(page)
await page.getByRole('button', { name: 'More job actions' }).click()
await page.getByRole('menuitem', { name: 'Stop running jobs' }).click()
await expect(page.getByRole('alertdialog')).toContainText('Finished jobs stay in the history')
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop jobs' }).click()
await expect.poll(() => seen.cancels).toEqual(['job-run-0003'])
})
test('the Jobs tab stays lit around the page', async ({ page }) => {
await open(page)
await expect(page.locator('.dk-hubtabs [data-hub-tab="jobs"]')).toHaveAttribute('aria-current', 'page')
})
})
test.describe('Jobs page: run a task now', () => {
test('the dialog asks for the values the prompt uses, filled from the schedule', async ({ page }) => {
const { seen } = await open(page)
await page.locator('[data-task="daily-digest"]').getByRole('button', { name: 'Run now' }).click()
const dialog = page.getByTestId('run-task-dialog')
await expect(dialog).toContainText('Run daily-digest now')
await expect(dialog.getByLabel('topic')).toHaveValue('local inference')
await expect(dialog.getByLabel('format')).toHaveValue('bullet points')
await dialog.getByLabel('topic').fill('open models')
await dialog.getByLabel('More parameters, one key=value per line').fill('tone=dry')
await dialog.getByRole('button', { name: 'Start run' }).click()
await expect(page.getByText('Task "daily-digest" started')).toBeVisible()
expect(seen.executes).toEqual([{ name: 'daily-digest', body: { topic: 'open models', format: 'bullet points', items: '8', tone: 'dry' } }])
await expect(dialog).toHaveCount(0)
})
test('a task with no gaps says so, and Escape closes the dialog', async ({ page }) => {
await open(page)
await page.locator('[data-task="weekly-report"]').getByRole('button', { name: 'Run now' }).click()
await expect(page.getByTestId('run-task-dialog')).toContainText('This prompt has no gaps to fill.')
await page.keyboard.press('Escape')
await expect(page.getByTestId('run-task-dialog')).toHaveCount(0)
})
test('attached media goes with the run through the job call', async ({ page }) => {
const { seen } = await open(page)
await page.locator('[data-task="weekly-report"]').getByRole('button', { name: 'Run now' }).click()
const dialog = page.getByTestId('run-task-dialog')
await dialog.getByText('Media', { exact: true }).click()
const chooser = page.waitForEvent('filechooser')
await dialog.getByRole('button', { name: 'Add' }).first().click()
await (await chooser).setFiles({ name: 'board.png', mimeType: 'image/png', buffer: Buffer.from('png') })
await expect(dialog.getByText('board.png')).toBeVisible()
await dialog.getByRole('button', { name: 'Start run' }).click()
await expect.poll(() => seen.jobExecutes.length).toBe(1)
expect(seen.jobExecutes[0].task_id).toBe('t3')
expect(seen.jobExecutes[0].images).toHaveLength(1)
expect(seen.jobExecutes[0].images[0]).toMatch(/^data:image\/png;base64,/)
expect(seen.executes).toEqual([])
})
})
test.describe('Jobs page: delete a task', () => {
test('hides the task, offers undo and sends nothing until the time ends; undo brings it back', async ({ page }) => {
const { seen } = await open(page)
await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click()
await page.getByRole('menuitem', { name: 'Delete' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click()
await expect(page.locator('[data-task="repo-triage"]')).toHaveCount(0)
const toast = page.getByTestId('task-undo-toast')
await expect(toast).toContainText('Task "repo-triage" deleted')
expect(seen.deletes).toEqual([])
await toast.getByRole('button', { name: 'Undo' }).click()
await expect(page.locator('[data-task="repo-triage"]')).toBeVisible()
await expect(toast).toHaveCount(0)
expect(seen.deletes).toEqual([])
})
test('without undo the delete is sent when the time ends', async ({ page }) => {
await page.clock.install()
const { seen } = await open(page)
await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click()
await page.getByRole('menuitem', { name: 'Delete' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click()
await expect(page.getByTestId('task-undo-toast')).toBeVisible()
expect(seen.deletes).toEqual([])
await page.clock.runFor(30500)
await expect.poll(() => seen.deletes).toEqual(['t2'])
await expect(page.getByText('Task deleted')).toBeVisible()
})
test('leaving the page during the wait deletes nothing', async ({ page }) => {
const { seen } = await open(page)
await page.locator('[data-task="repo-triage"]').getByRole('button', { name: /More actions for/ }).click()
await page.getByRole('menuitem', { name: 'Delete' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete' }).click()
await expect(page.getByTestId('task-undo-toast')).toBeVisible()
await page.getByRole('link', { name: 'Skills' }).first().click()
await expect(page.getByText('Nothing was deleted, because the page was closed first.')).toBeVisible()
expect(seen.deletes).toEqual([])
})
})
test.describe('Jobs page on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('fits the width, keeps the essentials and drops the wide columns', async ({ page }) => {
await open(page)
await expect(page.locator('[data-task="daily-digest"]')).toBeVisible()
await expect(page.locator('[data-task="daily-digest"] .aj-sched')).toBeHidden()
await expect(page.locator('[data-task="daily-digest"]').getByRole('button', { name: 'Run now' })).toBeVisible()
await expect(page.locator('[data-job="job-fail-0001"]')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
})
+126
View File
@@ -0,0 +1,126 @@
import { test, expect } from './coverage-fixtures.js'
import { installFakeStream, mockAgents, RESEARCH_CONFIG } from './agents-fixtures.js'
test.describe('Create and edit an agent', () => {
test('sections fold, show ready marks and a one-line summary', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/research-assistant/edit')
await expect(page.getByRole('heading', { name: 'Edit Agent: research-assistant' })).toBeVisible()
const basics = page.locator('[data-section="BasicInfo"]')
await expect(basics.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'true')
const model = page.locator('[data-section="ModelSettings"]')
await expect(model.locator('.ag-fold__button')).toHaveAttribute('aria-expanded', 'false')
await expect(model.locator('.ag-fold__sum')).toHaveText('qwen3-14b-instruct')
await expect(model.locator('.ag-ready')).toHaveAttribute('data-state', 'ready')
await expect(model.locator('.ag-fold__body')).toHaveCount(0)
await model.locator('.ag-fold__button').click()
await expect(model.locator('.ag-fold__body')).toBeVisible()
await model.locator('.ag-fold__button').click()
await expect(model.locator('.ag-fold__body')).toHaveCount(0)
})
test('a new agent starts with sections that need a name and a model', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/new')
await expect(page.locator('[data-section="BasicInfo"] .ag-ready')).toHaveAttribute('data-state', 'needs')
await expect(page.locator('[data-section="ModelSettings"] .ag-fold__sum')).toHaveText('Needs a model')
await expect(page.locator('[data-section="MemorySettings"] .ag-ready')).toHaveAttribute('data-state', 'empty')
})
test('a template fills the form and saves nothing', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await page.goto('/app/agents/new')
await page.getByRole('button', { name: 'Code reviewer' }).click()
await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('code-reviewer')
expect(seen.saves).toEqual([])
})
test('a template link from the launcher opens the form already filled', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/new?template=digest')
await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('daily-digest')
})
test('the preview sheet shows the config and, when editing, what changed', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page, { agents: { 'research-assistant': { active: true, config: { ...RESEARCH_CONFIG, api_key: 'sk-secret-value', mcp_servers: [{ url: 'https://x.example.org', token: 'tok-123' }] } } } })
await page.goto('/app/agents/research-assistant/edit')
await page.locator('[data-section="PromptsGoals"] .ag-fold__button').click()
await page.locator('[data-section="PromptsGoals"] textarea').fill('A new instruction.')
await expect(page.locator('[data-section="PromptsGoals"] .ag-badge-changed')).toHaveText('1 changed')
await page.getByTestId('agent-preview-open').click()
const sheet = page.getByTestId('agent-preview')
await expect(sheet.getByTestId('agent-preview-config')).toContainText('"system_prompt": "A new instruction."')
const cfg = await sheet.getByTestId('agent-preview-config').textContent()
expect(cfg).not.toContain('sk-secret-value')
expect(cfg).not.toContain('tok-123')
await sheet.getByRole('tab', { name: /Changes/ }).click()
const changes = sheet.getByTestId('agent-preview-changes')
await expect(changes.locator('[data-key]')).toHaveCount(1)
await expect(changes.locator('[data-key="system_prompt"]')).toContainText('You are a careful research assistant')
await expect(changes.locator('[data-key="system_prompt"]')).toContainText('A new instruction.')
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
await page.getByTestId('agent-preview-open').click()
await page.getByTestId('agent-preview').getByRole('button', { name: 'Save Changes' }).click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0]).toMatchObject({ method: 'PUT', name: 'research-assistant' })
expect(seen.saves[0].body.system_prompt).toBe('A new instruction.')
})
test('with no edits the Changes tab says the form matches the saved agent', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/research-assistant/edit')
await page.getByTestId('agent-preview-open').click()
await page.getByRole('tab', { name: /Changes/ }).click()
await expect(page.getByTestId('agent-preview-changes')).toContainText('No changes')
})
test('creating an agent sends the form and offers no Dry run it cannot run', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await page.goto('/app/agents/new')
await page.locator('[data-section="BasicInfo"] input').first().fill('my-agent')
await page.locator('[data-section="ModelSettings"] .ag-fold__button').click()
await page.locator('[data-section="ModelSettings"] input').first().fill('qwen3-8b-instruct')
await page.getByTestId('agent-preview-open').click()
await expect(page.getByRole('tab', { name: /Dry run/ })).toHaveCount(0)
await page.getByRole('tab', { name: /Changes/ }).click()
await expect(page.getByTestId('agent-preview-changes')).toContainText('qwen3-8b-instruct')
await page.keyboard.press('Escape')
await page.getByRole('button', { name: 'Create Agent' }).first().click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0].body).toMatchObject({ name: 'my-agent', model: 'qwen3-8b-instruct' })
})
test('draft from a sentence needs a model first and is labelled optional', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/new')
await expect(page.getByTestId('agent-start')).toContainText('Optional')
await page.getByLabel('Describe the agent in one sentence').fill('reads release notes')
await page.getByRole('button', { name: 'Draft' }).click()
await expect(page.getByText(/Pick a model in the Model section first/)).toBeVisible()
})
test('draft from a sentence fills name, description and instructions from the chosen model', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.route('**/v1/chat/completions', route => route.fulfill({ json: { choices: [{ message: { content: '```json\n{"name":"release-reader","description":"Reads release notes.","system_prompt":"List breaking changes."}\n```' } }] } }))
await page.goto('/app/agents/new')
await page.locator('[data-section="ModelSettings"] .ag-fold__button').click()
await page.locator('[data-section="ModelSettings"] input').first().fill('qwen3-8b-instruct')
await page.getByLabel('Describe the agent in one sentence').fill('reads release notes')
await page.getByRole('button', { name: 'Draft' }).click()
await expect(page.locator('[data-section="BasicInfo"] .ag-fold__sum')).toContainText('release-reader')
})
})
+139
View File
@@ -0,0 +1,139 @@
// Shared fixtures for the Agents specs: stubbed agent API routes, a fake event
// stream the test drives by hand, and run records written to the browser
// storage the pages read.
export const RUNS_KEY = (agent, userId) => `localai_agent_runs_${userId ? `${userId}:` : ''}${agent}`
export const RESEARCH_CONFIG = {
name: 'research-assistant',
description: 'Searches the web, reads sources and writes short briefs.',
model: 'qwen3-14b-instruct',
system_prompt: 'You are a careful research assistant. Search first, read at least two sources, then summarise in plain language.',
actions: [{ name: 'web_search', config: '{}' }, { name: 'read_page', config: '{}' }, { name: 'write_file', config: '{}' }],
mcp_stdio_servers: JSON.stringify({ mcpServers: { filesystem: { command: 'npx', args: [], env: {} } } }),
enable_kb: true,
enable_skills: true,
selected_skills: ['citations'],
}
export const AGENTS = {
'research-assistant': { active: true, config: RESEARCH_CONFIG },
'code-reviewer': {
active: true,
config: { name: 'code-reviewer', description: 'Reviews a change and lists what to fix first.', model: 'qwen3-14b-instruct', system_prompt: 'You review code changes.' },
},
'daily-digest': {
active: false,
config: { name: 'daily-digest', description: 'Collects what is new and summarises it.', model: 'qwen3-8b-instruct', system_prompt: 'You collect news.' },
},
}
const META = {
Fields: [
{ name: 'name', label: 'Name', type: 'text', required: true, tags: { section: 'BasicInfo' } },
{ name: 'description', label: 'Description', type: 'textarea', tags: { section: 'BasicInfo' } },
{ name: 'model', label: 'Model', type: 'text', required: true, tags: { section: 'ModelSettings' } },
{ name: 'enable_kb', label: 'Knowledge base', type: 'checkbox', tags: { section: 'MemorySettings' } },
{ name: 'system_prompt', label: 'System prompt', type: 'textarea', tags: { section: 'PromptsGoals' } },
{ name: 'enable_skills', label: 'Skills', type: 'checkbox', tags: { section: 'AdvancedSettings' } },
],
Connectors: [], Actions: [], Filters: [], DynamicPrompts: [],
}
// Replace EventSource with one the test drives: window.__emit(type, data).
export async function installFakeStream(page) {
await page.addInitScript(() => {
const sources = []
class FakeES {
constructor(url) {
this.url = url
this.listeners = {}
this.readyState = 1
sources.push(this)
setTimeout(() => this.onopen && this.onopen({}), 0)
}
addEventListener(type, fn) { (this.listeners[type] = this.listeners[type] || []).push(fn) }
removeEventListener(type, fn) { this.listeners[type] = (this.listeners[type] || []).filter(f => f !== fn) }
close() { this.readyState = 2; const i = sources.indexOf(this); if (i >= 0) sources.splice(i, 1) }
}
window.EventSource = FakeES
window.__emit = (type, data) => {
sources.forEach(s => (s.listeners[type] || []).forEach(fn => fn({ data: typeof data === 'string' ? data : JSON.stringify(data) })))
}
window.__streams = () => sources.length
})
}
export function emit(page, type, data) {
return page.evaluate(([t, d]) => window.__emit(t, d), [type, data])
}
// Stub every agent endpoint the area reads. Returns the requests the page
// sent, so a spec can assert on them.
export async function mockAgents(page, {
agents = AGENTS,
observables = {},
chatStatus = 202,
chatError = 'agent unavailable',
features = { distributed: false, localai_assistant: true, agents: true, mcp: true },
skills = [{ name: 'citations', description: 'Cite sources' }],
} = {}) {
const seen = { chats: [], saves: [], deletes: [], pauses: [], resumes: [], clears: [] }
await page.route('**/api/features', route => route.fulfill({ json: features }))
await page.route('**/api/agents/skills', route => route.fulfill({ json: { skills } }))
await page.route('**/api/agents/config/metadata', route => route.fulfill({ json: META }))
await page.route('**/api/agents', async route => {
const req = route.request()
if (req.method() === 'POST') {
seen.saves.push({ method: 'POST', body: req.postDataJSON() })
return route.fulfill({ json: { status: 'ok' } })
}
const statuses = Object.fromEntries(Object.entries(agents).map(([n, a]) => [n, a.active]))
return route.fulfill({ json: { agents: Object.keys(agents), statuses } })
})
await page.route(/\/api\/agents\/[^/?]+(\/[a-z]+)?(\?.*)?$/, async route => {
const req = route.request()
const url = new URL(req.url())
const m = url.pathname.match(/\/api\/agents\/([^/]+)(?:\/([a-z]+))?$/)
if (!m) return route.fallback()
const name = decodeURIComponent(m[1])
const tail = m[2]
if (['skills', 'collections', 'actions', 'config', 'git-repos', 'import'].includes(name)) return route.fallback()
const agent = agents[name]
if (!agent) return route.fulfill({ status: 404, json: { error: 'Agent not found' } })
if (tail === 'chat' && req.method() === 'POST') {
seen.chats.push({ name, ...req.postDataJSON() })
if (chatStatus !== 202) return route.fulfill({ status: chatStatus, json: { error: chatError } })
return route.fulfill({ status: 202, json: { status: 'message_received', message_id: `m${seen.chats.length}` } })
}
if (tail === 'config') return route.fulfill({ json: agent.config })
if (tail === 'observables') {
if (req.method() === 'DELETE') { seen.clears.push(name); return route.fulfill({ json: { cleared: true } }) }
return route.fulfill({ json: { Name: name, History: observables[name] || [] } })
}
if (tail === 'status') return route.fulfill({ json: { Name: name, History: [] } })
if (tail === 'pause') { seen.pauses.push(name); return route.fulfill({ json: { status: 'ok' } }) }
if (tail === 'resume') { seen.resumes.push(name); return route.fulfill({ json: { status: 'ok' } }) }
if (tail === 'export') return route.fulfill({ json: agent.config })
if (req.method() === 'PUT') { seen.saves.push({ method: 'PUT', name, body: req.postDataJSON() }); return route.fulfill({ json: { status: 'ok' } }) }
if (req.method() === 'DELETE') { seen.deletes.push(name); return route.fulfill({ json: { status: 'ok' } }) }
return route.fulfill({ json: { active: agent.active } })
})
return seen
}
// Seed run records. Each run: { id, task, status, outcome, ... } is expanded
// into the stored shape.
export function storedRun(agent, { id, task, status = 'done', outcome = '', error = '', steps = [], startedAt, seconds = 30, metadata = null, turns = null }) {
const first = {
task, startedAt, endedAt: status === 'running' ? null : startedAt + seconds * 1000, status, outcome, error, steps,
live: { reasoning: '', content: '' }, metadata,
}
return { id, agent, startedAt, updatedAt: startedAt + seconds * 1000, turns: turns || [first] }
}
export async function seedRuns(page, agent, runs, userId) {
await page.addInitScript(([key, data]) => {
try { localStorage.setItem(key, JSON.stringify(data)) } catch { /* ignore */ }
}, [RUNS_KEY(agent, userId), { version: 1, runs }])
}
+316
View File
@@ -0,0 +1,316 @@
import { test, expect } from './coverage-fixtures.js'
import { installFakeStream, mockAgents, seedRuns, storedRun, emit, AGENTS } from './agents-fixtures.js'
const TASK = 'Find two sources on speculative decoding and summarise them.'
const OUTCOME = 'I read two sources.\n\n| A | B | C | D |\n| - | - | - | - |\n| 1 | 2 | 3 | 4 |'
async function startRun(page, seen) {
await page.goto('/app/agents/research-assistant')
await expect(page.getByTestId('agent-page')).toBeVisible()
await page.locator('#ag-task-input').fill(TASK)
await page.getByRole('button', { name: 'Start run' }).click()
await expect(page).toHaveURL(/\/app\/agents\/research-assistant\/runs\/r_[a-z0-9]{6}$/)
await expect(page.getByTestId('run-live')).toBeVisible()
await expect.poll(() => seen.chats.length).toBe(1)
}
test.describe('Agent page', () => {
test('shows what the agent is: model, tools, memory, skills, instructions', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/research-assistant')
const facts = page.getByTestId('agent-facts')
await expect(facts).toContainText('qwen3-14b-instruct')
await expect(facts).toContainText('web_search')
await expect(facts).toContainText('mcp: filesystem')
await expect(facts.getByRole('link', { name: 'Knowledge base' })).toHaveAttribute('href', '/app/collections')
await expect(facts.getByRole('link', { name: 'citations' })).toHaveAttribute('href', '/app/skills')
await expect(facts).toContainText('You are a careful research assistant')
await expect(page.getByText('No runs yet')).toBeVisible()
})
test('an agent with no memory or skills shows None, not a guess', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/code-reviewer')
const facts = page.getByTestId('agent-facts')
await expect(facts.getByText('None')).toHaveCount(3)
await expect(facts.getByRole('link')).toHaveCount(0)
})
test('lists runs newest first and opens one', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
const t = Date.now()
await seedRuns(page, 'research-assistant', [
storedRun('research-assistant', { id: 'r_new', task: 'Newest task', startedAt: t - 1000, outcome: 'Wrote the digest.' }),
storedRun('research-assistant', { id: 'r_old', task: 'Older task', startedAt: t - 90000, status: 'failed', error: 'The file is not a valid PDF.' }),
])
await page.goto('/app/agents/research-assistant')
const rows = page.getByTestId('agent-runs').locator('a')
await expect(rows).toHaveCount(2)
await expect(rows.first()).toContainText('Newest task')
await expect(rows.last()).toContainText('The file is not a valid PDF.')
await rows.last().click()
await expect(page).toHaveURL(/\/runs\/r_old$/)
await expect(page.getByTestId('run-report')).toBeVisible()
})
test('a paused agent cannot start a run and offers Resume', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await page.goto('/app/agents/daily-digest')
await expect(page.locator('#ag-task-input')).toBeDisabled()
await page.getByTestId('agent-page').getByRole('button', { name: 'Resume', exact: true }).first().click()
await expect.poll(() => seen.resumes).toEqual(['daily-digest'])
})
test('an unknown agent says so', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/nobody')
await expect(page.getByTestId('agent-missing')).toContainText('No agent named "nobody"')
})
test('Clear run record removes this browser\'s runs after a confirmation', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_x', task: 'One', startedAt: Date.now() - 1000, outcome: 'ok' })])
await page.goto('/app/agents/research-assistant')
await expect(page.getByTestId('agent-runs')).toBeVisible()
await page.getByTestId('agent-page').getByRole('button', { name: /More actions/ }).click()
await page.getByRole('menuitem', { name: 'Clear run record' }).click()
await page.getByRole('button', { name: 'Clear', exact: true }).click()
await expect(page.getByText('No runs yet')).toBeVisible()
})
})
test.describe('A run', () => {
test('starts from the task box, shows the thread live and settles into the report', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await startRun(page, seen)
expect(seen.chats[0]).toMatchObject({ name: 'research-assistant', message: TASK, history: [] })
await expect(page.getByTestId('run-task')).toContainText(TASK)
await emit(page, 'stream_event', { type: 'reasoning', content: 'Search first.' })
await emit(page, 'stream_event', { type: 'done' })
await emit(page, 'stream_event', { type: 'tool_call', tool_name: 'web_search', tool_args: '{"q":"x"}' })
await expect(page.getByTestId('run-working')).toContainText('Using web_search')
await emit(page, 'stream_event', { type: 'tool_result', tool_name: 'web_search', tool_result: '8 results' })
await expect(page.getByTestId('run-fold')).toContainText('1 steps so far')
await emit(page, 'json_message', { sender: 'agent', content: OUTCOME })
await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'done')
await expect(page.getByTestId('run-settling')).toContainText('Report ready')
await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 })
await expect(page.getByRole('heading', { name: 'Outcome' })).toBeVisible()
await expect(page.getByTestId('run-evidence')).toContainText('8 results')
await expect(page.getByRole('heading', { name: 'Steps' })).toBeVisible()
// Wide content opens wider on demand.
const wideBtn = page.locator('.ag-wide__btn').first()
await expect(wideBtn).toHaveText('Open wider')
await wideBtn.click()
await expect(page.locator('.ag-wide[data-wide]')).toHaveCount(1)
await expect(wideBtn).toHaveText('Back to column')
})
test('choosing a view stops the page from moving by itself', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await startRun(page, seen)
await page.getByRole('button', { name: 'Live' }).click()
await emit(page, 'json_message', { sender: 'agent', content: 'Done.' })
await page.waitForTimeout(2200)
await expect(page.getByTestId('run-live')).toBeVisible()
await expect(page.getByTestId('run-report')).toHaveCount(0)
})
test('has its own address that survives a reload', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await startRun(page, seen)
await emit(page, 'json_message', { sender: 'agent', content: 'The answer.' })
await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 })
const url = page.url()
await page.reload()
await expect(page).toHaveURL(url)
await expect(page.getByTestId('run-report')).toContainText('The answer.')
// The reload did not send the task again.
expect(seen.chats.length).toBe(1)
})
test('a run from another browser opens a notice, not an error', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await page.goto('/app/agents/research-assistant/runs/r_nope12')
await expect(page.getByTestId('run-missing')).toContainText('not in this browser')
await expect(page.getByRole('link', { name: 'Open research-assistant' }).last()).toBeVisible()
})
test('a follow-up sits under the outcome and carries the earlier turns', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await startRun(page, seen)
await emit(page, 'json_message', { sender: 'agent', content: 'First answer.' })
await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 })
await page.getByTestId('run-followup').locator('textarea').fill('Add one more source')
await page.getByRole('button', { name: 'Send' }).click()
await expect.poll(() => seen.chats.length).toBe(2)
expect(seen.chats[1].history).toEqual([
{ role: 'user', content: TASK },
{ role: 'assistant', content: 'First answer.' },
])
await emit(page, 'json_message', { sender: 'agent', content: 'Second answer.' })
await expect(page.getByTestId('run-report')).toBeVisible({ timeout: 5000 })
await expect(page.getByTestId('run-followups')).toContainText('Add one more source')
await expect(page.getByTestId('run-followups')).toContainText('Second answer.')
})
test('the agent name switches to another agent', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_s', task: 'T', startedAt: Date.now() - 1000, outcome: 'ok' })])
await page.goto('/app/agents/research-assistant/runs/r_s')
await page.getByTestId('agent-switcher').click()
await page.getByRole('menuitem', { name: 'code-reviewer' }).click()
await expect(page).toHaveURL(/\/app\/agents\/code-reviewer$/)
})
test('there is no Stop, approval or steer control the API cannot honour', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await startRun(page, seen)
await expect(page.getByRole('button', { name: /^Stop$/ })).toHaveCount(0)
await expect(page.getByRole('button', { name: /Approve|Allow/ })).toHaveCount(0)
await expect(page.getByTestId('run-dock-note')).toContainText('Follow-ups open when it answers')
})
})
test.describe('A run that fails', () => {
test('a task the server refuses says so in plain words with one next action', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page, { chatStatus: 500, chatError: 'model is not loaded' })
await page.goto('/app/agents/research-assistant')
await page.locator('#ag-task-input').fill(TASK)
await page.keyboard.press('Enter')
const failed = page.getByTestId('run-failed')
await expect(failed).toContainText('The task did not reach the agent')
await expect(failed).toContainText('model is not loaded')
await expect(failed.getByRole('button')).toHaveCount(1)
await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'failed')
await failed.getByRole('button', { name: 'Run again' }).click()
await expect.poll(() => seen.chats.length).toBe(2)
await expect(page).toHaveURL(/\/runs\/r_[a-z0-9]{6}$/)
})
test('an error the agent reports stays in the live view and is the outcome in the report', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page)
await startRun(page, seen)
await emit(page, 'json_error', { error: 'A page did not answer' })
await expect(page.getByTestId('run-failed')).toContainText('The run stopped')
await expect(page.getByTestId('run-live')).toBeVisible()
await page.getByRole('button', { name: 'Report' }).click()
await expect(page.getByTestId('run-report').getByTestId('run-failed')).toContainText('A page did not answer')
})
test('a run cut off before it answered reads as stopped', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page)
const started = Date.now() - 20 * 60_000
await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_cut', task: 'Cut off', startedAt: started, status: 'running' })])
await page.goto('/app/agents/research-assistant/runs/r_cut')
await expect(page.getByTestId('agent-run')).toHaveAttribute('data-status', 'stopped')
await expect(page.getByTestId('run-report')).toContainText('No answer was recorded')
})
})
test.describe('Status page', () => {
test('is a quiet panel with the raw record and a way back', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page, { observables: { 'research-assistant': [
{ id: 1, name: 'web_search', creation: { function_definition: { name: 'web_search' } }, completion: { action_result: '8 results' } },
{ id: 2, name: 'read_page', completion: { error: 'page did not answer' } },
] } })
await page.goto('/app/agents/research-assistant/status')
const panel = page.getByTestId('agent-status')
await expect(panel.getByRole('heading', { name: 'Status' })).toBeVisible()
await expect(panel.getByTestId('status-observable')).toHaveCount(2)
await panel.getByRole('button', { name: 'Failed', exact: true }).click()
await expect(panel.getByTestId('status-observable')).toHaveCount(1)
await panel.getByRole('link', { name: 'research-assistant' }).click()
await expect(page).toHaveURL(/\/app\/agents\/research-assistant$/)
})
test('Clear removes the records through the API', async ({ page }) => {
await installFakeStream(page)
const seen = await mockAgents(page, { observables: { 'research-assistant': [{ id: 1, name: 'x', completion: {} }] } })
await page.goto('/app/agents/research-assistant/status')
await page.getByRole('button', { name: 'Clear' }).click()
await expect.poll(() => seen.clears).toEqual(['research-assistant'])
})
})
test.describe('Layouts', () => {
async function seeded(page) {
await installFakeStream(page)
await mockAgents(page)
await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_l', task: TASK, startedAt: Date.now() - 4000, outcome: OUTCOME, steps: [{ kind: 'tool', name: 'web_search', args: '{}', result: 'r', ts: Date.now() - 3000 }] })])
}
const noOverflow = page => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth + 1)
test('phone: no sideways scroll on the launcher, agent page, report and editor', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await seeded(page)
for (const path of ['/app/agents', '/app/agents/research-assistant', '/app/agents/research-assistant/runs/r_l', '/app/agents/research-assistant/edit', '/app/agents/research-assistant/status']) {
await page.goto(path)
await page.waitForTimeout(400)
expect(await noOverflow(page), path).toBe(true)
}
})
test('phone: the preview opens as a sheet that fits the screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await seeded(page)
await page.goto('/app/agents/research-assistant/edit')
await page.getByTestId('agent-preview-open').click()
await page.waitForTimeout(600) // the sheet rises into place
const box = await page.getByTestId('agent-preview').boundingBox()
expect(box.width).toBeLessThanOrEqual(390)
expect(box.y + box.height).toBeLessThanOrEqual(844 + 1)
})
test('2560 wide: the report keeps a reading column and opens a table wider on demand', async ({ page }) => {
await page.setViewportSize({ width: 2560, height: 1200 })
await seeded(page)
await page.goto('/app/agents/research-assistant/runs/r_l')
const col = page.locator('.ag-doc__in')
const colBox = await col.boundingBox()
expect(colBox.width).toBeLessThanOrEqual(830)
// Centred in the area beside the sidebar.
expect(colBox.x).toBeGreaterThan(500)
await page.locator('.ag-wide__btn').first().click()
const wide = await page.locator('.ag-wide[data-wide]').boundingBox()
expect(wide.width).toBeGreaterThan(colBox.width)
expect(wide.x + wide.width).toBeLessThanOrEqual(2560)
})
test('1440: the run page uses the page height and docks the follow-up box', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await seeded(page)
await page.goto('/app/agents/research-assistant/runs/r_l')
const dock = await page.getByTestId('run-followup').boundingBox()
expect(dock.y + dock.height).toBeGreaterThan(780)
expect(await noOverflow(page)).toBe(true)
})
test('reduced motion: the working dot does not animate', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await installFakeStream(page)
await mockAgents(page, { observables: { 'research-assistant': [{ id: 1, name: 'x' }] } })
await page.goto('/app/agents/research-assistant')
const anim = await page.locator('.ag-state[data-state="running"] .dk-dot').first().evaluate(el => getComputedStyle(el).animationName)
expect(anim).toBe('none')
})
})
+111 -15
View File
@@ -1,18 +1,23 @@
import { test, expect } from './coverage-fixtures.js'
import { installFakeStream, mockAgents, seedRuns, storedRun, emit, AGENTS } from './agents-fixtures.js'
// Agents feature page (src/pages/Agents.jsx).
// Agents launcher (src/pages/Agents.jsx) and the routes around it.
test.describe('Agents page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/agents')
})
test('renders the agents list and empty state', async ({ page }) => {
await page.goto('/app/agents')
await expect(page).toHaveURL(/\/app\/agents$/)
await expect(page.getByRole('heading', { name: 'Agents', exact: true })).toBeVisible()
await expect(page.getByText(/No agents configured/i)).toBeVisible()
await expect(page.getByRole('button', { name: 'Create Agent' }).first()).toBeVisible()
})
test('the empty state teaches with starting points', async ({ page }) => {
await page.goto('/app/agents')
const empty = page.getByTestId('agents-empty')
await expect(empty.getByRole('link', { name: 'Research assistant' })).toHaveAttribute('href', '/app/agents/new?template=researcher')
await expect(empty.getByRole('link', { name: 'Code reviewer' })).toBeVisible()
})
test('keeps Import Agent visible when agents exist', async ({ page }) => {
await page.route('**/api/agents', route => route.fulfill({
json: { agents: ['existing-agent'], statuses: { 'existing-agent': true } },
@@ -20,12 +25,12 @@ test.describe('Agents page', () => {
await page.route('**/api/agents/existing-agent/observables', route => route.fulfill({
json: { History: [] },
}))
await page.reload()
await page.goto('/app/agents')
await expect(page.locator('.header-actions label', { hasText: 'Import' })).toBeVisible()
})
test('Create Agent navigates to the agent creation form', async ({ page }) => {
await page.goto('/app/agents')
const create = page.getByRole('button', { name: 'Create Agent' }).last()
await create.scrollIntoViewIfNeeded()
await Promise.all([
@@ -33,18 +38,109 @@ test.describe('Agents page', () => {
create.click(),
])
// Wait for AgentCreate.jsx to actually render, not just for the URL to
// change. Ending the test the instant the route matched let the component
// mount race the coverage teardown — its ~400 lines were collected only
// when the render won, swinging total UI coverage ~1pp run-to-run.
// change, so the coverage teardown does not race the component mount.
await expect(page.getByRole('heading', { name: 'Create Agent' })).toBeVisible()
})
test('opens agent status without leaving an active chat', async ({ page }) => {
await page.goto('/app/agents/demo/chat?user_id=test-user')
test('opens agent status without leaving an active run', async ({ page }) => {
await mockAgents(page)
await installFakeStream(page)
await seedRuns(page, 'research-assistant', [storedRun('research-assistant', { id: 'r_a1', task: 'Do it', startedAt: Date.now() - 5000, outcome: 'Done.' })], 'test-user')
await page.goto('/app/agents/research-assistant/runs/r_a1?user_id=test-user')
const status = page.getByRole('link', { name: 'Status' })
await expect(status).toHaveAttribute('href', '/app/agents/demo/status?user_id=test-user')
await expect(status).toHaveAttribute('href', '/app/agents/research-assistant/status?user_id=test-user')
await expect(status).toHaveAttribute('target', '_blank')
await expect(page).toHaveURL(/\/app\/agents\/demo\/chat\?user_id=test-user$/)
await expect(page).toHaveURL(/\/runs\/r_a1\?user_id=test-user$/)
})
test('an old chat link opens the agent page and keeps the user', async ({ page }) => {
await mockAgents(page)
await page.goto('/app/agents/research-assistant/chat?user_id=u7')
await expect(page).toHaveURL(/\/app\/agents\/research-assistant\?user_id=u7$/)
await expect(page.getByTestId('agent-page')).toBeVisible()
})
})
test.describe('Launcher with agents', () => {
const now = Date.now()
const hours = h => now - h * 3600_000
async function open(page, opts = {}) {
await installFakeStream(page)
const seen = await mockAgents(page, {
observables: { 'research-assistant': [{ id: 7, name: 'read_page', creation: { function_definition: { name: 'read_page' } } }] },
...opts,
})
const runs = []
for (let i = 0; i < 16; i++) {
runs.push(storedRun('code-reviewer', {
id: `r_c${i}`, task: `Review ${i}`, startedAt: hours(60 - i), status: i === 3 ? 'failed' : i === 5 ? 'stopped' : 'done', outcome: 'ok', error: i === 3 ? 'boom' : '',
}))
}
await seedRuns(page, 'code-reviewer', runs.reverse())
await seedRuns(page, 'daily-digest', [storedRun('daily-digest', { id: 'r_d1', task: 'Collect news', startedAt: hours(2), status: 'failed', error: 'A page did not answer.' })])
await page.goto('/app/agents')
await expect(page.getByTestId('agents-list')).toBeVisible()
return seen
}
test('shows each agent with its model, attached chips and run strip', async ({ page }) => {
await open(page)
const row = page.locator('[data-agent="research-assistant"]')
await expect(row.getByText('qwen3-14b-instruct')).toBeVisible()
await expect(row.getByRole('link', { name: 'Knowledge base' })).toHaveAttribute('href', '/app/collections')
await expect(row.getByRole('link', { name: 'citations' })).toHaveAttribute('href', '/app/skills')
const strip = page.locator('[data-agent="code-reviewer"] [data-testid="agent-run-strip"]')
await expect(strip.locator('.ag-pip')).toHaveCount(14)
await expect(strip.locator('.ag-pip[data-state="failed"]')).toHaveCount(1)
await expect(strip.locator('.ag-pip[data-state="stopped"]')).toHaveCount(1)
await expect(page.locator('[data-agent="code-reviewer"]')).toContainText('of 16 runs finished')
})
test('an agent with no runs says so instead of showing a record', async ({ page }) => {
await open(page)
await expect(page.locator('[data-agent="research-assistant"]')).toContainText('No runs recorded here yet')
})
test('the Now strip lists work in flight and a run that failed in the last day', async ({ page }) => {
await open(page)
const now = page.getByTestId('agents-now')
await expect(now).toContainText('research-assistant')
await expect(now).toContainText('Working')
await expect(now).toContainText('daily-digest')
await expect(now).toContainText('Failed')
await expect(now).toContainText('A page did not answer.')
await now.getByRole('link', { name: 'Open' }).last().click()
await expect(page).toHaveURL(/\/app\/agents\/daily-digest\/runs\/r_d1$/)
})
test('Run again from the Now strip puts the task in the agent page box', async ({ page }) => {
await open(page)
await page.getByTestId('agents-now').getByRole('link', { name: 'Run again' }).click()
await expect(page).toHaveURL(/\/app\/agents\/daily-digest$/)
await expect(page.locator('#ag-task-input')).toHaveValue('Collect news')
})
test('search narrows the list and says when nothing matches', async ({ page }) => {
await open(page)
await page.getByLabel('Search agents...').fill('review')
await expect(page.locator('[data-agent]')).toHaveCount(1)
await page.getByLabel('Search agents...').fill('zzz')
await expect(page.getByText('No matching agents')).toBeVisible()
})
test('the row menu pauses an agent through the API', async ({ page }) => {
const seen = await open(page)
await page.locator('[data-agent="code-reviewer"]').getByRole('button', { name: /More actions/ }).click()
await page.getByRole('menuitem', { name: 'Pause' }).click()
await expect.poll(() => seen.pauses).toEqual(['code-reviewer'])
})
test('no Now strip when nothing needs a look', async ({ page }) => {
await installFakeStream(page)
await mockAgents(page, { agents: { 'code-reviewer': AGENTS['code-reviewer'] } })
await page.goto('/app/agents')
await expect(page.getByTestId('agents-list')).toBeVisible()
await expect(page.getByTestId('agents-now')).toHaveCount(0)
})
})
@@ -143,6 +143,8 @@ test.describe('Audio Transform', () => {
await expect(page.getByText('mic.wav')).toBeVisible()
// Set a backend tuning param so the form posts params[noise_gate]=true.
// The key=value box lives in the Advanced fold.
await page.getByRole('button', { name: /Advanced parameters/ }).click()
await page.locator('.textarea').fill('noise_gate=true')
await page.getByRole('button', { name: /Transform/ }).last().click()
+119 -41
View File
@@ -1,64 +1,142 @@
import { test, expect } from './coverage-fixtures.js'
import { LOG_LINES, mockOperate } from './operate-fixtures.js'
// Runtime, Logs: what a backend process printed, read live over the log
// stream. The viewer filters by stream and by text, follows the end, shows or
// hides times, exports, and clears (with an undo window).
async function openLogs(page, { lines = LOG_LINES, path = '/app/backend-logs/qwen3-8b-instruct' } = {}) {
await mockOperate(page)
await page.routeWebSocket('**/ws/backend-logs/**', ws => {
ws.send(JSON.stringify({ type: 'initial', lines }))
})
await page.goto(path)
}
const lineRows = page => page.locator('[data-log-line]')
test.describe('Backend Logs', () => {
test('model detail page shows title', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('.page-title')).toContainText('mock-model')
await openLogs(page)
await expect(page.locator('.page-title')).toContainText('qwen3-8b-instruct')
})
test('no back arrow link on detail page', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('a[href="/app/backend-logs"]')).not.toBeVisible()
test('the way out is the Backends list, which is where a process comes from', async ({ page }) => {
await openLogs(page)
await expect(page.locator('.lg-back')).toHaveAttribute('href', '/app/backends?view=installed')
})
test('filter buttons are visible', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('button', { hasText: 'All' })).toBeVisible()
await expect(page.locator('button', { hasText: 'stdout' })).toBeVisible()
await expect(page.locator('button', { hasText: 'stderr' })).toBeVisible()
test('reads the lines the process printed, with the error line marked', async ({ page }) => {
await openLogs(page)
await expect(lineRows(page)).toHaveCount(5)
await expect(lineRows(page).nth(3)).toContainText('out of memory')
await expect(lineRows(page).nth(3)).toHaveAttribute('data-stream', 'stderr')
await expect(lineRows(page).nth(3)).toContainText('err')
})
test('filter buttons toggle active state', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
const allBtn = page.locator('button', { hasText: 'All' })
const stdoutBtn = page.locator('button', { hasText: 'stdout' })
// All is active by default
await expect(allBtn).toHaveClass(/btn-primary/)
// Click stdout
await stdoutBtn.click()
await expect(stdoutBtn).toHaveClass(/btn-primary/)
await expect(allBtn).not.toHaveClass(/btn-primary/)
test('stream buttons narrow the lines and say which is on', async ({ page }) => {
await openLogs(page)
const all = page.getByRole('button', { name: 'All', exact: true })
const stderr = page.getByRole('button', { name: 'stderr', exact: true })
await expect(all).toHaveAttribute('aria-pressed', 'true')
await stderr.click()
await expect(stderr).toHaveAttribute('aria-pressed', 'true')
await expect(all).toHaveAttribute('aria-pressed', 'false')
await expect(lineRows(page)).toHaveCount(1)
await page.getByRole('button', { name: 'stdout', exact: true }).click()
await expect(lineRows(page)).toHaveCount(4)
})
test('export button is present', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('button', { hasText: 'Export' })).toBeVisible()
test('the filter keeps the lines that contain the text', async ({ page }) => {
await openLogs(page)
await page.getByRole('textbox', { name: 'Filter lines' }).fill('backend')
await expect(lineRows(page)).toHaveCount(2)
await page.getByRole('textbox', { name: 'Filter lines' }).fill('nothing like this')
await expect(page.getByText('No line contains that text.')).toBeVisible()
})
test('auto-scroll checkbox is present', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('text=Auto-scroll')).toBeVisible()
test('Times hides the time and the stream label for easier copying', async ({ page }) => {
await openLogs(page)
const times = page.getByRole('switch', { name: 'Times' })
await expect(times).toHaveAttribute('aria-checked', 'true')
await expect(page.locator('.lg-line__time').first()).toBeVisible()
await times.click()
await expect(times).toHaveAttribute('aria-checked', 'false')
await expect(page.locator('.lg-line__time')).toHaveCount(0)
await expect(page.locator('.lg-line__stream')).toHaveCount(0)
})
test('clear button is present', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
await expect(page.locator('button', { hasText: 'Clear' })).toBeVisible()
test('Follow is on by default and can be turned off', async ({ page }) => {
await openLogs(page)
const follow = page.getByRole('switch', { name: 'Follow' })
await expect(follow).toHaveAttribute('aria-checked', 'true')
await follow.click()
await expect(follow).toHaveAttribute('aria-checked', 'false')
})
test('details toggle button is present and toggles', async ({ page }) => {
await page.goto('/app/backend-logs/mock-model')
test('Export offers the visible lines as a file', async ({ page }) => {
await openLogs(page)
const download = page.waitForEvent('download')
await page.getByRole('button', { name: 'Export' }).click()
expect((await download).suggestedFilename()).toMatch(/^backend-logs-qwen3-8b-instruct-\d{4}-\d{2}-\d{2}\.json$/)
})
// "Text only" button visible by default (details are shown)
const toggleBtn = page.locator('button', { hasText: 'Text only' })
await expect(toggleBtn).toBeVisible()
test('Clear hides the lines at once and wipes them when the window ends', async ({ page }) => {
await openLogs(page)
const calls = []
await page.route('**/api/backend-logs/qwen3-8b-instruct/clear', route => { calls.push('clear'); return route.fulfill({ json: {} }) })
await page.getByRole('button', { name: 'Clear' }).click()
await expect(lineRows(page)).toHaveCount(0)
await expect(page.getByTestId('logs-undo-toast')).toContainText('Clearing the log')
expect(calls).toEqual([])
await expect.poll(() => calls, { timeout: 15_000 }).toEqual(['clear'])
})
// Click to hide details
await toggleBtn.click()
test('Undo brings the lines back and never calls the server', async ({ page }) => {
await openLogs(page)
const calls = []
await page.route('**/api/backend-logs/qwen3-8b-instruct/clear', route => { calls.push('clear'); return route.fulfill({ json: {} }) })
await page.getByRole('button', { name: 'Clear' }).click()
await page.getByTestId('logs-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(lineRows(page)).toHaveCount(5)
await page.waitForTimeout(7_000)
expect(calls).toEqual([])
})
// Button label changes to "Show details"
await expect(page.locator('button', { hasText: 'Show details' })).toBeVisible()
test('says so when the process has printed nothing', async ({ page }) => {
await openLogs(page, { lines: [] })
await expect(page.getByText('No log lines')).toBeVisible()
await expect(page.getByText('Log output will appear here as the backend process runs.')).toBeVisible()
})
test('a picker switches to another process', async ({ page }) => {
await openLogs(page)
await page.getByRole('combobox', { name: 'Process' }).selectOption('whisper-large-v3')
await expect(page).toHaveURL(/\/app\/backend-logs\/whisper-large-v3$/)
})
test('the Logs tab lists the processes that have output', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends')
await page.locator('.hub-subnav').getByRole('link', { name: 'Logs' }).click()
await expect(page).toHaveURL(/\/app\/backend-logs$/)
const list = page.getByTestId('logs-processes')
await expect(list.getByRole('link', { name: /qwen3-8b-instruct/ })).toHaveAttribute('href', '/app/backend-logs/qwen3-8b-instruct')
await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"]')).toHaveAttribute('aria-current', 'page')
})
test('says so when no process has printed anything', async ({ page }) => {
await mockOperate(page)
await page.route('**/api/backend-logs', route => route.fulfill({ json: [] }))
await page.goto('/app/backend-logs')
await expect(page.getByText('No process has printed anything')).toBeVisible()
})
test('fits a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await openLogs(page)
await expect(lineRows(page)).toHaveCount(5)
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
})
})
@@ -117,12 +117,19 @@ test.describe('Backends lifecycle page', () => {
await expect(page.getByRole('link', { name: 'Installed', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(page.getByRole('textbox', { name: /search installed backends/i })).toHaveValue('llama')
await expect(page.getByRole('tab', { name: /updates/i })).toHaveAttribute('aria-selected', 'true')
await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp')
// The open row is the one in the URL.
await expect(backendRow(page, 'llama-cpp')).toHaveAttribute('data-selected', 'true')
await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('v1.1.0')
await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/)
await page.getByRole('link', { name: 'Catalog', exact: true }).click()
await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/)
await expect(page.getByText(/installing only on GPU worker/i)).toBeVisible()
// The row opened from the URL; a click closes it and a second one opens it.
// Neither touches the scope.
await backendRow(page, 'llama-cpp').click()
await expect(page).not.toHaveURL(/[?&]backend=/)
await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/)
await backendRow(page, 'llama-cpp').click()
await expect(page).toHaveURL(/[?&]backend=llama-cpp(?:&|$)/)
await expect(page).toHaveURL(/[?&]target=worker-1(?:&|$)/)
@@ -136,8 +143,8 @@ test.describe('Backends lifecycle page', () => {
})
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await expect(page.getByRole('button', { name: /upgrade to v1\.1\.0/i })).toBeVisible()
await page.getByRole('button', { name: 'Actions for llama-cpp' }).click()
await expect(page.getByRole('button', { name: /update to v1\.1\.0/i })).toBeVisible()
await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click()
const reinstall = page.getByRole('menuitem', { name: 'Reinstall backend' })
await expect(reinstall).toBeVisible()
await reinstall.click()
@@ -147,7 +154,7 @@ test.describe('Backends lifecycle page', () => {
test('keeps Reinstall beside Upgrade for an installed backend in Catalog', async ({ page }) => {
await page.goto('/app/backends?view=catalog&backend=llama-cpp')
await expect(page.locator('button[title^="Upgrade to"]')).toBeVisible()
await expect(page.locator('button[title^="Update to"]')).toBeVisible()
await expect(page.locator('button[title="Reinstall"]')).toBeVisible()
})
@@ -158,7 +165,7 @@ test.describe('Backends lifecycle page', () => {
}))
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await page.getByRole('button', { name: 'Actions for llama-cpp' }).click()
await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click()
await page.getByRole('menuitem', { name: 'Reinstall backend' }).click()
const detail = page.locator('[data-testid="backends-installed-pane"]')
@@ -172,7 +179,7 @@ test.describe('Backends lifecycle page', () => {
}))
await page.goto('/app/backends?view=installed')
await page.getByRole('button', { name: /upgrade all/i }).click()
await page.getByRole('button', { name: /update all/i }).click()
await expect(page.getByRole('alert')).toContainText('upgrade registry unavailable')
})
@@ -199,7 +206,7 @@ test.describe('Backends lifecycle page', () => {
})
await page.goto('/app/backends?view=installed')
await page.getByRole('button', { name: /upgrade all/i }).click()
await page.getByRole('button', { name: /update all/i }).click()
await expect(page.getByRole('alert')).toContainText('first registry unavailable')
await expect.poll(() => laterUpgradeRequests).toBe(1)
@@ -269,7 +276,7 @@ test.describe('Backends lifecycle page', () => {
})
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await page.getByRole('button', { name: 'Actions for llama-cpp' }).click()
await page.getByRole('button', { name: 'Actions for llama-cpp', exact: true }).click()
await page.getByRole('menuitem', { name: 'Delete backend' }).click()
await expect(page.getByRole('alertdialog')).toContainText('Delete backend llama-cpp?')
expect(deleteRequests).toBe(0)
@@ -278,18 +285,23 @@ test.describe('Backends lifecycle page', () => {
await expect.poll(() => deleteRequests).toBe(1)
})
test('narrow detail Back restores focus to the originating backend', async ({ page }) => {
test('on a phone a backend opens in place and closes again, with its action in reach', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/backends?view=installed')
const backend = backendRow(page, 'llama-cpp')
await backend.click()
await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('llama-cpp')
await expect(backend).not.toBeVisible()
await page.locator('[data-testid="backends-installed-back"]').click()
await expect(backend).toBeVisible()
await expect(backend).toBeFocused()
// The row is a card: name, state and the action, no sideways scroll.
await expect(backend.getByRole('button', { name: 'Update llama-cpp' })).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
await backend.click()
await expect(backend).toHaveAttribute('data-selected', 'true')
await expect(page.locator('[data-testid="backends-installed-pane"]')).toContainText('v1.1.0')
await expect(backend).toBeVisible()
await backend.getByRole('button', { name: /Hide details for llama-cpp/ }).click()
await expect(page.locator('[data-testid="backends-installed-pane"]')).toHaveCount(0)
})
})
@@ -1,8 +1,9 @@
import { test, expect } from './coverage-fixtures.js'
// Backends admin page (src/pages/Backends.jsx).
const PANE = '[data-testid="backends-pane"]'
const railItem = (page, name) => page.locator(`[data-entity="${name}"]`)
// Backends admin page (src/pages/Backends.jsx): one list with an Installed and
// a Catalog view. A row is a name, a version, a state and the one action; it
// opens in place into the rest.
const row = (page, name) => page.locator(`[data-entity="${name}"]`)
test.describe('Backends management page', () => {
test.beforeEach(async ({ page }) => {
@@ -12,7 +13,7 @@ test.describe('Backends management page', () => {
test('renders the management header and gallery tabs', async ({ page }) => {
await expect(page).toHaveURL(/\/app\/backends$/)
await expect(page.getByRole('heading', { name: 'Backend Management' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Manual Install' })).toBeVisible()
await expect(page.getByRole('button', { name: 'From URL' })).toBeVisible()
await expect(page.getByRole('button').filter({ hasText: /^All$/ })).toBeVisible()
await expect(page.getByRole('button').filter({ hasText: /^Image$/ })).toBeVisible()
})
@@ -24,8 +25,8 @@ test.describe('Backends management page', () => {
await expect(search).toHaveValue('whisper')
})
test('Manual Install reveals the OCI install form', async ({ page }) => {
await page.getByRole('button', { name: 'Manual Install' }).click()
test('From URL reveals the OCI install form', async ({ page }) => {
await page.getByRole('button', { name: 'From URL' }).click()
await expect(page.getByPlaceholder('oci://quay.io/example/backend:latest')).toBeVisible()
})
})
@@ -52,14 +53,11 @@ test.describe('Backends management page - Markdown descriptions', () => {
})
})
await page.goto('/app/backends')
// Rendered means the rail has entries. The old gate waited on a column
// header, and there are no columns now.
await expect(railItem(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 })
await expect(row(page, 'markdown-backend')).toBeVisible({ timeout: 10_000 })
})
test('the pane lede shows the description as clean text, not raw Markdown', async ({ page }) => {
await railItem(page, 'markdown-backend').click()
const cell = page.locator('.detail-pane__lede')
test('the row shows the description as clean text, not raw Markdown', async ({ page }) => {
const cell = row(page, 'markdown-backend').locator('.dk-table-sub')
await expect(cell).toHaveText(STRIPPED_DESCRIPTION)
// The syntax itself must be gone, not merely rendered somewhere.
@@ -71,23 +69,27 @@ test.describe('Backends management page - Markdown descriptions', () => {
await expect(cell.locator('h1')).toHaveCount(0)
})
test("the lede's tooltip carries the stripped text, not raw Markdown", async ({ page }) => {
await railItem(page, 'markdown-backend').click()
await expect(page.locator('.detail-pane__lede')).toHaveAttribute('title', STRIPPED_DESCRIPTION)
test("the row's tooltip carries the stripped text, not raw Markdown", async ({ page }) => {
await expect(row(page, 'markdown-backend').locator('.dk-table-sub')).toHaveAttribute('title', STRIPPED_DESCRIPTION)
})
test('a backend with no description renders no lede rather than a blank one', async ({ page }) => {
// The table needed a placeholder because an empty cell in a grid of full
// ones reads as a fault. The pane has no grid to keep aligned, so it omits
// the line - but must never print "undefined".
await railItem(page, 'plain-backend').click()
await expect(page.locator(PANE)).toContainText('plain-backend')
await expect(page.locator('.detail-pane__lede')).toHaveCount(0)
await expect(page.locator(PANE)).not.toContainText('undefined')
test('opening the row renders the Markdown, with its link', async ({ page }) => {
await row(page, 'markdown-backend').click()
const detail = page.getByTestId('backend-detail')
await expect(detail.locator('.bk-detail__desc h1')).toHaveText('InsightFace')
await expect(detail.locator('.bk-detail__desc a[href="https://example.com/docs"]')).toBeVisible()
})
test('a backend with no description renders no blank line and never "undefined"', async ({ page }) => {
await expect(row(page, 'plain-backend')).toContainText('plain-backend')
await expect(row(page, 'plain-backend')).not.toContainText('undefined')
await row(page, 'plain-backend').click()
await expect(page.getByTestId('backend-detail')).not.toContainText('undefined')
await expect(page.locator('.bk-detail__desc')).toHaveCount(0)
})
})
test.describe('Backends gallery - split view', () => {
test.describe('Backends gallery - list', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/backends*', (route) => {
route.fulfill({
@@ -102,46 +104,50 @@ test.describe('Backends gallery - split view', () => {
})
})
await page.goto('/app/backends')
await expect(railItem(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 })
await expect(row(page, 'llama-cpp')).toBeVisible({ timeout: 10_000 })
})
test('the gallery renders no table', async ({ page }) => {
test('is a table with columns, not a rail and a pane', async ({ page }) => {
await expect(page.locator('[data-testid="backends"]')).toBeVisible()
await expect(page.locator('table thead th')).toHaveCount(0)
await expect(page.locator('table thead th').first()).toBeVisible()
await expect(page.locator('[data-testid="backends-pane"]')).toHaveCount(0)
})
test('with nothing selected the pane describes the host', async ({ page }) => {
await expect(page.locator(PANE)).toContainText('This host')
await expect(page.locator('[data-testid="backends-back"]')).toHaveCount(0)
test('opening a row shows its facts, and the chevron closes it', async ({ page }) => {
await row(page, 'llama-cpp').click()
const detail = page.getByTestId('backend-detail')
await expect(detail).toContainText('MIT')
await expect(detail).toContainText('chat')
await row(page, 'llama-cpp').getByRole('button', { name: /Hide details for llama-cpp/ }).click()
await expect(page.getByTestId('backend-detail')).toHaveCount(0)
})
test('choosing a backend turns the pane into its detail, and back returns', async ({ page }) => {
await railItem(page, 'llama-cpp').click()
await expect(page.locator(PANE)).toContainText('llama-cpp')
await expect(page.locator(PANE)).toContainText('MIT')
await expect(page.locator(PANE)).not.toContainText('This host')
await page.locator('[data-testid="backends-back"]').click()
await expect(page.locator(PANE)).toContainText('This host')
test('only one row is open at a time', async ({ page }) => {
await row(page, 'llama-cpp').click()
await row(page, 'whisper').click()
await expect(page.getByTestId('backend-detail')).toHaveCount(1)
await expect(page.getByTestId('backend-detail')).toContainText('transcript')
await expect(page).toHaveURL(/[?&]backend=whisper/)
})
test('the selection lives in the URL and survives a reload', async ({ page }) => {
await railItem(page, 'whisper').click()
test('the open row lives in the URL and survives a reload', async ({ page }) => {
await row(page, 'whisper').click()
await expect(page).toHaveURL(/[?&]backend=whisper/)
await page.reload()
await expect(railItem(page, 'whisper')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('[data-testid="backends-back"]')).toBeVisible()
await expect(row(page, 'whisper')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('backend-detail')).toContainText('transcript')
})
test('the rail groups while browsing and flattens on a query', async ({ page }) => {
await expect(page.locator('[data-testid^="backends-rail-group-"]').first()).toBeVisible()
await page.locator('input[placeholder*="Search backends"]').fill('llama')
await expect(page.locator('[data-testid^="backends-rail-group-"]')).toHaveCount(0)
test('a filter chip narrows the list', async ({ page }) => {
await page.getByRole('button', { name: 'Image', exact: true }).click()
await expect(page).toHaveURL(/[?&]state=image/)
await expect(row(page, 'diffusers')).toBeVisible()
await expect(row(page, 'llama-cpp')).toHaveCount(0)
})
test('an installed backend states its version, an absent one says so', async ({ page }) => {
await expect(railItem(page, 'llama-cpp')).toContainText('v1.52.0')
await expect(railItem(page, 'diffusers')).toContainText('not installed')
await expect(row(page, 'llama-cpp')).toContainText('v1.52.0')
await expect(row(page, 'llama-cpp')).toContainText('Current')
await expect(row(page, 'diffusers')).toContainText('Not installed')
})
})
+19 -19
View File
@@ -1,23 +1,23 @@
import { test, expect } from './coverage-fixtures.js'
import { UPGRADE_LLAMA, mockOperate } from './operate-fixtures.js'
// A notice is a hairline with a coloured left edge, not a filled panel. A tint
// makes every notice shout at the weight of an error, which is how notices stop
// being read — and it is the same treatment the Operate overview uses for the
// rows that want a decision.
// An update is said where it can be acted on: in the row's state, in the one
// primary button above the list and on the tab. It is not a banner. A filled
// panel makes every notice shout at the weight of an error, and a coloured
// rail on its edge is decoration that repeats on every item.
test('the backends notice is an edge, not a filled card', async ({ page }) => {
// The upgrade banner is the notice worth pinning, so make one exist.
await page.route('**/api/backends/upgrades', route => route.fulfill({
json: { 'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7' } },
}))
await page.goto('/app/backends')
const notice = page.locator('.bk-notice', { hasText: /update/i }).first()
await expect(notice).toBeVisible()
const s = await notice.evaluate(el => {
const cs = getComputedStyle(el)
return { bg: cs.backgroundColor, left: parseFloat(cs.borderLeftWidth), top: parseFloat(cs.borderTopWidth) }
})
expect(s.bg).toMatch(/rgba\(0, 0, 0, 0\)|transparent/)
expect(s.left).toBeGreaterThanOrEqual(3)
expect(s.top).toBeLessThanOrEqual(1)
test('an update is a row state and a header button, not a banner or a rail', async ({ page }) => {
await mockOperate(page, { upgrades: UPGRADE_LLAMA })
await page.goto('/app/backends?view=installed')
const row = page.locator('[data-entity="llama-cpp"]')
await expect(row).toContainText('Update 0.9.7')
await expect(page.getByRole('button', { name: 'Update all (1)' })).toBeVisible()
await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"] .dk-hubtab-attn')).toContainText('1')
// Nothing here is a filled card with an edge.
await expect(page.locator('.bk-notice')).toHaveCount(0)
const edges = await page.evaluate(() => [...document.querySelectorAll('.bk-table tr, .op-notice, .bk-recommend, .bk-manual')]
.map(el => parseFloat(getComputedStyle(el).borderLeftWidth)))
expect(Math.max(0, ...edges)).toBeLessThanOrEqual(1)
})
@@ -0,0 +1,307 @@
import { test, expect } from './coverage-fixtures.js'
import {
CATALOG, FAILED_OP, INSTALLED, RUNNING_OP, UPGRADE_LLAMA, catalogBackend, installedBackend, mockOperate,
} from './operate-fixtures.js'
// Runtime, Backends: Installed and Catalog as two views of one list. A row says
// what the backend is doing now, carries the one button that matters, and opens
// into the rest.
const row = (page, name) => page.locator(`[data-entity="${name}"]`)
const jsonOk = route => route.fulfill({ json: { status: 'ok' } })
const VLLM_INSTALLING = { ...RUNNING_OP, name: 'vllm', id: 'vllm', fullName: 'localai@vllm', jobID: 'job-vllm', progress: 6 }
test.describe('Backends views', () => {
test('Installed and Catalog carry their counts, and the current one says so', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends?view=installed')
const installed = page.getByRole('link', { name: 'Installed', exact: true })
const catalog = page.getByRole('link', { name: 'Catalog', exact: true })
await expect(installed).toHaveAttribute('aria-current', 'page')
await expect(installed).toContainText('3')
await expect(catalog).toContainText('6')
await expect(row(page, 'llama-cpp')).toBeVisible()
await catalog.click()
await expect(catalog).toHaveAttribute('aria-current', 'page')
await expect(row(page, 'vllm')).toBeVisible()
})
test('the Installed list puts what has an update first', async ({ page }) => {
await mockOperate(page, { upgrades: { whisper: { backend_name: 'whisper', installed_version: '1.7.2', available_version: '1.8.0' } } })
await page.goto('/app/backends?view=installed')
await expect(page.getByTestId('backend-row').first()).toHaveAttribute('data-entity', 'whisper')
await expect(row(page, 'whisper')).toContainText('Update 1.8.0')
})
test('an empty Installed list sends you to the catalog', async ({ page }) => {
await mockOperate(page, { installed: [] })
await page.goto('/app/backends?view=installed')
await expect(page.getByText('No backends installed yet')).toBeVisible()
await page.getByRole('link', { name: 'Open the catalog' }).click()
await expect(page.getByRole('link', { name: 'Catalog', exact: true })).toHaveAttribute('aria-current', 'page')
})
test('a first run recommends llama-cpp and installs it with one button', async ({ page }) => {
await mockOperate(page, { catalog: CATALOG.map(b => ({ ...b, installed: false })), installed: [] })
const calls = []
await page.route('**/api/backends/install/llama-cpp', route => { calls.push('install'); return jsonOk(route) })
await page.goto('/app/backends')
const recommend = page.getByTestId('backends-recommend')
await expect(recommend).toContainText('Start with llama-cpp')
await recommend.getByRole('button', { name: 'Install llama-cpp' }).click()
await expect.poll(() => calls).toEqual(['install'])
})
test('no recommendation once something is installed', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends')
await expect(row(page, 'llama-cpp')).toBeVisible()
await expect(page.getByTestId('backends-recommend')).toHaveCount(0)
})
})
test.describe('Backend install progress', () => {
test('shows progress in the row and offers Cancel with an undo window', async ({ page }) => {
await mockOperate(page, { operations: [VLLM_INSTALLING] })
const calls = []
await page.route('**/api/operations/job-vllm/cancel', route => { calls.push('cancel'); return jsonOk(route) })
await page.goto('/app/backends')
const vllm = row(page, 'vllm')
await expect(vllm.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '6')
await expect(vllm).toContainText('6%')
await vllm.getByRole('button', { name: 'Cancel vllm' }).click()
await expect(page.getByTestId('backends-undo-toast')).toContainText('Cancelling vllm')
expect(calls).toEqual([])
await expect.poll(() => calls, { timeout: 15_000 }).toEqual(['cancel'])
})
test('Undo keeps the install going', async ({ page }) => {
await mockOperate(page, { operations: [VLLM_INSTALLING] })
const calls = []
await page.route('**/api/operations/job-vllm/cancel', route => { calls.push('cancel'); return jsonOk(route) })
await page.goto('/app/backends')
await row(page, 'vllm').getByRole('button', { name: 'Cancel vllm' }).click()
await page.getByTestId('backends-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(page.getByTestId('backends-undo-toast')).toHaveCount(0)
await expect(row(page, 'vllm').getByRole('progressbar')).toBeVisible()
await page.waitForTimeout(9_000)
expect(calls).toEqual([])
})
test('a queued install says so and a failed one says why, with Retry', async ({ page }) => {
await mockOperate(page, {
operations: [
{ ...VLLM_INSTALLING, isQueued: true, progress: 0 },
{ ...FAILED_OP, id: 'diffusers', name: 'diffusers', fullName: 'localai@diffusers', jobID: 'job-diff', isBackend: true, error: 'no space left on device' },
],
})
const calls = []
await page.route('**/api/backends/install/diffusers', route => { calls.push('install'); return jsonOk(route) })
await page.goto('/app/backends')
await expect(row(page, 'vllm')).toContainText('Queued')
await expect(row(page, 'diffusers')).toContainText('Failed')
await expect(row(page, 'diffusers')).toContainText('no space left on device')
await row(page, 'diffusers').getByRole('button', { name: 'Retry diffusers' }).click()
await expect.poll(() => calls).toEqual(['install'])
})
})
test.describe('Backend updates', () => {
test('Update on the row starts that update only', async ({ page }) => {
await mockOperate(page, { upgrades: UPGRADE_LLAMA })
const calls = []
await page.route('**/api/backends/upgrade/llama-cpp', route => { calls.push('llama-cpp'); return jsonOk(route) })
await page.goto('/app/backends?view=installed')
await row(page, 'llama-cpp').getByRole('button', { name: 'Update llama-cpp' }).click()
await expect.poll(() => calls).toEqual(['llama-cpp'])
})
test('Update all starts every update and counts them', async ({ page }) => {
await mockOperate(page, {
upgrades: {
...UPGRADE_LLAMA,
whisper: { backend_name: 'whisper', installed_version: '1.7.2', available_version: '1.8.0' },
},
})
const calls = []
await page.route('**/api/backends/upgrade/*', route => { calls.push(new URL(route.request().url()).pathname.split('/').pop()); return jsonOk(route) })
await page.goto('/app/backends?view=installed')
await page.getByRole('button', { name: 'Update all (2)' }).click()
await expect.poll(() => calls.sort()).toEqual(['llama-cpp', 'whisper'])
})
test('Check for updates asks the server to check and says what it found', async ({ page }) => {
await mockOperate(page)
let checks = 0
await page.route('**/api/backends/upgrades/check', route => { checks += 1; return route.fulfill({ json: UPGRADE_LLAMA }) })
await page.goto('/app/backends?view=installed')
await page.getByRole('button', { name: 'Check for updates' }).click()
await expect.poll(() => checks).toBe(1)
await expect(page.getByRole('button', { name: 'Update all (1)' })).toBeVisible()
})
test('says when every backend is current', async ({ page }) => {
await mockOperate(page)
await page.route('**/api/backends/upgrades/check', route => route.fulfill({ json: {} }))
await page.goto('/app/backends?view=installed')
await page.getByRole('button', { name: 'Check for updates' }).click()
await expect(page.getByText('Every installed backend is current')).toBeVisible()
})
})
test.describe('Removing a backend', () => {
test('names the models that would stop working', async ({ page }) => {
await mockOperate(page)
const calls = []
await page.route('**/api/backends/system/delete/llama-cpp', route => { calls.push('delete'); return jsonOk(route) })
await page.goto('/app/backends?view=installed')
await row(page, 'llama-cpp').getByRole('button', { name: 'Actions for llama-cpp' }).click()
await page.getByRole('menuitem', { name: 'Delete backend' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Delete backend llama-cpp?')
await expect(dialog.getByTestId('backend-remove-warning')).toContainText('2 models ask for this backend')
await expect(dialog.getByTestId('backend-remove-warning')).toContainText('gemma-3-12b-it, qwen3-8b-instruct')
expect(calls).toEqual([])
await dialog.getByRole('button', { name: 'Delete', exact: true }).click()
await expect.poll(() => calls).toEqual(['delete'])
})
test('a backend nothing depends on has no warning', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends?view=installed')
await row(page, 'kokoro').getByRole('button', { name: 'Actions for kokoro' }).click()
await page.getByRole('menuitem', { name: 'Delete backend' }).click()
await expect(page.getByRole('alertdialog')).toContainText('Delete backend kokoro?')
await expect(page.getByTestId('backend-remove-warning')).toHaveCount(0)
})
test('a meta backend that points at the one removed is named', async ({ page }) => {
await mockOperate(page, {
installed: [
installedBackend('llama-cpp', '0.9.4', { Metadata: { version: '0.9.4', meta_backend_for: 'cuda12-llama-cpp' } }),
installedBackend('cuda12-llama-cpp', '0.9.4'),
],
models: { data: [] },
})
await page.goto('/app/backends?view=installed&show_all=1')
await row(page, 'cuda12-llama-cpp').getByRole('button', { name: 'Actions for cuda12-llama-cpp' }).click()
await page.getByRole('menuitem', { name: 'Delete backend' }).click()
await expect(page.getByTestId('backend-remove-warning')).toContainText('llama-cpp points at it and will stop resolving.')
})
test('keeps a model that uses a backend visible in the open row', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends?view=installed&backend=whisper')
await expect(page.getByTestId('backend-used-by')).toContainText('Used by 1 model: whisper-large-v3')
})
test('a system backend is protected and offers no removal', async ({ page }) => {
await mockOperate(page, { installed: [installedBackend('cpu-ggml', '1.0.0', { IsSystem: true })] })
await page.goto('/app/backends?view=installed')
await expect(row(page, 'cpu-ggml')).toContainText('Protected')
await expect(row(page, 'cpu-ggml').getByRole('button', { name: /Actions for/ })).toHaveCount(0)
})
})
test.describe('Install from URL and the catalog', () => {
test('posts the image, the name and the alias', async ({ page }) => {
await mockOperate(page)
const bodies = []
await page.route('**/api/backends/install-external', route => { bodies.push(route.request().postDataJSON()); return jsonOk(route) })
await page.goto('/app/backends')
await page.getByRole('button', { name: 'From URL' }).click()
await page.getByLabel('OCI image, URL or path').fill('oci://quay.io/example/backend:latest')
await page.getByLabel('Name (required for an OCI image)').fill('my-backend')
await page.getByLabel('Alias (optional)').fill('mine')
await page.getByRole('button', { name: 'Install', exact: true }).click()
await expect.poll(() => bodies).toEqual([{ uri: 'oci://quay.io/example/backend:latest', name: 'my-backend', alias: 'mine' }])
})
test('asks for an address before posting anything', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends')
await page.getByRole('button', { name: 'From URL' }).click()
await page.getByRole('button', { name: 'Install', exact: true }).click()
await expect(page.getByRole('alert')).toContainText('Enter an image, URL or path')
})
test('a catalog row installs a backend that is not there', async ({ page }) => {
await mockOperate(page)
const calls = []
await page.route('**/api/backends/install/vllm', route => { calls.push('vllm'); return jsonOk(route) })
await page.goto('/app/backends')
await row(page, 'vllm').getByRole('button', { name: 'Install vllm' }).click()
await expect.poll(() => calls).toEqual(['vllm'])
})
test('the open row links to the logs list', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await page.getByTestId('backend-detail').getByRole('link', { name: 'Logs' }).click()
await expect(page).toHaveURL(/\/app\/backend-logs$/)
})
test('shows no size, no rollback and no version history, because the API has none', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/backends?view=installed&backend=llama-cpp')
await expect(page.getByTestId('backend-detail')).not.toContainText(/roll back|rollback|history/i)
await expect(page.locator('.bk-table thead')).not.toContainText(/size/i)
})
})
test.describe('Backends in a cluster', () => {
const nodes = [
{ id: 'n1', name: 'gpu-box-1', status: 'healthy', node_type: 'backend' },
{ id: 'n2', name: 'gpu-box-2', status: 'healthy', node_type: 'backend' },
]
test('a meta backend installs on all nodes, with a way to choose', async ({ page }) => {
await mockOperate(page, { distributed: true, nodes, catalog: [catalogBackend('vllm', { isMeta: true })], installed: [] })
await page.goto('/app/backends')
const vllm = row(page, 'vllm')
await expect(vllm.getByRole('button', { name: 'Install vllm' })).toContainText('Install on all')
await vllm.getByRole('button', { name: 'More install options' }).click()
await expect(page.getByRole('menuitem', { name: 'Install on specific nodes…' })).toBeVisible()
})
test('a hardware-specific build goes straight to choosing nodes', async ({ page }) => {
await mockOperate(page, { distributed: true, nodes, catalog: [catalogBackend('cuda12-vllm')], installed: [] })
await page.goto('/app/backends')
await expect(row(page, 'cuda12-vllm').getByRole('button', { name: 'Choose nodes…' })).toBeVisible()
})
test('the Nodes column shows where a backend is installed', async ({ page }) => {
await mockOperate(page, {
distributed: true,
nodes,
installed: [installedBackend('llama-cpp', '0.9.4', { Nodes: [{ node_id: 'n1', node_name: 'gpu-box-1', node_status: 'healthy', version: '0.9.4' }] })],
})
await page.goto('/app/backends?view=installed')
await expect(page.locator('.bk-table thead')).toContainText('Nodes')
await expect(row(page, 'llama-cpp')).toContainText('gpu-box-1')
})
})
test.describe('Backends layout', () => {
test('a catalog row fits a phone with its progress and Cancel', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await mockOperate(page, { operations: [VLLM_INSTALLING], installed: INSTALLED })
await page.goto('/app/backends')
const vllm = row(page, 'vllm')
await expect(vllm.getByRole('progressbar')).toBeVisible()
await expect(vllm.getByRole('button', { name: 'Cancel vllm' })).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
})
test('the install bar does not move when motion is reduced', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockOperate(page, { operations: [VLLM_INSTALLING] })
await page.goto('/app/backends')
const duration = await row(page, 'vllm').locator('.dk-progress-bar').evaluate(el => parseFloat(getComputedStyle(el).transitionDuration))
expect(duration).toBeLessThan(0.001)
})
})
@@ -0,0 +1,161 @@
import { test, expect } from './coverage-fixtures.js'
import { CPU_HOST, GPU_HOST, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js'
// The Build landing says what each tool is for and what it needs from this
// machine. The needs come from the machine's own figures and the installed
// backends, so each spec stubs those and reads the card.
const tool = (page, id) => page.locator(`[data-testid="build-landing"] [data-tool="${id}"]`)
async function setup(page, { host = GPU_HOST, ft = {}, qz = {}, features } = {}) {
await mockMachine(page, { host, ...(features ? { features } : {}) })
await mockFineTune(page, { jobs: [], ...ft })
await mockQuantize(page, { jobs: [], ...qz })
}
test.describe('Build landing', () => {
test('lists the tools in three groups with a sentence each', async ({ page }) => {
await setup(page)
await page.goto('/app/build')
const landing = page.getByTestId('build-landing')
await expect(landing.getByRole('heading', { name: 'Make a model' })).toBeVisible()
await expect(landing.getByRole('heading', { name: 'Recognise' })).toBeVisible()
await expect(landing.getByRole('heading', { name: 'Automate' })).toBeVisible()
await expect(tool(page, 'import')).toContainText('Bring in a model from Hugging Face')
await expect(tool(page, 'quantize')).toContainText('Make a model smaller')
await expect(tool(page, 'fine-tune')).toContainText('Teach a model your own data')
await expect(tool(page, 'agents')).toContainText('Create and run agents')
// The flow strip names the make-a-model steps in order.
await expect(page.getByRole('list', { name: /path from a model/i }).locator('li')).toHaveText(['Import', 'Quantize', 'Fine-Tune', 'Chat'])
})
test('every tool opens its page', async ({ page }) => {
await setup(page)
await page.goto('/app/build')
await expect(tool(page, 'fine-tune').getByRole('link', { name: 'Open Fine-Tune' })).toHaveAttribute('href', '/app/fine-tune')
await expect(tool(page, 'quantize').getByRole('link', { name: /Open Quantize/ })).toHaveAttribute('href', '/app/quantize')
await expect(tool(page, 'import').getByRole('link', { name: /Open Import/ })).toHaveAttribute('href', '/app/import-model')
})
test('a machine with a GPU and both backends shows ready tools and the figures', async ({ page }) => {
await setup(page)
await page.goto('/app/build')
const machine = page.getByTestId('build-machine')
await expect(machine).toContainText('RTX 4070')
await expect(machine).toContainText('9.8 GB of 12 GB free')
await expect(machine).toContainText('118 GB')
await expect(machine).toContainText('llama-cpp-quantization')
await expect(machine).toContainText('trl')
for (const id of ['import', 'quantize', 'fine-tune']) {
await expect(tool(page, id)).toHaveAttribute('data-state', 'ready')
await expect(tool(page, id).locator('.dk-badge--ok')).toHaveText('Ready')
}
await expect(tool(page, 'fine-tune')).toContainText('GPU memory: RTX 4070, 9.8 GB of 12 GB free')
await expect(tool(page, 'quantize')).toContainText('Runs on the CPU')
await expect(tool(page, 'import')).toContainText('118 GB free on the models disk')
})
test('no backend installed: the tool says why and an admin gets an Install link', async ({ page }) => {
await setup(page, { host: CPU_HOST, ft: { backends: [] }, qz: { backends: [] } })
await page.goto('/app/build')
for (const id of ['quantize', 'fine-tune']) {
await expect(tool(page, id)).toHaveAttribute('data-state', 'needs-backend')
await expect(tool(page, id)).toContainText('Backend needed')
await expect(tool(page, id).getByRole('link', { name: 'Install' })).toHaveAttribute('href', '/app/backends')
}
await expect(tool(page, 'quantize')).toContainText('The quantization backend is not installed')
await expect(tool(page, 'fine-tune')).toContainText('No fine-tuning backend is installed')
await expect(tool(page, 'import')).toHaveAttribute('data-state', 'ready')
await expect(page.getByTestId('build-machine')).toContainText('none installed for these tools')
})
test('no GPU is a warning on Fine-tune, not a block', async ({ page }) => {
await setup(page, { host: CPU_HOST })
await page.goto('/app/build')
await expect(page.getByTestId('build-machine')).toContainText('No GPU found')
await expect(tool(page, 'fine-tune')).toHaveAttribute('data-state', 'ready')
await expect(tool(page, 'fine-tune').locator('[data-tone="warn"]')).toContainText('No GPU found. Training runs on the CPU')
})
test('a running job is surfaced above the list and opens its page', async ({ page }) => {
await setup(page, { qz: { jobs: [qzJob('qz1', 'converting', { message: 'converting tensor 84 of 201' })] } })
await page.goto('/app/build')
const now = page.getByTestId('build-now')
await expect(now).toHaveAttribute('data-kind', 'running')
await expect(now).toContainText('Quantizing meta-llama/Llama-3.2-1B to q4_k_m')
await expect(now).toContainText('converting tensor 84 of 201')
await expect(now.getByRole('link', { name: 'Open' })).toHaveAttribute('href', '/app/quantize')
})
test('a failed newest job is surfaced, an old failure is not', async ({ page }) => {
await setup(page, { ft: { jobs: [ftJob('j2', 'failed', { message: 'CUDA out of memory' })] } })
await page.goto('/app/build')
await expect(page.getByTestId('build-now')).toHaveAttribute('data-kind', 'failed')
await expect(page.getByTestId('build-now')).toContainText('Fine-tuning TinyLlama/TinyLlama-1.1B-Chat-v1.0 failed')
})
test('no job, no line', async ({ page }) => {
await setup(page, { ft: { jobs: [ftJob('j1', 'completed')] } })
await page.goto('/app/build')
await expect(page.getByTestId('build-landing')).toBeVisible()
await expect(page.getByTestId('build-now')).toHaveCount(0)
})
test('a tool switched off by a feature flag is left out', async ({ page }) => {
await setup(page, { features: { agents: false, mcp: false, fine_tuning: false, quantization: true, distributed: false } })
await page.goto('/app/build')
await expect(tool(page, 'fine-tune')).toHaveCount(0)
await expect(tool(page, 'quantize')).toBeVisible()
await expect(tool(page, 'agents')).toHaveCount(0)
await expect(page.getByRole('list', { name: 'Build tools' }).locator('a[href="/app/import-model"]')).toBeVisible()
})
test('a member without admin sees no machine strip, no Install link, and the tools they may use', async ({ page }) => {
await mockMachine(page, { host: null })
await mockFineTune(page, { jobs: [], backends: [] })
await mockQuantize(page, { jobs: [] })
await page.route('**/api/auth/status', route => route.fulfill({
json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { fine_tuning: true } } },
}))
await page.goto('/app/build')
await expect(tool(page, 'fine-tune')).toHaveAttribute('data-state', 'needs-backend')
await expect(tool(page, 'fine-tune')).toContainText('Ask an administrator to install the backend')
await expect(tool(page, 'fine-tune').getByRole('link', { name: 'Install' })).toHaveCount(0)
await expect(page.getByTestId('build-machine')).toHaveCount(0)
await expect(tool(page, 'import')).toHaveCount(0)
await expect(tool(page, 'quantize')).toHaveCount(0)
})
test('a user with no Build tool sees the empty line', async ({ page }) => {
await mockMachine(page, { host: null })
await page.route('**/api/auth/status', route => route.fulfill({
json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { chat: true } } },
}))
await page.goto('/app/build')
await expect(page.getByTestId('build-empty')).toContainText('No build tools are available')
})
})
test.describe('Build landing: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('fits the screen and keeps every Open link tappable', async ({ page }) => {
await setup(page, { qz: { jobs: [qzJob('qz1', 'converting')] } })
await page.goto('/app/build')
await expect(page.getByTestId('build-landing')).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
const heights = await page.locator('[data-testid="build-landing"] .bt-tool__end a').evaluateAll(els => els.map(el => el.getBoundingClientRect().height))
expect(heights.length).toBeGreaterThan(5)
expect(Math.min(...heights)).toBeGreaterThanOrEqual(30)
})
})
test.describe('Build landing: reduced motion', () => {
test('page content does not animate', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await setup(page)
await page.goto('/app/build')
const duration = await page.getByTestId('build-landing').locator('.bt-tool').first().evaluate(el => getComputedStyle(el).animationDuration)
expect(parseFloat(duration)).toBeLessThan(0.01)
})
})
+513
View File
@@ -0,0 +1,513 @@
import { test, expect } from './coverage-fixtures.js'
import { CPU_HOST, GPU_HOST, ftEvents, ftJob, mockFineTune, mockMachine, mockQuantize, qzJob } from './tools-fixtures.js'
// Fine-tune and Quantize share one pattern: set up, check before you start (the
// list redraws as the form changes), run with progress and a log, then a result
// with real next steps. Every number on these pages comes from the stubs below.
const MODEL = 'TinyLlama/TinyLlama-1.1B-Chat-v1.0'
const expectStep = (page, key, state) => expect(page.locator(`[data-testid="tool-steps"] [data-step="${key}"]`)).toHaveAttribute('data-state', state)
const check = (page, id) => page.locator(`[data-testid="tool-checks"] [data-check="${id}"]`)
async function boot(page, { host = GPU_HOST, ft = {}, qz = {} } = {}) {
await mockMachine(page, { host })
const ftState = { jobs: [], ...ft }
const qzState = { jobs: [], ...qz }
await mockFineTune(page, ftState)
await mockQuantize(page, qzState)
return { ftState, qzState }
}
async function fillFineTune(page) {
await page.fill('#ft-model', MODEL)
await page.fill('#ft-dataset', 'tatsu-lab/alpaca')
}
test.describe('Fine-tune: set up and check', () => {
test('starts empty: Set up is current, Start is off, and the check says what is missing', async ({ page }) => {
await boot(page)
await page.goto('/app/fine-tune')
await expect(page.getByTestId('fine-tune-form')).toBeVisible()
await expectStep(page, 'setup', 'current')
await expect(page.getByTestId('ft-start')).toBeDisabled()
await expect(check(page, 'model')).toHaveAttribute('data-tone', 'fail')
await expect(check(page, 'data')).toContainText('Add a dataset')
await expect(page.getByTestId('ft-bar')).toContainText('Enter the model to train.')
await expect(page.getByTestId('ft-recipe')).toContainText('Choose a model and a dataset')
})
test('a model and a dataset move to Check and switch Start on', async ({ page }) => {
await boot(page)
await page.goto('/app/fine-tune')
await fillFineTune(page)
await expectStep(page, 'check', 'current')
await expect(page.getByTestId('ft-start')).toBeEnabled()
await expect(check(page, 'model')).toHaveAttribute('data-tone', 'ok')
await expect(check(page, 'data')).toContainText('Data comes from tatsu-lab/alpaca')
await expect(check(page, 'backend')).toContainText('trl is installed')
await expect(check(page, 'memory')).toContainText('RTX 4070: 9.8 GB of 12 GB free')
await expect(check(page, 'memory')).toContainText('LocalAI does not estimate it before the job starts')
await expect(check(page, 'memory').getByRole('img')).toHaveAttribute('aria-label', /18 percent in use/)
await expect(check(page, 'disk')).toContainText('118 GB free on the models disk')
await expect(page.getByTestId('ft-recipe')).toContainText('Train a LoRA adapter of TinyLlama-1.1B-Chat-v1.0 with SFT, 3 epochs over tatsu-lab/alpaca.')
await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.')
})
test('the check list redraws as the form changes', async ({ page }) => {
await boot(page)
await page.goto('/app/fine-tune')
await fillFineTune(page)
await expect(check(page, 'memory')).toContainText('Batch size 2')
await page.fill('#ft-batch', '4')
await expect(check(page, 'memory')).toContainText('Batch size 4')
await page.fill('#ft-epochs', '5')
await expect(page.getByTestId('ft-recipe')).toContainText('5 epochs')
await page.getByRole('radio', { name: /Full model/ }).check()
await expect(page.getByTestId('ft-recipe')).toContainText('full model')
// GRPO needs a reward function: a warning that goes when one is chosen.
await page.getByTestId('ft-more-toggle').click()
await page.selectOption('#ft-method', 'grpo')
await expect(check(page, 'reward')).toHaveAttribute('data-tone', 'warn')
await expect(page.getByTestId('ft-bar')).toContainText('1 warning. You can still start.')
await expect(page.getByTestId('ft-start')).toBeEnabled()
await page.getByLabel('format_reward').check()
await expect(check(page, 'reward')).toHaveCount(0)
await expect(page.getByTestId('ft-bar')).toContainText('Nothing blocks the start.')
})
test('with no GPU the memory check warns and names the RAM', async ({ page }) => {
await boot(page, { host: CPU_HOST })
await page.goto('/app/fine-tune')
await fillFineTune(page)
await expect(check(page, 'memory')).toHaveAttribute('data-tone', 'warn')
await expect(check(page, 'memory')).toContainText('No GPU found. Training uses the CPU and 20 GB of 32 GB RAM is free.')
})
test('with no backend installed it warns, links to Backends, and still lets you start', async ({ page }) => {
await boot(page, { ft: { backends: [] } })
await page.goto('/app/fine-tune')
await fillFineTune(page)
await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn')
await expect(check(page, 'backend')).toContainText('No fine-tuning backend is installed')
await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toHaveAttribute('href', '/app/backends')
await expect(page.getByTestId('ft-start')).toBeEnabled()
})
test('a user who cannot read the machine still gets a check list', async ({ page }) => {
await boot(page, { host: null })
await page.goto('/app/fine-tune')
await fillFineTune(page)
await expect(check(page, 'memory')).toContainText('This account cannot read the machine')
await expect(check(page, 'disk')).toHaveCount(0)
})
test('Start sends the request the form describes and opens the job', async ({ page }) => {
const { ftState } = await boot(page)
await page.goto('/app/fine-tune')
await fillFineTune(page)
await page.fill('#ft-split', 'train')
await page.fill('#ft-lr', '5e-5')
await page.getByTestId('ft-start').click()
await expect(page.getByTestId('job-view')).toBeVisible()
expect(ftState.started).toHaveLength(1)
expect(ftState.started[0]).toMatchObject({
model: MODEL, backend: 'trl', training_method: 'sft', training_type: 'lora', dataset_source: 'tatsu-lab/alpaca',
dataset_split: 'train', num_epochs: 3, batch_size: 2, learning_rate: 0.00005, adapter_rank: 16,
})
expect(ftState.started[0].extra_options).toMatchObject({ max_seq_length: '2048', eval_strategy: 'no' })
await expectStep(page, 'run', 'current')
})
test('an uploaded file is the dataset, and is sent to the upload endpoint first', async ({ page }) => {
const { ftState } = await boot(page)
await page.goto('/app/fine-tune')
await page.fill('#ft-model', MODEL)
await page.setInputFiles('#ft-dataset-file', { name: 'pairs.jsonl', mimeType: 'application/json', buffer: Buffer.from('{"text":"a"}\n') })
await expect(check(page, 'data')).toContainText('pairs.jsonl')
await page.getByTestId('ft-start').click()
await expect(page.getByTestId('job-view')).toBeVisible()
expect(ftState.started[0].dataset_source).toBe('/data/datasets/upload.jsonl')
})
test('a refused start shows the server message and keeps the form', async ({ page }) => {
await boot(page, { ft: { startError: 'failed to load backend trl: not found' } })
await page.goto('/app/fine-tune')
await fillFineTune(page)
await page.getByTestId('ft-start').click()
await expect(page.getByTestId('tool-error')).toContainText('failed to load backend trl: not found')
await expect(page.getByTestId('fine-tune-form')).toBeVisible()
await expect(page.locator('#ft-model')).toHaveValue(MODEL)
})
test('More options carries the method, adapter and evaluation settings', async ({ page }) => {
await boot(page)
await page.goto('/app/fine-tune')
await expect(page.locator('#ft-method')).toHaveCount(0)
await page.getByTestId('ft-more-toggle').click()
for (const id of ['#ft-backend', '#ft-method', '#ft-type', '#ft-rank', '#ft-optimizer', '#ft-hf-token', '#ft-seq-len']) await expect(page.locator(id)).toBeVisible()
await page.getByRole('switch', { name: 'Evaluate while training' }).click()
await expect(page.locator('#ft-eval-strategy')).toBeVisible()
})
})
test.describe('Fine-tune: run, fail, finish', () => {
const open = async (page) => {
await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click()
await expect(page.getByTestId('job-view')).toBeVisible()
}
test('a running job shows progress, stages, chart and log from its events', async ({ page }) => {
await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } })
await page.goto('/app/fine-tune')
await open(page)
await expectStep(page, 'run', 'current')
const view = page.getByTestId('job-view')
await expect(view.locator('.bt-job__percent')).toContainText('60')
await expect(view).toContainText('step 540 of 900')
await expect(view).toContainText('epoch 1.8 of 3')
await expect(view).toContainText('about 12 min left')
await expect(view).toContainText('312 tokens/s')
await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '60')
await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Training')
// The chart: a line, the evaluation loss as hollow dots, the last value.
await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible()
await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(5)
await expect(page.getByTestId('job-chart')).toContainText('Hollow dots are the evaluation loss')
await page.getByRole('tab', { name: 'Learning rate' }).click()
await expect(page.getByTestId('job-chart').getByRole('heading', { name: 'Learning rate' })).toBeVisible()
await expect(page.getByTestId('job-chart').locator('.bt-chart__eval')).toHaveCount(0)
// The log is what the events said, and the filter shows warnings only.
await expect(page.getByTestId('job-log')).toContainText('step 530/900 loss')
await page.getByTestId('job-log-filter').check()
await expect(page.getByTestId('job-log')).toContainText('No warnings or errors.')
})
test('Stop asks whether to keep a checkpoint and sends the answer', async ({ page }) => {
const { ftState } = await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } })
await page.goto('/app/fine-tune')
await open(page)
await page.getByTestId('job-stop').click()
await expect(page.getByTestId('stop-dialog')).toContainText('Stop training?')
await page.getByTestId('stop-keep').click()
await expect(page.getByTestId('stop-dialog')).toHaveCount(0)
expect(ftState.stopped).toEqual([{ id: 'job-run', save: 'true' }])
await page.getByTestId('job-stop').click()
await page.getByTestId('stop-discard').click()
expect(ftState.stopped[1]).toEqual({ id: 'job-run', save: 'false' })
await page.getByTestId('job-stop').click()
await page.getByRole('button', { name: 'Keep training' }).click()
expect(ftState.stopped).toHaveLength(2)
})
test('a failed job shows the server message, offers the fixes for a memory failure, and applies them to a copy', async ({ page }) => {
const message = 'torch.OutOfMemoryError: CUDA out of memory. Tried to allocate 1.34 GiB.'
await boot(page, {
ft: {
jobs: [ftJob('job-f', 'failed', { message })],
checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: '2026-10-08 10:10' }] },
},
})
await page.goto('/app/fine-tune')
await open(page)
await expectStep(page, 'run', 'failed')
await expect(page.getByTestId('job-failed')).toHaveAttribute('role', 'alert')
await expect(page.getByTestId('job-failed-message')).toHaveText(message)
await expect(page.getByTestId('job-try')).toContainText('Lower the batch size to 1')
await expect(page.getByRole('button', { name: 'Resume from step 200' })).toBeVisible()
await page.getByTestId('job-retry').click()
// Back on the form, with the setup restored and the two fixes applied.
await expect(page.getByTestId('fine-tune-form')).toBeVisible()
await expect(page.locator('#ft-model')).toHaveValue(MODEL)
await expect(page.locator('#ft-batch')).toHaveValue('1')
await page.getByTestId('ft-more-toggle').click()
await expect(page.getByLabel(/Gradient checkpointing/)).toBeChecked()
})
test('a failure that is not about memory gets no invented advice', async ({ page }) => {
await boot(page, { ft: { jobs: [ftJob('job-f', 'failed', { message: 'dataset not found: tatsu-lab/alpaca' })] } })
await page.goto('/app/fine-tune')
await open(page)
await expect(page.getByTestId('job-failed-message')).toHaveText('dataset not found: tatsu-lab/alpaca')
await expect(page.getByTestId('job-try')).not.toContainText('batch size')
await expect(page.getByTestId('job-try')).toContainText('change what the message points at')
})
test('Resume from a checkpoint puts it in the form', async ({ page }) => {
await boot(page, {
ft: {
jobs: [ftJob('job-f', 'failed', { message: 'stopped' })],
checkpoints: { 'job-f': [{ path: '/data/finetune/job-f/checkpoint-200', step: 200, epoch: 0.66, loss: 1.4012, created_at: 'x' }] },
},
})
await page.goto('/app/fine-tune')
await open(page)
await page.getByRole('button', { name: 'Resume from step 200' }).click()
await expect(page.getByText('Resuming from checkpoint:')).toBeVisible()
await expect(page.getByTestId('fine-tune-form')).toContainText('/data/finetune/job-f/checkpoint-200')
await expect(page.getByTestId('ft-start')).toContainText('Resume training')
})
test('a finished job offers the export, and an exported model leads to Chat and Models', async ({ page }) => {
const { ftState } = await boot(page, {
ft: {
jobs: [ftJob('job-d', 'completed')],
checkpoints: { 'job-d': [{ path: '/data/finetune/job-d/checkpoint-500', step: 500, epoch: 1.66, loss: 0.8911, created_at: 'x' }] },
},
})
await page.goto('/app/fine-tune')
await open(page)
await expectStep(page, 'result', 'current')
await expect(page.getByTestId('job-finished')).toContainText('Training finished.')
await expect(page.getByTestId('job-checkpoints')).toContainText('1.4012'.replace('1.4012', '0.8911'))
await page.fill('#ft-export-name', 'tinyllama-ft')
await page.selectOption('#ft-export-format', 'gguf')
await page.fill('#ft-export-quant', 'q5_k_m')
await page.getByRole('region', { name: 'Export as a model' }).getByRole('button', { name: 'Export', exact: true }).click()
expect(ftState.exported[0]).toMatchObject({ name: 'tinyllama-ft', export_format: 'gguf', quantization_method: 'q5_k_m', checkpoint_path: '/data/finetune/job-d', model: MODEL })
})
test('an exported job links to Chat, Models and the archive', async ({ page }) => {
await boot(page, { ft: { jobs: [ftJob('job-d', 'completed', { export_status: 'completed', export_model_name: 'tinyllama-ft' })] } })
await page.goto('/app/fine-tune')
await open(page)
const next = page.getByTestId('export-next')
await expect(next).toContainText('tinyllama-ft is a model now')
await expect(next.getByRole('link', { name: 'Chat with tinyllama-ft' })).toHaveAttribute('href', '/app/chat/tinyllama-ft')
await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed')
await expect(next.getByRole('link', { name: 'Download archive' })).toHaveAttribute('href', /jobs\/job-d\/download/)
})
test('New job returns to the form', async ({ page }) => {
await boot(page, { ft: { jobs: [ftJob('job-d', 'completed')] } })
await page.goto('/app/fine-tune')
await open(page)
await page.getByTestId('job-back').click()
await expect(page.getByTestId('fine-tune-form')).toBeVisible()
})
})
test.describe('Fine-tune: earlier jobs', () => {
test('lists jobs with kind and status, reuses a setup and deletes after a confirmation', async ({ page }) => {
const { ftState } = await boot(page, {
ft: { jobs: [ftJob('job-1', 'completed', { model: 'Qwen2.5-0.5B', training_method: 'dpo', config: { model: 'Qwen2.5-0.5B', dataset_source: 'x/y', training_method: 'dpo', batch_size: 3 } }), ftJob('job-2', 'failed', { model: 'gemma-2-2b', message: 'unsupported' })] },
})
await page.goto('/app/fine-tune')
const jobs = page.getByTestId('ft-jobs')
await expect(jobs).toContainText('2 kept on this server')
await expect(jobs.locator('tbody tr')).toHaveCount(2)
await expect(jobs.locator('tbody tr').first()).toContainText('lora, dpo')
await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported')
await jobs.locator('tbody tr').first().getByRole('button', { name: 'Reuse' }).click()
await expect(page.locator('#ft-model')).toHaveValue('Qwen2.5-0.5B')
await expect(page.locator('#ft-dataset')).toHaveValue('x/y')
await expect(page.locator('#ft-batch')).toHaveValue('3')
await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click()
await expect(page.getByRole('alertdialog')).toContainText('Delete this job?')
await page.getByRole('button', { name: 'Cancel' }).click()
expect(ftState.deleted).toEqual([])
await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click()
await page.getByTestId('delete-confirm').click()
await expect.poll(() => ftState.deleted).toEqual(['job-2'])
})
test('with no jobs it names the next step', async ({ page }) => {
await boot(page)
await page.goto('/app/fine-tune')
await expect(page.getByTestId('ft-jobs').locator('.dk-empty-title')).toHaveText('No fine-tuning jobs yet')
})
})
test.describe('Quantize', () => {
test('setup: a model is needed, the type can be custom, and the check lists the facts', async ({ page }) => {
await boot(page)
await page.goto('/app/quantize')
await expectStep(page, 'setup', 'current')
await expect(page.getByTestId('qz-start')).toBeDisabled()
await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B')
await expectStep(page, 'check', 'current')
await expect(page.getByTestId('qz-start')).toContainText('Quantize (q4_k_m)')
await expect(check(page, 'backend')).toContainText('llama-cpp-quantization is installed')
await expect(check(page, 'memory')).toContainText('20 GB of 32 GB RAM is free. Conversion runs on the CPU.')
await expect(check(page, 'disk')).toContainText('118 GB free on the models disk')
await expect(page.getByTestId('qz-recipe')).toContainText('Convert Llama-3.2-1B to GGUF at q4_k_m.')
await page.selectOption('#qz-type', 'q8_0')
await expect(page.getByTestId('qz-recipe')).toContainText('at q8_0')
await page.selectOption('#qz-type', '__custom__')
await expect(check(page, 'type')).toHaveAttribute('data-tone', 'fail')
await expect(page.getByTestId('qz-start')).toBeDisabled()
await page.fill('#qz-custom', 'iq4_nl')
await expect(page.getByTestId('qz-start')).toContainText('Quantize (iq4_nl)')
await expect(page.getByTestId('qz-start')).toBeEnabled()
})
test('no backend: a warning and a link, and the start stays possible', async ({ page }) => {
await boot(page, { qz: { backends: [] } })
await page.goto('/app/quantize')
await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B')
await expect(check(page, 'backend')).toHaveAttribute('data-tone', 'warn')
await expect(check(page, 'backend').getByRole('link', { name: 'Open Backends' })).toBeVisible()
await expect(page.getByTestId('qz-start')).toBeEnabled()
})
test('Start sends the request, with the token under extra options, and opens the job', async ({ page }) => {
const { qzState } = await boot(page)
await page.goto('/app/quantize')
await page.fill('#qz-model', 'meta-llama/Llama-3.2-1B')
await page.getByRole('button', { name: /More options/ }).click()
await page.fill('#qz-token', 'hf_secret')
await page.getByTestId('qz-start').click()
await expect(page.getByTestId('job-view')).toBeVisible()
expect(qzState.started[0]).toEqual({ model: 'meta-llama/Llama-3.2-1B', backend: 'llama-cpp-quantization', quantization_type: 'q4_k_m', extra_options: { hf_token: 'hf_secret' } })
await expectStep(page, 'run', 'current')
})
test('a running job shows the stage, progress and log, and Stop sends the stop', async ({ page }) => {
const events = [{ job_id: 'qz1', status: 'downloading', progress_percent: 10, message: 'Downloading model files' }, { job_id: 'qz1', status: 'converting', progress_percent: 42, message: 'converting tensor 84 of 201' }]
const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz1', 'converting')], events: { qz1: events } } })
await page.goto('/app/quantize')
await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click()
const view = page.getByTestId('job-view')
await expect(view.locator('.bt-job__percent')).toContainText('42')
await expect(view.getByRole('progressbar', { name: 'Progress' })).toHaveAttribute('aria-valuenow', '42')
await expect(view).toContainText('converting tensor 84 of 201')
await expect(page.locator('[data-testid="job-stages"] [data-state="current"]')).toHaveText('Converting')
await expect(page.locator('[data-testid="job-stages"] [data-state="done"]')).toHaveCount(2)
await expect(page.getByTestId('job-log')).toContainText('Downloading model files')
await page.getByTestId('job-stop').click()
expect(qzState.stopped).toEqual(['qz1'])
})
test('a failed job shows the server message and reuses its setup', async ({ page }) => {
await boot(page, { qz: { jobs: [qzJob('qz9', 'failed', { model: 'acme/odd-model', quantization_type: 'q5_k_m', message: 'unsupported architecture: OddForCausalLM' })] } })
await page.goto('/app/quantize')
await page.getByTestId('qz-jobs').getByRole('button', { name: 'acme/odd-model' }).click()
await expectStep(page, 'run', 'failed')
await expect(page.getByTestId('job-failed-message')).toHaveText('unsupported architecture: OddForCausalLM')
await page.getByTestId('job-retry').click()
await expect(page.locator('#qz-model')).toHaveValue('acme/odd-model')
await expect(page.locator('#qz-type')).toHaveValue('q5_k_m')
})
test('a finished job imports under a name and then leads to Chat and Models', async ({ page }) => {
const { qzState } = await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/model-q4_k_m.gguf' })] } })
await page.goto('/app/quantize')
await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click()
await expectStep(page, 'result', 'current')
await expect(page.getByTestId('job-finished')).toContainText('Quantization finished: q4_k_m.')
await expect(page.getByTestId('quantize-output')).toContainText('/data/quant/qz2/model-q4_k_m.gguf')
await expect(page.getByRole('link', { name: 'Download GGUF' })).toHaveAttribute('href', /jobs\/qz2\/download/)
await page.fill('#qz-import-name', 'llama-1b-q4')
await page.getByTestId('quantize-import').click()
const next = page.getByTestId('quantize-next')
await expect(next).toContainText('llama-1b-q4 is a model now')
expect(qzState.imported).toEqual([{ name: 'llama-1b-q4' }])
await expect(next.getByRole('link', { name: 'Chat with llama-1b-q4' })).toHaveAttribute('href', '/app/chat/llama-1b-q4')
await expect(next.getByRole('link', { name: 'Open in Models' })).toHaveAttribute('href', '/app/models?view=installed')
})
test('earlier jobs: imported mark, failure line, delete after a confirmation', async ({ page }) => {
const { qzState } = await boot(page, {
qz: { jobs: [qzJob('qz0', 'completed', { import_status: 'completed', import_model_name: 'llama-3.2-1b-q4_k_m' }), qzJob('qz9', 'failed', { model: 'acme/odd-model', message: 'unsupported architecture' })] },
})
await page.goto('/app/quantize')
const jobs = page.getByTestId('qz-jobs')
await expect(jobs.locator('tbody tr').first()).toContainText('imported as llama-3.2-1b-q4_k_m')
await expect(jobs.locator('tbody tr').nth(1)).toContainText('unsupported architecture')
await jobs.locator('tbody tr').nth(1).getByRole('button', { name: 'Delete' }).click()
await page.getByTestId('delete-confirm').click()
await expect.poll(() => qzState.deleted).toEqual(['qz9'])
})
test('with no jobs it names the next step', async ({ page }) => {
await boot(page)
await page.goto('/app/quantize')
await expect(page.getByTestId('qz-jobs').locator('.dk-empty-title')).toHaveText('No quantization jobs yet')
})
})
test.describe('Disabled: the account lacks the permission', () => {
const member = (permissions) => (page) => page.route('**/api/auth/status', route => route.fulfill({
json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions } },
}))
test('Fine-tune says the account cannot use it, who can change that, and where to go', async ({ page }) => {
await boot(page)
await member({ chat: true })(page)
await page.goto('/app/fine-tune')
const off = page.getByTestId('tool-off')
await expect(off).toHaveAttribute('data-cause', 'account')
await expect(off).toContainText('Your account cannot fine-tune')
await expect(off).toContainText('Ask an administrator')
await expect(off.getByRole('link', { name: 'Go to Home' })).toHaveAttribute('href', '/app')
await expect(page.getByTestId('fine-tune-form')).toHaveCount(0)
})
test('Quantize has its own words', async ({ page }) => {
await boot(page)
await member({ chat: true, fine_tuning: true })(page)
await page.goto('/app/quantize')
await expect(page.getByTestId('tool-off')).toContainText('Your account cannot quantize')
await page.goto('/app/fine-tune')
await expect(page.getByTestId('fine-tune-form')).toBeVisible()
})
test('an administrator is never shown the page', async ({ page }) => {
await boot(page)
await page.goto('/app/fine-tune')
await expect(page.getByTestId('fine-tune-form')).toBeVisible()
await expect(page.getByTestId('tool-off')).toHaveCount(0)
})
})
test.describe('Build tools: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
const noOverflow = (page) => page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
test('Fine-tune setup and check fit the screen', async ({ page }) => {
await boot(page, { ft: { jobs: [ftJob('job-1', 'completed'), ftJob('job-2', 'failed', { message: 'x' })] } })
await page.goto('/app/fine-tune')
await fillFineTune(page)
await page.getByTestId('ft-more-toggle').click()
await expect(check(page, 'memory')).toBeVisible()
expect(await noOverflow(page)).toBe(false)
await expect(page.getByTestId('ft-start')).toBeVisible()
})
test('a running Fine-tune job fits the screen', async ({ page }) => {
await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(540) } } })
await page.goto('/app/fine-tune')
await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click()
await expect(page.getByTestId('job-chart').locator('.bt-chart__line')).toBeVisible()
expect(await noOverflow(page)).toBe(false)
})
test('Quantize finished fits the screen', async ({ page }) => {
await boot(page, { qz: { jobs: [qzJob('qz2', 'completed', { output_file: '/data/quant/qz2/a-very-long-file-name-q4_k_m.gguf' })] } })
await page.goto('/app/quantize')
await page.getByTestId('qz-jobs').getByRole('button', { name: 'meta-llama/Llama-3.2-1B' }).click()
await expect(page.getByTestId('quantize-output')).toBeVisible()
expect(await noOverflow(page)).toBe(false)
})
test('the disabled page fits the screen', async ({ page }) => {
await boot(page)
await page.route('**/api/auth/status', route => route.fulfill({
json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions: { chat: true } } },
}))
await page.goto('/app/quantize')
await expect(page.getByTestId('tool-off')).toBeVisible()
expect(await noOverflow(page)).toBe(false)
})
})
test.describe('Build tools: reduced motion', () => {
test('the job card does not animate', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await boot(page, { ft: { jobs: [ftJob('job-run', 'training')], events: { 'job-run': ftEvents(100) } } })
await page.goto('/app/fine-tune')
await page.getByTestId('ft-jobs').getByRole('button', { name: MODEL }).click()
const duration = await page.getByTestId('job-view').locator('.bt-job').evaluate(el => getComputedStyle(el).animationDuration)
expect(parseFloat(duration)).toBeLessThan(0.01)
})
})
@@ -49,8 +49,8 @@ test.describe('Canonical resource navigation', () => {
await expect(sidebar.getByRole('link', { name: 'Discover', exact: true })).toHaveCount(0)
await sidebar.getByRole('link', { name: 'Operate', exact: true }).click()
const operateRail = page.locator('.console-rail')
await expect(operateRail.getByRole('link', { name: /Backends/ })).toBeVisible()
await expect(operateRail.getByRole('link', { name: /Host/ })).toHaveCount(0)
const operateBar = page.locator('.dk-hubtabs')
await expect(operateBar.getByRole('link', { name: /Runtime/ })).toBeVisible()
await expect(operateBar.getByRole('link', { name: /Host/ })).toHaveCount(0)
})
})
@@ -0,0 +1,184 @@
import { test, expect } from './coverage-fixtures.js'
import { chatOf, pair, mockChat, openChat, GO_BLOCK } from './chat-fixtures.js'
// The canvas opens on demand beside the thread, find marks matches in the
// messages that are loaded, and a long thread has a way back to the end.
const CODE_THREAD = chatOf('c1', 'Code', 'qwen3-8b', [
{ role: 'user', content: 'write the handler and a page' },
{
role: 'assistant',
content: 'First the handler:\n\n```go\n' + GO_BLOCK + '\n```\n\nThen the page:\n\n```html\n<h1>Hello canvas</h1>\n```\n\nThat is all.',
},
])
const canvas = (page) => page.getByTestId('canvas-panel')
test.describe('Chat canvas', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page)
})
test('the Canvas button on a code block opens that block in the panel', async ({ page }) => {
await openChat(page, [CODE_THREAD])
await expect(canvas(page)).toHaveCount(0)
await page.locator('.code-block').nth(1).getByRole('button', { name: 'Canvas' }).click()
await expect(canvas(page)).toBeVisible()
// Canvas mode is on now, and the block that was clicked is the one shown.
await expect(page.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'true')
await expect(canvas(page).getByRole('tab', { selected: true })).toContainText('.html')
await expect(canvas(page).getByRole('button', { name: 'Preview' })).toHaveAttribute('aria-pressed', 'true')
await expect(canvas(page).frameLocator('iframe').getByRole('heading', { name: 'Hello canvas' })).toBeVisible()
})
test('with Canvas on, code blocks are cards and a card opens the panel with tabs', async ({ page }) => {
await openChat(page, [CODE_THREAD])
await page.getByTestId('chat-canvas-chip').click()
// Turning it on opens the panel on the newest block; the blocks are cards now.
await expect(canvas(page)).toBeVisible()
await expect(page.locator('.artifact-card')).toHaveCount(2)
await expect(canvas(page).getByRole('tab')).toHaveCount(2)
await canvas(page).getByRole('tab', { name: /main.go/ }).click()
await expect(canvas(page)).toContainText('context.WithCancel')
// Code and Preview switch for html.
await canvas(page).getByRole('tab', { name: /\.html/ }).click()
await canvas(page).getByRole('button', { name: 'Code' }).click()
await expect(canvas(page)).toContainText('<h1>Hello canvas</h1>')
await expect(canvas(page).getByRole('button', { name: 'Copy' })).toBeVisible()
await expect(canvas(page).getByRole('button', { name: 'Download' })).toBeVisible()
await canvas(page).getByRole('button', { name: 'Close canvas' }).click()
await expect(canvas(page)).toHaveCount(0)
// The chip carries the count and opens it again.
await page.getByRole('button', { name: 'Open canvas panel' }).click()
await expect(canvas(page)).toBeVisible()
})
test('Esc closes the canvas', async ({ page }) => {
await openChat(page, [CODE_THREAD])
await page.getByTestId('chat-canvas-chip').click()
await expect(canvas(page)).toBeVisible()
await page.locator('.cx-body').click({ position: { x: 5, y: 5 } })
await page.keyboard.press('Escape')
await expect(canvas(page)).toHaveCount(0)
})
test('the panel is wide beside the thread on a desktop window', async ({ page }) => {
await openChat(page, [CODE_THREAD])
await page.getByTestId('chat-canvas-chip').click()
const panel = await canvas(page).boundingBox()
const view = page.viewportSize()
expect(panel.width).toBeGreaterThan(view.width * 0.3)
// The thread keeps its own room beside it.
const thread = await page.getByTestId('chat-thread').boundingBox()
expect(thread.x + thread.width).toBeLessThanOrEqual(panel.x + 1)
})
test('on a phone the canvas takes the whole page', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await openChat(page, [CODE_THREAD])
await page.getByTestId('chat-canvas-chip').click()
const box = await canvas(page).boundingBox()
expect(box.width).toBeGreaterThanOrEqual(389)
expect(box.height).toBeGreaterThan(500)
await canvas(page).getByRole('button', { name: 'Close canvas' }).click()
await expect(canvas(page)).toHaveCount(0)
})
})
test.describe('Chat find', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page)
})
const THREAD = chatOf('c1', 'Find', 'qwen3-8b', [
...pair('Why do goroutines leak?', 'Because nothing watches the context. Each goroutine waits forever, and the goroutine count grows.'),
...pair('And the fix?', 'Select on ctx.Done() in every goroutine.'),
])
test('Ctrl+Shift+F searches the loaded messages, shows n of m and steps with Enter', async ({ page }) => {
let completions = 0
await page.route('**/v1/chat/completions', (route) => { completions++; route.abort() })
await openChat(page, [THREAD])
await page.keyboard.press('Control+Shift+F')
const input = page.getByTestId('chat-find-input')
await expect(input).toBeFocused()
await input.fill('goroutine')
const count = page.getByTestId('chat-find-count')
await expect(count).toHaveText('1 of 4')
await expect(page.locator('mark.cx-hit')).toHaveCount(4)
await expect(page.locator('mark.cx-hit[data-cur]')).toHaveCount(1)
await expect(page.locator('mark.cx-hit').first()).toHaveAttribute('data-cur', '')
await input.press('Enter')
await expect(count).toHaveText('2 of 4')
await expect(page.locator('mark.cx-hit').nth(1)).toHaveAttribute('data-cur', '')
await input.press('Shift+Enter')
await input.press('Shift+Enter')
await expect(count).toHaveText('4 of 4')
// Case does not matter, and a miss says so.
await input.fill('CTX.DONE')
await expect(count).toHaveText('1 of 1')
await input.fill('nothing like this')
await expect(count).toHaveText('No matches')
await expect(page.locator('mark.cx-hit')).toHaveCount(0)
// The search never leaves the page.
expect(completions).toBe(0)
})
test('Esc closes the search, clears the marks and puts the cursor back in the box', async ({ page }) => {
await openChat(page, [THREAD])
await page.getByTestId('chat-find-button').click()
await page.getByTestId('chat-find-input').fill('context')
await expect(page.locator('mark.cx-hit')).not.toHaveCount(0)
await page.keyboard.press('Escape')
await expect(page.getByTestId('chat-find')).toHaveCount(0)
await expect(page.locator('mark.cx-hit')).toHaveCount(0)
await expect(page.getByTestId('chat-input')).toBeFocused()
// The text of the messages is whole again.
await expect(page.locator('[data-role="assistant"]').first()).toContainText('Because nothing watches the context.')
})
test('it finds text inside code blocks and in your own messages', async ({ page }) => {
await openChat(page, [CODE_THREAD])
await page.keyboard.press('Control+Shift+F')
await page.getByTestId('chat-find-input').fill('write the')
await expect(page.locator('[data-role="user"] mark.cx-hit')).toHaveCount(1)
await page.getByTestId('chat-find-input').fill('WithCancel')
await expect(page.locator('.code-block mark.cx-hit')).toHaveCount(1)
})
})
test.describe('Chat long threads', () => {
test('Jump to latest appears when you leave the end and takes you back', async ({ page }) => {
await mockChat(page)
const history = []
for (let i = 1; i <= 40; i++) history.push(...pair(`Question ${i} about context sizes`, `Answer ${i}. The same advice applies each time, so keep going.`))
await openChat(page, [chatOf('c1', 'Long', 'qwen3-8b', history)])
const body = page.locator('.cx-body')
await expect(page.getByTestId('chat-message')).toHaveCount(80)
await expect(page.getByTestId('chat-jump-latest')).toHaveCount(0)
await body.evaluate(el => { el.scrollTop = 0 })
const jump = page.getByTestId('chat-jump-latest')
await expect(jump).toBeVisible()
await jump.click()
await expect(jump).toHaveCount(0)
await expect.poll(() => body.evaluate(el => el.scrollHeight - el.scrollTop - el.clientHeight)).toBeLessThan(80)
})
test('a long thread does not push the page: the thread scrolls inside the window', async ({ page }) => {
await mockChat(page)
const history = []
for (let i = 1; i <= 40; i++) history.push(...pair(`Question ${i}`, `Answer ${i}`))
await openChat(page, [chatOf('c1', 'Long', 'qwen3-8b', history)])
await expect(page.getByTestId('chat-message')).toHaveCount(80)
const overflow = await page.evaluate(() => document.documentElement.scrollHeight - window.innerHeight)
expect(overflow).toBeLessThanOrEqual(1)
// The composer stays in view.
await expect(page.getByTestId('chat-input')).toBeInViewport()
})
})
@@ -0,0 +1,226 @@
import { test, expect } from './coverage-fixtures.js'
import {
chatOf, pair, mockChat, openChat, storedChats, answerWith, PNG,
} from './chat-fixtures.js'
// The Chat composer is the Home command bar: the model chip, the MCP chip, a
// Canvas chip, attach buttons, a solid Send and the slash menu. These specs
// check that it is the same object and that what it adds for a conversation
// (Stop, Up to edit, the token line) works.
const input = (page) => page.getByTestId('chat-input')
test.describe('Chat composer', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page)
})
test('is the Home command bar, with the Canvas chip and the hint line', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Bar', 'qwen3-8b', pair('hi', 'hello'))])
const bar = page.getByTestId('chat-composer')
await expect(bar).toHaveClass(/home-cmd/)
await expect(bar.getByTestId('home-model-chip')).toContainText('qwen3-8b')
await expect(bar.locator('.chat-mcp-dropdown')).toBeVisible()
await expect(bar.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'false')
await expect(bar.getByRole('button', { name: 'Attach image' })).toBeVisible()
await expect(bar.getByRole('button', { name: 'Attach audio' })).toBeVisible()
await expect(bar.getByRole('button', { name: 'Attach file' })).toBeVisible()
await expect(bar).toContainText('Enter to send, Shift+Enter for a new line')
await expect(bar).toContainText('for actions')
})
test('Send is quiet and disabled when empty, active with text', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Send', 'qwen3-8b', [])])
const send = page.getByTestId('chat-send')
await expect(send).toBeDisabled()
await expect(send).toHaveAttribute('data-empty', 'true')
await input(page).fill('hello')
await expect(send).toBeEnabled()
await expect(send).not.toHaveAttribute('data-empty', 'true')
})
test('Enter sends, Shift+Enter adds a line, and Ctrl, Cmd and Alt Enter do not send', async ({ page }) => {
const answer = await answerWith(page, 'ok')
await openChat(page, [chatOf('c1', 'Keys', 'qwen3-8b', [])])
await input(page).fill('one')
await input(page).press('Shift+Enter')
await expect(input(page)).toHaveValue('one\n')
for (const combo of ['Control+Enter', 'Meta+Enter', 'Alt+Enter']) await input(page).press(combo)
await expect(page.locator('[data-role="user"]')).toHaveCount(0)
expect(answer.request()).toBeNull()
await input(page).press('Enter')
await expect(page.locator('[data-role="user"]')).toContainText('one')
expect(answer.request().messages.at(-1).content).toBe('one')
})
test('the settings the chat holds go out with the message', async ({ page }) => {
const answer = await answerWith(page, 'ok')
await openChat(page, [chatOf('c1', 'Params', 'qwen3-8b', [], { temperature: 0.3, topP: 0.8, topK: 20, systemPrompt: 'Be brief.' })])
await input(page).fill('go')
await page.keyboard.press('Enter')
await expect(page.locator('[data-role="assistant"]')).toContainText('ok')
const body = answer.request()
expect(body).toMatchObject({ model: 'qwen3-8b', temperature: 0.3, top_p: 0.8, top_k: 20, stream: true })
expect(body.messages[0]).toEqual({ role: 'system', content: 'Be brief.' })
})
test('the slash menu lists the chat actions in two groups and filters as you type', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))])
await input(page).fill('/')
const list = page.getByRole('listbox', { name: 'Actions' })
await expect(list).toBeVisible()
await expect(list.locator('.dk-cmd-label')).toHaveText(['Chat', 'View'])
const names = await list.locator('.dk-cmd-name').allTextContents()
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/find', '/settings', '/export', '/clear'])
await input(page).fill('/set')
await expect(list.locator('.dk-cmd-name')).toHaveText(['/settings'])
await input(page).fill('/zzz')
await expect(list).toHaveCount(0)
// A message that merely starts with a slash is a message.
await input(page).fill('/etc/hosts how do I read it')
await expect(list).toHaveCount(0)
})
test('arrow keys, Enter, Tab and Esc work in the slash menu', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))])
await input(page).fill('/')
await input(page).press('ArrowDown')
await expect(page.getByRole('option', { selected: true })).toContainText('/new')
await input(page).press('ArrowUp')
await expect(page.getByRole('option', { selected: true })).toContainText('/model')
await input(page).press('Escape')
await expect(page.getByRole('listbox', { name: 'Actions' })).toHaveCount(0)
// Escape closes the menu and leaves the text.
await expect(input(page)).toHaveValue('/')
})
test('/new starts an empty chat, /canvas toggles Canvas, /settings opens the sheet', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))])
await input(page).fill('/canvas')
await input(page).press('Enter')
await expect(page.getByTestId('chat-canvas-chip')).toHaveAttribute('aria-pressed', 'true')
await expect(input(page)).toHaveValue('')
await input(page).fill('/settings')
await input(page).press('Enter')
await expect(page.getByTestId('chat-settings')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByTestId('chat-settings')).toHaveCount(0)
await input(page).fill('/new')
await input(page).press('Enter')
await expect(page.getByTestId('chat-empty')).toBeVisible()
await expect.poll(async () => (await storedChats(page)).chats.length).toBe(2)
})
test('/chats opens the conversations, /find opens the search, /clear asks first', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))])
await input(page).fill('/chats')
await input(page).press('Enter')
await expect(page.getByTestId('chats-menu')).toBeVisible()
await page.keyboard.press('Escape')
await input(page).fill('/find')
await input(page).press('Enter')
await expect(page.getByTestId('chat-find')).toBeVisible()
await page.getByTestId('chat-find-input').press('Escape')
await input(page).fill('/clear')
await input(page).press('Enter')
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Clear this conversation')
await dialog.getByRole('button', { name: 'Clear conversation' }).click()
await expect(page.getByTestId('chat-empty')).toBeVisible()
await expect.poll(async () => (await storedChats(page)).chats[0].history.length).toBe(0)
})
test('/export downloads the chat as Markdown', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Slash export', 'qwen3-8b', pair('hi', 'hello'))])
const download = page.waitForEvent('download')
await input(page).fill('/export')
await input(page).press('Enter')
expect((await download).suggestedFilename()).toBe('Slash_export.md')
})
test('the actions that need a thread are hidden in an empty chat', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Empty', 'qwen3-8b', [])])
await input(page).fill('/')
const names = await page.getByRole('listbox', { name: 'Actions' }).locator('.dk-cmd-name').allTextContents()
expect(names).toEqual(['/model', '/new', '/chats', '/assistant', '/canvas', '/settings'])
})
test('"/" from outside a text field starts a command', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Slash', 'qwen3-8b', pair('hi', 'hello'))])
await page.locator('.cx-body').click({ position: { x: 5, y: 5 } })
await page.keyboard.press('/')
await expect(input(page)).toBeFocused()
await expect(input(page)).toHaveValue('/')
await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible()
})
test('an attached image shows as a thumbnail tag and can be removed', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Attach', 'gemma-3-12b', [])])
await page.locator('input[type=file][accept="image/*"]').setInputFiles({
name: 'pixel.png', mimeType: 'image/png', buffer: Buffer.from(PNG.split(',')[1], 'base64'),
})
const tag = page.locator('.home-file-tag', { hasText: 'pixel.png' })
await expect(tag).toBeVisible()
await expect(tag.locator('img')).toBeVisible()
await expect(page.getByTestId('chat-send')).toBeEnabled()
await tag.getByRole('button', { name: /Remove pixel.png/ }).click()
await expect(tag).toHaveCount(0)
})
test('a pasted image attaches as a file', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Paste', 'gemma-3-12b', [])])
await input(page).focus()
await input(page).evaluate((el, b64) => {
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
const dt = new DataTransfer()
dt.items.add(new File([bytes], 'image.png', { type: 'image/png' }))
el.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }))
}, PNG.split(',')[1])
await expect(page.locator('.home-file-tag', { hasText: 'pasted-image-1.png' })).toBeVisible()
})
test('the line under the box shows the token count against the context size', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Tokens', 'qwen3-8b', pair('hi', 'hello'), {
contextSize: 8192, tokenUsage: { prompt: 2600, completion: 540, total: 3140 },
})])
await expect(page.getByTestId('chat-foot')).toContainText('3140 / 8192 tokens')
await expect(page.getByTestId('chat-context')).toContainText('38%')
})
test('the foot warns when the context is nearly full', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Full', 'qwen3-8b', pair('hi', 'hello'), {
contextSize: 8192, tokenUsage: { prompt: 7000, completion: 400, total: 7400 },
})])
await expect(page.getByTestId('chat-foot')).toContainText('The context is nearly full')
await expect(page.getByTestId('chat-context')).toHaveAttribute('data-warn', 'true')
})
test('Up in an empty box edits your last message', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Up', 'qwen3-8b', [...pair('first question', 'first answer'), ...pair('second question', 'second answer')])])
await input(page).focus()
await input(page).press('ArrowUp')
const user = page.locator('[data-role="user"]').last()
await expect(user.getByRole('textbox')).toHaveValue('second question')
await user.getByRole('textbox').press('Escape')
// With text in the box the key does what it always did.
await input(page).fill('draft')
await input(page).press('ArrowUp')
await expect(page.getByRole('textbox', { name: 'Edit message' })).toHaveCount(0)
})
test('the model chip changes the model of this chat', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Model', 'qwen3-8b', [])])
await page.getByTestId('home-model-chip').click()
await page.getByRole('option', { name: /phi-4-mini/ }).click()
await expect(page.getByTestId('home-model-chip')).toContainText('phi-4-mini')
await expect.poll(async () => (await storedChats(page)).chats[0].model).toBe('phi-4-mini')
})
})
+12 -12
View File
@@ -28,10 +28,10 @@ test.describe('Chat - Error Handling', () => {
await page.goto('/app/chat')
// Wait for the model to be auto-selected (ModelSelector shows model name in button)
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hello')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Hello')
await page.getByTestId('chat-send').click()
await expect(page.getByRole('paragraph').filter({ hasText: 'Model failed to load' })).toBeVisible({ timeout: 10_000 })
})
@@ -50,10 +50,10 @@ test.describe('Chat - Error Handling', () => {
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hello')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Hello')
await page.getByTestId('chat-send').click()
await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed unexpectedly' })).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.chat-error-trace-link')).toBeVisible()
@@ -76,10 +76,10 @@ test.describe('Chat - Error Handling', () => {
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hello')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Hello')
await page.getByTestId('chat-send').click()
await expect(page.getByRole('paragraph').filter({ hasText: 'Backend crashed mid-stream' })).toBeVisible({ timeout: 10_000 })
})
@@ -96,10 +96,10 @@ test.describe('Chat - Error Handling', () => {
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hello')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Hello')
await page.getByTestId('chat-send').click()
await expect(page.getByRole('paragraph').filter({ hasText: 'HTTP 502' })).toBeVisible({ timeout: 10_000 })
})
+146
View File
@@ -0,0 +1,146 @@
// Shared fixtures for the Chat and Talk specs: stubbed API routes, conversations
// written to the browser storage the Chat page reads, and a completion stream
// the spec drives by hand.
import { mockHome, seedChats, CHATS_KEY } from './home-fixtures.js'
export { CHATS_KEY, seedChats }
const GB = 1024 * 1024 * 1024
export const MODELS = [
{ id: 'qwen3-8b', capabilities: ['FLAG_CHAT'] },
{ id: 'gemma-3-12b', capabilities: ['FLAG_CHAT', 'FLAG_VISION'] },
{ id: 'phi-4-mini', capabilities: ['FLAG_CHAT'] },
]
// A bare conversation in the shape useChat stores.
export function chatOf(id, name, model, history, extra = {}, updatedAt = Date.now()) {
return {
id, name, model, history, updatedAt, createdAt: updatedAt - 60_000,
systemPrompt: '', mcpMode: false, mcpServers: [], clientMCPServers: [],
temperature: null, topP: null, topK: null,
tokenUsage: { prompt: 0, completion: 0, total: 0 }, contextSize: null,
...extra,
}
}
export const pair = (q, a) => [{ role: 'user', content: q }, { role: 'assistant', content: a }]
export const GO_BLOCK = 'func main() {\n\tctx, cancel := context.WithCancel(context.Background())\n\tdefer cancel()\n}'
// Stub what Chat reads. Models are the chat models the list offers; `loaded`
// the ones the server holds in memory.
export async function mockChat(page, { models = MODELS, loaded = ['qwen3-8b', 'phi-4-mini'], resources, features } = {}) {
await mockHome(page, {
loaded: loaded.map(id => ({ id, backend: 'llama-cpp' })),
models: models.map(m => m.id),
chatModels: models.map(m => m.id),
...(resources ? { resources } : {}),
...(features ? { features } : {}),
})
// Capabilities with the vision flag, which mockHome leaves out.
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: models } }))
}
export const RESOURCES_24GB = {
type: 'gpu',
gpus: [{ name: 'RTX 4090', vendor: 'nvidia', total_vram: 24 * GB, used_vram: 7.6 * GB }],
ram: { total: 64 * GB, used: 20 * GB, available: 40 * GB },
aggregate: { total_memory: 24 * GB, used_memory: 7.6 * GB, gpu_count: 1, usage_percent: 31 },
}
// One answer in the OpenAI stream format.
export function sse(text, usage = { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 }) {
return `data: ${JSON.stringify({ choices: [{ delta: { content: text } }] })}\n\n`
+ `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: 'stop' }], usage })}\n\n`
+ 'data: [DONE]\n\n'
}
export async function answerWith(page, text) {
let body = null
await page.route('**/v1/chat/completions', (route) => {
body = route.request().postDataJSON()
route.fulfill({ status: 200, contentType: 'text/event-stream', body: sse(text) })
})
return { request: () => body }
}
// Replace fetch for the completion endpoint with a stream the spec drives:
// await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Hi' } }] }))
// await page.evaluate(() => window.__sse.end())
// Abort is honoured, as the real fetch does.
export async function controlStream(page) {
await page.addInitScript(() => {
const orig = window.fetch.bind(window)
const enc = new TextEncoder()
window.__sse = { ctrl: null, calls: 0 }
window.__sse.push = (obj) => window.__sse.ctrl?.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`))
window.__sse.end = () => { window.__sse.ctrl?.enqueue(enc.encode('data: [DONE]\n\n')); window.__sse.ctrl?.close() }
window.fetch = (url, init) => {
if (!String(url).includes('/v1/chat/completions')) return orig(url, init)
window.__sse.calls++
const stream = new ReadableStream({ start(c) { window.__sse.ctrl = c } })
init?.signal?.addEventListener('abort', () => {
try { window.__sse.ctrl.error(new DOMException('aborted', 'AbortError')) } catch { /* already closed */ }
})
return Promise.resolve(new Response(stream, { status: 200, headers: { 'Content-Type': 'text/event-stream' } }))
}
})
}
export async function storedChats(page) {
return page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), CHATS_KEY)
}
// Open Chat with these conversations stored.
export async function openChat(page, chats, active = chats[0]?.id) {
await seedChats(page, chats, active)
await page.goto('/app/chat')
// Keys and clicks only work once the page has mounted its handlers.
await page.getByTestId('chat-composer').waitFor()
}
// A tiny valid PNG, for a message with an image in it.
export const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=='
// The Talk page's WebRTC, faked: the page connects as it would, and the spec
// plays the server's events with window.__talk.emit(event).
export async function fakeRealtime(page, { pipelines = true, micError = null } = {}) {
await page.route('**/api/pipeline-models', route => route.fulfill({
json: pipelines
? [
{ name: 'voice-assistant', voice: 'alloy', vad: 'silero-vad', transcription: 'whisper-large-v3-turbo', llm: 'qwen3-8b', tts: 'kokoro-82m' },
{ name: 'voice-it', voice: 'paola', vad: 'silero-vad', transcription: 'whisper-small', llm: 'gemma-3-12b', tts: 'piper-it' },
]
: [],
}))
await page.route('**/v1/realtime/calls', route => route.fulfill({ json: { sdp: 'v=0' } }))
await page.addInitScript(({ micError }) => {
window.__talk = { dc: null, pc: null, sent: [], hold: false }
class FakePC {
constructor() { this.connectionState = 'new'; this.iceGatheringState = 'complete'; this.localDescription = null; window.__talk.pc = this }
addTrack() {}
createDataChannel() {
const dc = { readyState: 'open', send: (m) => window.__talk.sent.push(JSON.parse(m)), close() {}, onmessage: null }
window.__talk.dc = dc
return dc
}
async createOffer() { return { type: 'offer', sdp: 'v=0' } }
async setLocalDescription(d) { this.localDescription = d }
async setRemoteDescription() {
if (window.__talk.hold) return new Promise(() => {})
this.connectionState = 'connected'
this.onconnectionstatechange && this.onconnectionstatechange()
}
close() { this.connectionState = 'closed' }
getStats() { return Promise.resolve(new Map()) }
}
window.RTCPeerConnection = FakePC
navigator.mediaDevices.getUserMedia = async () => {
if (micError) { const e = new Error('denied'); e.name = micError; throw e }
return new MediaStream()
}
window.__talk.emit = (ev) => window.__talk.dc.onmessage({ data: JSON.stringify(ev) })
window.__talk.fail = () => { window.__talk.pc.connectionState = 'failed'; window.__talk.pc.onconnectionstatechange() }
}, { micError })
}
+215
View File
@@ -0,0 +1,215 @@
import { test, expect } from './coverage-fixtures.js'
import { mockChat, openChat, storedChats, chatOf, pair } from './chat-fixtures.js'
import { sampleChats } from './home-fixtures.js'
// The conversations list opens on Ctrl K. It replaces the permanent history
// pane: groups by day like the Home list, search, resume, rename, delete with
// undo, and the same duplicate, copy and export actions as before.
const menu = (page) => page.getByTestId('chats-menu')
const rows = (page) => menu(page).getByTestId('chats-row')
async function openMenu(page) {
await expect(page.getByTestId('chats-trigger')).toBeVisible()
await page.keyboard.press('Control+k')
await expect(menu(page)).toBeVisible()
// The search box takes focus a moment after the menu opens; keys need it.
await expect(menu(page).getByRole('combobox')).toBeFocused()
}
test.describe('Chat conversations menu', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page, { models: [
{ id: 'qwen3-8b-instruct', capabilities: ['FLAG_CHAT'] },
{ id: 'gemma-4-e4b-it-qat-q4_0', capabilities: ['FLAG_CHAT'] },
{ id: 'qwen2.5-vl-7b-instruct', capabilities: ['FLAG_CHAT'] },
], loaded: ['qwen3-8b-instruct'] })
await openChat(page, sampleChats())
await expect(page.getByTestId('chat-message').first()).toBeVisible()
})
test('opens on Ctrl K, closes on Esc and gives focus back', async ({ page }) => {
const composer = page.getByTestId('chat-input')
await composer.focus()
await openMenu(page)
await expect(menu(page).getByRole('combobox')).toBeFocused()
await page.keyboard.press('Escape')
await expect(menu(page)).toHaveCount(0)
await expect(composer).toBeFocused()
// The button opens it too, and Ctrl K closes it again.
await page.getByTestId('chats-trigger').click()
await expect(menu(page)).toBeVisible()
await page.keyboard.press('Control+k')
await expect(menu(page)).toHaveCount(0)
})
test('groups the chats by day, newest first, with the model and the time', async ({ page }) => {
await openMenu(page)
await expect(page.getByTestId('chats-day-today').getByRole('heading')).toHaveText('Today')
await expect(page.getByTestId('chats-day-yesterday').getByRole('heading')).toHaveText('Yesterday')
await expect(page.getByTestId('chats-day-week').getByRole('heading')).toHaveText('Earlier this week')
await expect(page.getByTestId('chats-day-today').getByTestId('chats-row')).toHaveCount(2)
await expect(rows(page).first()).toContainText('Rewrite the 3.4 release notes for clarity')
await expect(rows(page).first()).toContainText('qwen3-8b-instruct')
// The line under the name is the last thing said.
await expect(rows(page).first()).toContainText('Anytime.')
// The open chat is marked.
await expect(rows(page).first()).toHaveAttribute('data-current', 'true')
})
test('search narrows by name and by what was said, and says when nothing matches', async ({ page }) => {
await openMenu(page)
const search = menu(page).getByRole('combobox')
await search.fill('regex')
await expect(rows(page)).toHaveCount(1)
await expect(rows(page).first()).toContainText('Regex for semver')
// A word that only appears inside a message.
await search.fill('accounting')
await expect(rows(page)).toHaveCount(1)
await expect(rows(page).first()).toContainText('Draft a polite reply about the invoice')
await expect(rows(page).first()).toContainText('accounting')
await search.fill('zzzz')
await expect(rows(page)).toHaveCount(0)
await expect(menu(page)).toContainText('No conversations match your search')
})
test('arrow keys move and Enter resumes that conversation', async ({ page }) => {
await openMenu(page)
await page.keyboard.press('ArrowDown')
await expect(rows(page).nth(1)).toHaveAttribute('aria-selected', 'true')
await page.keyboard.press('Enter')
await expect(menu(page)).toHaveCount(0)
await expect(page.getByTestId('chat-title')).toHaveText('Why does llama-cpp stall at a 4096 context')
await expect.poll(async () => (await storedChats(page)).activeChatId).toBe('c-today-b')
})
test('clicking a row resumes it', async ({ page }) => {
await openMenu(page)
await rows(page).filter({ hasText: 'Regex for semver' }).click()
await expect(page.getByTestId('chat-title')).toHaveText('Regex for semver with prerelease tags')
})
test('F2 renames in place and Enter saves', async ({ page }) => {
await openMenu(page)
await page.keyboard.press('F2')
const box = menu(page).getByRole('textbox', { name: 'Rename' })
await expect(box).toBeFocused()
await box.fill('Release notes 3.4')
await box.press('Enter')
await expect(rows(page).first()).toContainText('Release notes 3.4')
await expect.poll(async () => (await storedChats(page)).chats.find(c => c.id === 'c-today-a').name).toBe('Release notes 3.4')
// The header shows it too.
await page.keyboard.press('Escape')
await expect(page.getByTestId('chat-title')).toHaveText('Release notes 3.4')
})
test('Escape in the rename box cancels and keeps the menu open', async ({ page }) => {
await openMenu(page)
await page.keyboard.press('F2')
await menu(page).getByRole('textbox', { name: 'Rename' }).fill('Nope')
await page.keyboard.press('Escape')
await expect(menu(page)).toBeVisible()
await expect(rows(page).first()).toContainText('Rewrite the 3.4 release notes for clarity')
})
test('the title in the header renames the chat with a click', async ({ page }) => {
await page.getByTestId('chat-title').click()
const box = page.getByTestId('chat-title-input')
await expect(box).toBeFocused()
await box.fill('Clearer notes')
await box.press('Enter')
await expect(page.getByTestId('chat-title')).toHaveText('Clearer notes')
await expect.poll(async () => (await storedChats(page)).chats.find(c => c.id === 'c-today-a').name).toBe('Clearer notes')
})
test('Delete hides the row and offers an undo; Undo brings it back', async ({ page }) => {
await openMenu(page)
const target = rows(page).filter({ hasText: 'Regex for semver' })
await target.hover()
await page.keyboard.press('Delete')
await expect(rows(page).filter({ hasText: 'Regex for semver' })).toHaveCount(0)
const toast = page.getByTestId('chat-undo-toast')
await expect(toast).toContainText('Deleted "Regex for semver with prerelease tags"')
// Until the undo time ends the chat is still stored.
expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-week-a')
await toast.getByRole('button', { name: 'Undo' }).click()
await expect(toast).toHaveCount(0)
await expect(rows(page).filter({ hasText: 'Regex for semver' })).toHaveCount(1)
expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-week-a')
})
test('the chat is deleted for good when the undo time ends', async ({ page }) => {
await openMenu(page)
const row = rows(page).filter({ hasText: 'Regex for semver' })
await row.hover()
await row.getByTitle('Delete chat').click()
const toast = page.getByTestId('chat-undo-toast')
await expect(toast).toBeVisible()
// Dismissing the toast ends the undo time at once.
await toast.getByRole('button', { name: 'Dismiss' }).click()
await expect(toast).toHaveCount(0)
await expect.poll(async () => (await storedChats(page)).chats.map(c => c.id)).not.toContain('c-week-a')
})
test('the undo time runs out by itself after six seconds', async ({ page }) => {
await page.clock.install()
await page.reload()
await page.getByTestId('chat-composer').waitFor()
await openMenu(page)
const row = rows(page).filter({ hasText: 'Regex for semver' })
await row.hover()
await row.getByTitle('Delete chat').click()
await expect(page.getByTestId('chat-undo-toast')).toBeVisible()
await page.clock.runFor(6500)
await expect(page.getByTestId('chat-undo-toast')).toHaveCount(0)
await expect.poll(async () => (await storedChats(page)).chats.map(c => c.id)).not.toContain('c-week-a')
})
test('deleting the open chat opens another one, and Undo returns to it', async ({ page }) => {
await openMenu(page)
await page.keyboard.press('Delete')
await expect(page.getByTestId('chat-undo-toast')).toBeVisible()
await expect(page.getByTestId('chat-title')).not.toHaveText('Rewrite the 3.4 release notes for clarity')
await page.getByTestId('chat-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(page.getByTestId('chat-title')).toHaveText('Rewrite the 3.4 release notes for clarity')
})
test('Duplicate, Copy chat and Export are on every row', async ({ page }) => {
await openMenu(page)
const row = rows(page).first()
await expect(row.getByTitle('Duplicate chat')).toBeVisible()
await expect(row.getByTitle('Copy chat')).toBeVisible()
await expect(row.getByTitle('Export as Markdown')).toBeVisible()
await expect(row.getByTitle('Rename')).toBeVisible()
await expect(row.getByTitle('Delete chat')).toBeVisible()
})
test('New chat from the menu opens an empty chat', async ({ page }) => {
await openMenu(page)
await page.getByTestId('chats-new').click()
await expect(menu(page)).toHaveCount(0)
await expect(page.getByTestId('chat-empty')).toBeVisible()
})
test('Clear all asks before it removes anything', async ({ page }) => {
await openMenu(page)
await menu(page).getByRole('button', { name: 'Clear all' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Delete all chats?')
await dialog.getByRole('button', { name: 'Delete all' }).click()
await expect.poll(async () => (await storedChats(page)).chats.length).toBe(1)
await expect(page.getByTestId('chat-empty')).toBeVisible()
})
})
test.describe('Chat conversations menu with one chat', () => {
test('has no delete button and no Clear all', async ({ page }) => {
await mockChat(page)
await openChat(page, [chatOf('only', 'The only chat', 'qwen3-8b', pair('hi', 'hello'))])
await openMenu(page)
await expect(rows(page)).toHaveCount(1)
await expect(rows(page).first().getByTitle('Delete chat')).toHaveCount(0)
await expect(menu(page).getByRole('button', { name: 'Clear all' })).toHaveCount(0)
})
})
@@ -70,15 +70,16 @@ test.describe('Chat - model loading', () => {
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hello')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Hello')
await page.getByTestId('chat-send').click()
// The 503 is shown as progress, not as an error.
await expect(page.locator('.chat-staging-progress')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.chat-staging-label')).toContainText('nvidia-thor')
await expect(page.locator('.chat-staging-pct')).toContainText('41%')
await expect(page.getByTestId('chat-load')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('chat-load-phase')).toContainText('nvidia-thor')
await expect(page.getByTestId('chat-load-pct')).toContainText('41%')
await expect(page.getByTestId('chat-load')).toContainText('13.7 of 33.3 GB')
// ...and the request retries itself once the load reports ready.
await expect(page.getByText('loaded at last')).toBeVisible({ timeout: 25_000 })
@@ -0,0 +1,145 @@
import { test, expect } from './coverage-fixtures.js'
import { chatOf, pair, mockChat, openChat, storedChats, RESOURCES_24GB } from './chat-fixtures.js'
// The model chip lists the chat models: loaded ones first, a dot for warm or
// not loaded, an eye for vision, and, where the server can estimate, whether
// the model fits this machine. Nothing is shown that the API does not report.
const GB = 1024 * 1024 * 1024
const NEED_GB = { 'qwen3-8b': 5.1, 'gemma-3-12b': 7.6, 'phi-4-mini': 2.5, 'llama-70b': 42.5, 'deepseek-32b': 19.5 }
const MODELS = [
{ id: 'qwen3-8b', capabilities: ['FLAG_CHAT'] },
{ id: 'gemma-3-12b', capabilities: ['FLAG_CHAT', 'FLAG_VISION'] },
{ id: 'phi-4-mini', capabilities: ['FLAG_CHAT'] },
{ id: 'deepseek-32b', capabilities: ['FLAG_CHAT'] },
{ id: 'llama-70b', capabilities: ['FLAG_CHAT'] },
{ id: 'mystery-model', capabilities: ['FLAG_CHAT'] },
]
async function stubEstimates(page) {
const asked = []
await page.route('**/api/models/vram-estimate', (route) => {
const body = route.request().postDataJSON()
asked.push(body)
const need = NEED_GB[body.model]
// A model the server cannot read answers 200 with no figure.
if (!need) return route.fulfill({ json: { message: 'no estimate for this format' } })
return route.fulfill({ json: { vram_bytes: need * GB, size_bytes: need * 0.9 * GB } })
})
return asked
}
const chip = (page) => page.getByTestId('home-model-chip')
const row = (page, name) => page.getByRole('option').filter({ has: page.locator('code', { hasText: new RegExp(`^${name}$`) }) })
test.describe('Chat model switcher', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page, { models: MODELS, loaded: ['qwen3-8b', 'phi-4-mini'], resources: RESOURCES_24GB })
})
test('groups loaded and installed models and says warm or not loaded', async ({ page }) => {
await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'), { contextSize: 8192 })])
await chip(page).click()
const heads = page.locator('.home-menu__head')
await expect(heads).toHaveText(['Loaded now', 'Installed'])
await expect(row(page, 'qwen3-8b')).toContainText('warm')
await expect(row(page, 'phi-4-mini')).toContainText('warm')
await expect(row(page, 'gemma-3-12b')).toContainText('not loaded')
// Loaded models come first.
const names = await page.getByRole('option').locator('code').allTextContents()
expect(names.slice(0, 2).sort()).toEqual(['phi-4-mini', 'qwen3-8b'])
// The chip's dot follows the loaded state.
await expect(chip(page).locator('.home-dot')).not.toHaveClass(/home-dot--cold/)
})
test('marks the models that understand images', async ({ page }) => {
await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])])
await chip(page).click()
await expect(row(page, 'gemma-3-12b').getByRole('img', { name: 'Understands images' })).toBeVisible()
await expect(row(page, 'phi-4-mini').getByRole('img', { name: 'Understands images' })).toHaveCount(0)
})
test('reads memory and estimates once the list opens, then shows what fits', async ({ page }) => {
const asked = await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [], { contextSize: 16384 })])
// Nothing is asked until the list opens.
await page.waitForTimeout(300)
expect(asked).toHaveLength(0)
await chip(page).click()
// Warm models say they are ready; a cold one that fits says how much room is left.
await expect(row(page, 'qwen3-8b')).toContainText('ready now')
await expect(row(page, 'gemma-3-12b')).toContainText('GB free')
await expect(row(page, 'gemma-3-12b')).toContainText('7.6 GB')
// 19.5 GB on a 24 GB card with a 22.8 GB limit fits with 3.3 left.
await expect(row(page, 'deepseek-32b')).toContainText('3.3 GB free')
// 42.5 GB does not fit the card; the rest would run on the CPU (RAM is 40 GB).
await expect(row(page, 'llama-70b')).toContainText('runs on the CPU')
// The estimates use this chat's context size.
expect(asked.every(b => b.context_size === 16384)).toBe(true)
// The memory bar closes the list.
await expect(page.getByTestId('chat-model-memory')).toContainText('7.6 of 24 GB used')
})
test('says how far over the machine a model is when it cannot run at all', async ({ page }) => {
await page.route('**/api/resources', route => route.fulfill({ json: {
...RESOURCES_24GB, ram: { total: 16 * GB, used: 10 * GB, available: 6 * GB },
} }))
await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])])
await chip(page).click()
await expect(row(page, 'llama-70b')).toContainText('more than this machine has')
})
test('a model with no estimate shows no fit text and no invented numbers', async ({ page }) => {
await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])])
await chip(page).click()
await expect(row(page, 'deepseek-32b')).toContainText('GB free')
const mystery = row(page, 'mystery-model')
await expect(mystery).toContainText('not loaded')
await expect(mystery).not.toContainText('GB')
await expect(mystery).not.toContainText('free')
})
test('picking a model changes this chat and remembers it', async ({ page }) => {
await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', [])])
await chip(page).click()
await row(page, 'gemma-3-12b').click()
await expect(chip(page)).toContainText('gemma-3-12b')
await expect(page.getByRole('option')).toHaveCount(0)
await expect.poll(async () => (await storedChats(page)).chats[0].model).toBe('gemma-3-12b')
// Cold now: the dot is hollow.
await expect(chip(page).locator('.home-dot')).toHaveClass(/home-dot--cold/)
})
test('/model opens the list and Esc closes it', async ({ page }) => {
await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'))])
await page.getByTestId('chat-input').fill('/model')
await page.getByTestId('chat-input').press('Enter')
await expect(page.getByRole('listbox', { name: 'Model' })).toBeVisible()
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Escape')
await expect(page.getByRole('listbox', { name: 'Model' })).toHaveCount(0)
await expect(chip(page)).toBeFocused()
})
test('on a phone the list is a sheet from the bottom', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await stubEstimates(page)
await openChat(page, [chatOf('c1', 'Models', 'qwen3-8b', pair('hi', 'hello'))])
await chip(page).click()
const menu = page.locator('.home-menu--models')
await expect(menu).toBeVisible()
const box = await menu.boundingBox()
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
expect(box.y + box.height).toBeLessThanOrEqual(844)
expect(await menu.evaluate(el => getComputedStyle(el).position)).toBe('fixed')
})
})
@@ -32,7 +32,7 @@ async function openChat(page) {
})
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
}
test.describe('Chat - PDF attachments', () => {
@@ -44,15 +44,15 @@ test.describe('Chat - PDF attachments', () => {
})
await openChat(page)
await page.locator('input[type=file]').setInputFiles({
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'report.pdf',
mimeType: 'application/pdf',
buffer: buildPdf('Quarterly revenue grew 42 percent'),
})
await expect(page.locator('.chat-file-name', { hasText: 'report.pdf' })).toBeVisible()
await expect(page.locator('.home-file-tag', { hasText: 'report.pdf' })).toBeVisible()
await page.locator('.chat-input').fill('Summarize')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Summarize')
await page.getByTestId('chat-send').click()
await expect.poll(() => requestBody).toContain('Quarterly revenue grew 42 percent')
expect(requestBody).toContain('File: report.pdf')
@@ -62,14 +62,14 @@ test.describe('Chat - PDF attachments', () => {
test('rejects a PDF that cannot be parsed instead of attaching garbage', async ({ page }) => {
await openChat(page)
await page.locator('input[type=file]').setInputFiles({
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'broken.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('%PDF-1.4 this is not a real document'),
})
await expect(page.getByText('Could not read text from broken.pdf')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.chat-file-name', { hasText: 'broken.pdf' })).toHaveCount(0)
await expect(page.locator('.home-file-tag', { hasText: 'broken.pdf' })).toHaveCount(0)
})
})
@@ -52,12 +52,12 @@ test.describe('Chat - /api/operations polling (#9904)', () => {
const { getOperationsHits } = await setupChatPage(page)
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hi')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Hi')
await page.getByTestId('chat-send').click()
const assistantContent = page.locator('.chat-message-assistant .chat-message-content').first()
const assistantContent = page.locator('[data-role="assistant"] .cx-prose').first()
await expect(assistantContent).toContainText('Hello world', { timeout: 10_000 })
// Sanity check: the polling we're regressing against is actually firing.
@@ -114,12 +114,12 @@ test.describe('Chat - copy button (#9904)', () => {
})
await page.goto('/app/chat')
await expect(page.getByRole('button', { name: 'test-model' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByRole('button', { name: /test-model/ })).toBeVisible({ timeout: 10_000 })
await page.locator('.chat-input').fill('Hi')
await page.locator('.chat-send-btn').click()
await page.getByTestId('chat-input').fill('Hi')
await page.getByTestId('chat-send').click()
const assistantBubble = page.locator('.chat-message-assistant .chat-message-bubble').first()
const assistantBubble = page.locator('[data-role="assistant"]').first()
await expect(assistantBubble).toContainText('Hello world', { timeout: 10_000 })
// Spy on document.execCommand so we can confirm the fallback path ran.
@@ -135,7 +135,7 @@ test.describe('Chat - copy button (#9904)', () => {
}
})
await assistantBubble.locator('.chat-message-actions button').first().click()
await assistantBubble.getByTestId('message-actions').locator('button').first().click()
const execCommandCalls = await page.evaluate(() => window.__execCommandCalls)
expect(execCommandCalls).toContain('copy')
@@ -0,0 +1,170 @@
import { test, expect } from './coverage-fixtures.js'
import { chatOf, pair, mockChat, openChat, storedChats, answerWith } from './chat-fixtures.js'
// Chat settings are a sheet: the system prompt, sampling, the context size,
// the behaviour switches and, for admins, the model's facts. They apply from
// the next message.
const sheet = (page) => page.getByTestId('chat-settings')
async function open(page) {
await page.getByTestId('chat-settings-button').click()
await expect(sheet(page)).toBeVisible()
}
test.describe('Chat settings sheet', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page)
await page.route('**/api/models/config-json/qwen3-8b', route => route.fulfill({ json: {
name: 'qwen3-8b', backend: 'llama-cpp', context_size: 8192, threads: 8, gpu_layers: 99,
parameters: { model: 'qwen3-8b-q4_k_m.gguf' }, template: { chat_message: 'x' },
} }))
})
test('opens as a kit sheet, says when it applies, and closes on Esc, the veil and the button', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))])
await open(page)
await expect(sheet(page)).toHaveClass(/dk-sheet/)
await expect(sheet(page)).toContainText('Applies from the next message.')
await expect(sheet(page).getByRole('button', { name: 'Close' })).toBeFocused()
await page.keyboard.press('Escape')
await expect(sheet(page)).toHaveCount(0)
// Focus returns to the button that opened it.
await expect(page.getByTestId('chat-settings-button')).toBeFocused()
await open(page)
await page.locator('.cx-sheet-veil').click({ position: { x: 20, y: 300 } })
await expect(sheet(page)).toHaveCount(0)
await open(page)
await sheet(page).getByRole('button', { name: 'Close' }).click()
await expect(sheet(page)).toHaveCount(0)
})
test('each sampling value says "model default" until changed, and Reset puts it back', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))])
await open(page)
const temp = sheet(page).locator('.cx-field', { hasText: 'Temperature' })
await expect(temp.locator('.cx-field__value')).toHaveText('model default')
await expect(temp.getByRole('button', { name: 'Reset' })).toHaveCount(0)
await temp.getByRole('slider').fill('0.4')
await expect(temp.locator('.cx-field__value')).toHaveText('0.4')
await expect.poll(async () => (await storedChats(page)).chats[0].temperature).toBe(0.4)
await temp.getByRole('button', { name: 'Reset' }).click()
await expect(temp.locator('.cx-field__value')).toHaveText('model default')
await expect.poll(async () => (await storedChats(page)).chats[0].temperature).toBeNull()
// Top P and Top K are there too and change their own fields.
await sheet(page).locator('.cx-field', { hasText: 'Top P' }).getByRole('slider').fill('0.5')
await sheet(page).locator('.cx-field', { hasText: 'Top K' }).getByRole('slider').fill('20')
await expect.poll(async () => {
const c = (await storedChats(page)).chats[0]
return [c.topP, c.topK]
}).toEqual([0.5, 20])
})
test('the system prompt is stored and goes out before the next message', async ({ page }) => {
const answer = await answerWith(page, 'ok')
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', [])])
await open(page)
await sheet(page).getByTestId('chat-system-prompt').fill('Answer in one line.')
await expect.poll(async () => (await storedChats(page)).chats[0].systemPrompt).toBe('Answer in one line.')
await page.keyboard.press('Escape')
await page.getByTestId('chat-input').fill('hello')
await page.keyboard.press('Enter')
await expect(page.locator('[data-role="assistant"]')).toContainText('ok')
expect(answer.request().messages[0]).toEqual({ role: 'system', content: 'Answer in one line.' })
})
test('the context size has quick sizes, drives the header meter and is not sent', async ({ page }) => {
const answer = await answerWith(page, 'ok')
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'), { tokenUsage: { prompt: 1000, completion: 96, total: 1096 } })])
// The admin's model configuration fills the size in: 8192.
await expect.poll(async () => (await storedChats(page)).chats[0].contextSize).toBe(8192)
await open(page)
const size = sheet(page).getByRole('group', { name: 'Context Size' })
await expect(size.getByRole('button', { name: '8k' })).toHaveAttribute('aria-pressed', 'true')
await expect(sheet(page)).toContainText('Using 1096 of 8192 tokens (13%)')
await size.getByRole('button', { name: '4k' }).click()
await expect(sheet(page)).toContainText('(27%)')
await page.keyboard.press('Escape')
await expect(page.getByTestId('chat-context')).toContainText('27%')
await page.getByTestId('chat-input').fill('go')
await page.keyboard.press('Enter')
await expect(page.locator('[data-role="assistant"]').last()).toContainText('ok')
expect(JSON.stringify(answer.request())).not.toContain('4096')
})
test('Manage mode turns on from the sheet, marks the header and is sent with the message', async ({ page }) => {
const answer = await answerWith(page, 'ok')
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', [])])
await open(page)
const manage = sheet(page).getByRole('switch', { name: 'Manage mode' })
await expect(manage).toHaveAttribute('aria-checked', 'false')
await manage.click()
await expect(manage).toHaveAttribute('aria-checked', 'true')
await page.keyboard.press('Escape')
await expect(page.getByTestId('chat-manage-badge')).toBeVisible()
await page.getByTestId('chat-input').fill('what is installed')
await page.keyboard.press('Enter')
await expect(page.locator('[data-role="assistant"]')).toContainText('ok')
expect(answer.request().metadata).toMatchObject({ localai_assistant: 'true' })
})
test('Focus mode is a switch that is remembered', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))])
await open(page)
const focus = sheet(page).getByRole('switch', { name: 'Focus mode' })
await expect(focus).toHaveAttribute('aria-checked', 'true')
await focus.click()
await expect(focus).toHaveAttribute('aria-checked', 'false')
expect(await page.evaluate(() => localStorage.getItem('localai_chat_focus_mode'))).toBe('false')
})
test('admins see the model facts and can open its config', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))])
await open(page)
const info = page.getByTestId('chat-model-info')
await expect(info).toContainText('llama-cpp')
await expect(info).toContainText('qwen3-8b-q4_k_m.gguf')
await expect(info).toContainText('99')
await info.getByRole('button', { name: 'Edit config' }).click()
await expect(page).toHaveURL(/\/app\/model-editor\/qwen3-8b/)
})
test('Clear conversation asks for a confirmation, then empties the chat but keeps its settings', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'), { temperature: 0.4 })])
await open(page)
await sheet(page).getByRole('button', { name: 'Clear chat history' }).click()
await expect(sheet(page)).toHaveCount(0)
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Clear this conversation')
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByTestId('chat-message')).toHaveCount(2)
await open(page)
await sheet(page).getByRole('button', { name: 'Clear chat history' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Clear conversation' }).click()
await expect(page.getByTestId('chat-empty')).toBeVisible()
await expect.poll(async () => {
const c = (await storedChats(page)).chats[0]
return [c.history.length, c.temperature]
}).toEqual([0, 0.4])
})
test('on a phone the sheet rises from the bottom', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await openChat(page, [chatOf('c1', 'Settings', 'qwen3-8b', pair('hi', 'hello'))])
await page.getByTestId('chat-settings-button').click()
const box = await sheet(page).boundingBox()
expect(box.width).toBeLessThanOrEqual(390)
expect(box.y + box.height).toBeGreaterThanOrEqual(843)
expect(box.y).toBeGreaterThan(20)
await expect(sheet(page).locator('.dk-sheet-grip')).toBeVisible()
})
})
+200
View File
@@ -0,0 +1,200 @@
import { test, expect } from './coverage-fixtures.js'
import {
chatOf, pair, mockChat, openChat, storedChats, controlStream, answerWith,
} from './chat-fixtures.js'
import { sampleChats } from './home-fixtures.js'
// The states around the thread: an empty chat, no model, a reply waiting for a
// model, the phone layout and reduced motion.
test.describe('Empty chat', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page)
})
test('says what the page is for, offers starters and says whether the model is ready', async ({ page }) => {
await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])])
await expect(page.getByTestId('chat-empty').getByRole('heading')).toHaveText('What are we working on?')
await expect(page.getByTestId('chat-starters').getByRole('button')).toHaveCount(4)
await expect(page.getByTestId('chat-ready')).toContainText('qwen3-8b is loaded and ready.')
// The composer sits under the line, not at the bottom of the page.
const head = await page.getByTestId('chat-empty').boundingBox()
const bar = await page.getByTestId('chat-composer').boundingBox()
expect(bar.y).toBeGreaterThan(head.y)
expect(bar.y).toBeLessThan(head.y + 200)
})
test('a model that is not loaded says the first reply loads it', async ({ page }) => {
await openChat(page, [chatOf('n1', 'New Chat', 'gemma-3-12b', [])])
await expect(page.getByTestId('chat-ready')).toContainText('gemma-3-12b is not loaded yet. The first reply loads it.')
})
test('a starter fills the box and focuses it', async ({ page }) => {
await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])])
await page.getByTestId('chat-starters').getByRole('button', { name: 'Help me write code' }).click()
await expect(page.getByTestId('chat-input')).toHaveValue('Help me write code')
await expect(page.getByTestId('chat-input')).toBeFocused()
})
test('lists the conversations like Home does, and a row resumes one', async ({ page }) => {
const chats = sampleChats()
await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', []), ...chats], 'n1')
const list = page.getByTestId('chat-under')
await expect(list.getByTestId('home-day-today')).toBeVisible()
await expect(list.getByTestId('home-day-yesterday')).toBeVisible()
await expect(list.getByTestId('home-conversation')).toHaveCount(5)
await list.getByTestId('home-conversation').filter({ hasText: 'Regex for semver' }).getByRole('button').first().click()
await expect(page.getByTestId('chat-title')).toHaveText('Regex for semver with prerelease tags')
await expect(page.getByTestId('chat-empty')).toHaveCount(0)
})
test('deleting from the list offers an undo', async ({ page }) => {
await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', []), ...sampleChats()], 'n1')
const row = page.getByTestId('chat-under').getByTestId('home-conversation').filter({ hasText: 'Draft a polite reply' })
await row.hover()
await row.getByRole('button', { name: /Delete conversation/ }).click()
await expect(page.getByTestId('chat-undo-toast')).toContainText('Deleted "Draft a polite reply about the invoice"')
await page.getByTestId('chat-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(page.getByTestId('chat-under').getByTestId('home-conversation')).toHaveCount(5)
})
test('the first message moves the composer to the bottom without losing the box', async ({ page }) => {
await answerWith(page, 'hello there')
await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])])
await page.getByTestId('chat-input').fill('hi')
await page.keyboard.press('Enter')
await expect(page.locator('[data-role="assistant"]')).toContainText('hello there')
await expect(page.getByTestId('chat-empty')).toHaveCount(0)
const bar = await page.getByTestId('chat-composer').boundingBox()
expect(bar.y + bar.height).toBeGreaterThan(page.viewportSize().height - 80)
})
})
test.describe('No chat model', () => {
test('shows an install card, keeps the composer and what was typed, and cannot send', async ({ page }) => {
await mockChat(page, { models: [], loaded: [] })
await openChat(page, [chatOf('n1', 'New Chat', '', [])])
const card = page.getByTestId('chat-no-model')
await expect(card).toBeVisible()
await expect(card.getByRole('heading')).toHaveText('Chat needs a language model')
await expect(card.getByRole('button', { name: 'Browse the gallery' })).toBeVisible()
await expect(card.getByRole('button', { name: 'Import a model' })).toBeVisible()
await expect(page.getByTestId('chat-input')).toHaveAttribute('placeholder', 'Install a model to start chatting')
await expect(page.getByTestId('home-model-chip')).toContainText('No chat model')
await page.getByTestId('chat-input').fill('what I wanted to ask')
await expect(page.getByTestId('chat-send')).toBeDisabled()
await expect(page.getByTestId('chat-input')).toHaveValue('what I wanted to ask')
})
test('the gallery button opens the Models page', async ({ page }) => {
await mockChat(page, { models: [], loaded: [] })
await openChat(page, [chatOf('n1', 'New Chat', '', [])])
await page.getByTestId('chat-no-model').getByRole('button', { name: 'Browse the gallery' }).click()
await expect(page).toHaveURL(/\/app\/models/)
})
})
test.describe('A reply that waits for a model', () => {
test('a model that is not loaded gets a plain note, with no invented phases', async ({ page }) => {
await mockChat(page)
await controlStream(page)
await openChat(page, [chatOf('c1', 'Cold', 'gemma-3-12b', pair('hi', 'hello'))])
await page.getByTestId('chat-input').fill('go')
await page.keyboard.press('Enter')
const card = page.getByTestId('chat-load')
await expect(card).toBeVisible()
await expect(card).toContainText('Loading')
await expect(card).toContainText('gemma-3-12b')
await expect(card).toContainText('This model is not loaded yet, so the first reply takes longer.')
await expect(card.getByTestId('chat-load-phase')).toHaveCount(0)
await expect(card.getByRole('progressbar')).toHaveCount(0)
// The first token replaces the card.
await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Hello' } }] }))
await expect(page.getByTestId('chat-load')).toHaveCount(0)
await expect(page.getByTestId('chat-streaming')).toContainText('Hello')
})
test('a loaded model waits behind quiet dots, not a card', async ({ page }) => {
await mockChat(page)
await controlStream(page)
await openChat(page, [chatOf('c1', 'Warm', 'qwen3-8b', pair('hi', 'hello'))])
await page.getByTestId('chat-input').fill('go')
await page.keyboard.press('Enter')
await expect(page.getByTestId('chat-streaming').locator('.cx-dots')).toBeVisible()
await expect(page.getByTestId('chat-load')).toHaveCount(0)
})
test('a staging operation shows its progress when no load job names the phase', async ({ page }) => {
await mockChat(page)
await page.route('**/api/operations', route => route.fulfill({ json: { operations: [{
id: 'op1', name: 'gemma-3-12b', taskType: 'staging', nodeName: 'node-2', progress: 55, message: 'file 1 of 2',
}] } }))
await controlStream(page)
await openChat(page, [chatOf('c1', 'Stage', 'gemma-3-12b', [])])
await page.getByTestId('chat-input').fill('go')
await page.keyboard.press('Enter')
const card = page.getByTestId('chat-load')
await expect(card.getByTestId('chat-load-phase')).toContainText('node-2')
await expect(card.getByTestId('chat-load-pct')).toHaveText('55%')
await expect(card.getByTestId('chat-load-detail')).toHaveText('file 1 of 2')
})
})
test.describe('Chat on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('fits the width, keeps the composer in view and keeps message actions visible', async ({ page }) => {
await mockChat(page)
await openChat(page, [chatOf('c1', 'Phone', 'qwen3-8b', [...pair('one', 'first'), ...pair('two', 'second')], { contextSize: 8192, tokenUsage: { prompt: 10, completion: 5, total: 15 } })])
await expect(page.getByTestId('chat-message')).toHaveCount(4)
const scroll = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, v: window.innerWidth }))
expect(scroll.w).toBeLessThanOrEqual(scroll.v)
await expect(page.getByTestId('chat-input')).toBeInViewport()
// The header drops its words and keeps the buttons.
await expect(page.locator('.cx-hist__label')).toBeHidden()
await expect(page.getByTestId('chat-settings-button')).toBeVisible()
// Actions need no hover on a touch screen.
const opacity = await page.getByTestId('message-actions').first().evaluate(el => getComputedStyle(el).opacity)
expect(opacity).toBe('1')
// On a turn that is not the last one, only the first two buttons stay.
const visible = await page.getByTestId('message-actions').nth(2).locator('button').evaluateAll(els => els.filter(e => e.offsetParent !== null).length)
expect(visible).toBe(2)
})
test('the conversations menu fits the screen', async ({ page }) => {
await mockChat(page)
await openChat(page, sampleChats())
await page.getByTestId('chats-trigger').click()
const box = await page.getByTestId('chats-menu').boundingBox()
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
expect(box.y + box.height).toBeLessThanOrEqual(844)
})
test('the empty chat fits too', async ({ page }) => {
await mockChat(page)
await openChat(page, [chatOf('n1', 'New Chat', 'qwen3-8b', [])])
await expect(page.getByTestId('chat-empty')).toBeVisible()
const scroll = await page.evaluate(() => ({ w: document.documentElement.scrollWidth, v: window.innerWidth }))
expect(scroll.w).toBeLessThanOrEqual(scroll.v)
await expect(page.getByTestId('chat-composer')).toBeInViewport()
})
})
test.describe('Chat with reduced motion', () => {
test('nothing in the thread animates', async ({ page }) => {
// The fixture option test.use({ reducedMotion }) does not reach our extended page.
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockChat(page)
await controlStream(page)
await openChat(page, [chatOf('c1', 'Calm', 'qwen3-8b', pair('hi', 'hello'))])
const msgAnim = await page.getByTestId('chat-message').first().evaluate(el => getComputedStyle(el).animationName)
expect(msgAnim).toBe('none')
await page.getByTestId('chat-input').fill('go')
await page.keyboard.press('Enter')
await page.evaluate(() => window.__sse.push({ choices: [{ delta: { reasoning: 'thinking it over' } }] }))
const shimmer = await page.getByTestId('chat-activity').locator('.cx-shimmer, .cx-fold__head span').first().evaluate(el => getComputedStyle(el).animationName)
expect(shimmer).toBe('none')
expect((await storedChats(page)).chats).toHaveLength(1)
})
})
+237
View File
@@ -0,0 +1,237 @@
import { test, expect } from './coverage-fixtures.js'
import {
chatOf, pair, mockChat, openChat, storedChats, controlStream, answerWith, sse, PNG, GO_BLOCK,
} from './chat-fixtures.js'
// The thread's parts: the one-line activity fold, code blocks, attachments,
// per-message actions and their keys, a failed reply and a streaming reply.
const msg = (page, role) => page.locator(`[data-testid="chat-message"][data-role="${role}"]`)
const ACTIVITY = [
{ role: 'user', content: 'Which file has the leak?' },
{ role: 'thinking', content: 'The handler starts one reader per request.' },
{ role: 'tool_call', content: JSON.stringify({ type: 'tool_call', name: 'read_file', arguments: { path: 'stream_handler.go' } }) },
{ role: 'tool_result', content: JSON.stringify({ type: 'tool_result', name: 'read_file', result: '148 lines' }) },
{ role: 'assistant', content: 'It is stream_handler.go.' },
]
test.describe('Chat thread', () => {
test.beforeEach(async ({ page }) => {
await mockChat(page)
})
test('reasoning and tool calls fold into one quiet line that opens inline', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Activity', 'qwen3-8b', ACTIVITY)])
const fold = page.getByTestId('chat-activity')
const head = fold.getByRole('button')
await expect(head).toHaveText('Thought · read_file')
await expect(head).toHaveAttribute('aria-expanded', 'false')
// Closed: the steps are not in the page at all.
await expect(page.locator('.cx-steps')).toHaveCount(0)
await head.click()
await expect(head).toHaveAttribute('aria-expanded', 'true')
const steps = page.locator('.cx-steps .cx-step')
await expect(steps).toHaveCount(3)
await expect(steps.nth(0)).toContainText('The handler starts one reader per request.')
await expect(steps.nth(1)).toContainText('Tool call')
await expect(steps.nth(1)).toContainText('stream_handler.go')
await expect(steps.nth(2)).toContainText('148 lines')
await head.click()
await expect(page.locator('.cx-steps')).toHaveCount(0)
})
test('the activity line sits inside the answer it belongs to', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Activity', 'qwen3-8b', ACTIVITY)])
// One assistant turn, and the fold is part of it, between the name and the text.
await expect(msg(page, 'assistant')).toHaveCount(1)
await expect(msg(page, 'assistant').getByTestId('chat-activity')).toBeVisible()
await expect(msg(page, 'assistant').locator('.cx-prose')).toContainText('It is stream_handler.go.')
})
test('a code block has Copy and Canvas buttons', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await openChat(page, [chatOf('c1', 'Code', 'qwen3-8b', pair('Show me', 'Here:\n\n```go\n' + GO_BLOCK + '\n```\n\nDone.'))])
const block = page.locator('.code-block')
await expect(block.locator('.code-block__lang')).toHaveText('go')
const copy = block.getByRole('button', { name: 'Copy code' })
await expect(copy).toContainText('Copy')
await expect(block.getByRole('button', { name: 'Canvas' })).toBeVisible()
await copy.click()
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('context.WithCancel')
})
test('an image is a thumbnail that opens in the viewer, and a file is a chip', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Attachments', 'gemma-3-12b', [
{
role: 'user',
content: [{ type: 'text', text: 'What is in this?' }, { type: 'image_url', image_url: { url: PNG } }],
files: [{ name: 'shot.png', type: 'image' }, { name: 'notes.txt', type: 'file', content: 'x' }],
},
{ role: 'assistant', content: 'A single pixel.' },
])])
const user = msg(page, 'user')
await expect(user.locator('.cx-thumb img')).toBeVisible()
// The image is shown once: as a thumbnail, not also as a chip.
await expect(user.locator('.cx-chip')).toHaveCount(1)
await expect(user.locator('.cx-chip')).toContainText('notes.txt')
await user.locator('.cx-thumb').click()
await expect(page.locator('.lightbox')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.locator('.lightbox')).toHaveCount(0)
})
test('message actions show on hover, on focus and on the last turn', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Actions', 'qwen3-8b', [...pair('one', 'first'), ...pair('two', 'second')])])
const opacity = (loc) => loc.getByTestId('message-actions').evaluate(el => getComputedStyle(el).opacity)
const first = msg(page, 'user').first()
// Away from the pointer the first turn's actions are hidden; the last turn keeps them.
await page.mouse.move(5, 5)
await expect.poll(() => opacity(first)).toBe('0')
await expect.poll(() => opacity(msg(page, 'assistant').last())).toBe('1')
await first.hover()
await expect.poll(() => opacity(first)).toBe('1')
await page.mouse.move(5, 5)
await first.focus()
await expect.poll(() => opacity(first)).toBe('1')
})
test('the buttons are Copy, Edit, Regenerate and Branch on an answer, and only Copy and Edit on yours', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Actions', 'qwen3-8b', pair('one', 'first'))])
await expect(msg(page, 'user').getByTestId('message-actions').locator('button')).toHaveCount(2)
await expect(msg(page, 'assistant').getByTestId('message-actions').getByTitle('Regenerate')).toBeVisible()
await expect(msg(page, 'assistant').getByTestId('message-actions').getByTitle('Branch from here')).toBeVisible()
})
test('arrow keys move between messages and C, E and B act on the focused one', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await openChat(page, [chatOf('c1', 'Keys', 'qwen3-8b', [...pair('first question', 'first answer'), ...pair('second question', 'second answer')])])
const all = page.getByTestId('chat-message')
await all.first().focus()
await page.keyboard.press('ArrowDown')
await expect(all.nth(1)).toBeFocused()
await page.keyboard.press('ArrowUp')
await expect(all.first()).toBeFocused()
// C copies the focused turn.
await page.keyboard.press('c')
await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe('first question')
// E opens the editor on your own turn; Esc leaves it as it was.
await page.keyboard.press('e')
await expect(all.first().getByRole('textbox')).toHaveValue('first question')
await page.keyboard.press('Escape')
await expect(all.first().getByRole('textbox')).toHaveCount(0)
// B branches from an answer into a new chat.
await all.nth(1).focus()
await page.keyboard.press('b')
await expect(page.getByTestId('chat-title')).toHaveText('Keys (fork)')
await expect(msg(page, 'assistant')).toHaveCount(1)
})
test('R regenerates the focused answer', async ({ page }) => {
const answer = await answerWith(page, 'a fresh answer')
await openChat(page, [chatOf('c1', 'Regen', 'qwen3-8b', pair('the question', 'the old answer'))])
await msg(page, 'assistant').focus()
await page.keyboard.press('r')
await expect(msg(page, 'assistant')).toContainText('a fresh answer')
expect(JSON.stringify(answer.request().messages)).toContain('the question')
})
test('Edit and Regenerate are not offered while a reply is streaming', async ({ page }) => {
await controlStream(page)
await openChat(page, [chatOf('c1', 'Busy', 'qwen3-8b', pair('one', 'first'))])
await page.getByTestId('chat-input').fill('two')
await page.keyboard.press('Enter')
await expect(page.getByTestId('chat-streaming')).toBeVisible()
await expect(msg(page, 'assistant').first().getByTitle('Regenerate')).toHaveCount(0)
await expect(msg(page, 'assistant').first().getByTitle('Edit')).toHaveCount(0)
})
test('a failed reply keeps the text written so far, says why, and Retry asks again', async ({ page }) => {
let calls = 0
await page.route('**/v1/chat/completions', (route) => {
calls++
const body = calls === 1
? `data: ${JSON.stringify({ choices: [{ delta: { content: 'Half an answer' } }] })}\n\n`
+ `data: ${JSON.stringify({ error: { message: 'out of memory after 214 tokens' } })}\n\n`
+ 'data: [DONE]\n\n'
: sse('A whole answer')
route.fulfill({ status: 200, contentType: 'text/event-stream', body })
})
await openChat(page, [chatOf('c1', 'Fail', 'qwen3-8b', [])])
await page.getByTestId('chat-input').fill('Tell me')
await page.keyboard.press('Enter')
const card = page.getByTestId('chat-error')
await expect(card).toBeVisible()
await expect(card.getByRole('heading')).toHaveText('The reply failed')
await expect(card).toContainText('out of memory after 214 tokens')
await expect(card).toContainText('What was written so far is kept above.')
await expect(msg(page, 'assistant')).toContainText('Half an answer')
// One action to take, and the way to the traces.
await expect(card.getByRole('button')).toHaveCount(1)
await expect(card.getByRole('link', { name: /traces/i })).toBeVisible()
await card.locator('details summary').click()
await expect(card.locator('pre')).toContainText('out of memory')
await card.getByRole('button', { name: 'Retry' }).click()
await expect(msg(page, 'assistant')).toContainText('A whole answer')
await expect(page.getByTestId('chat-error')).toHaveCount(0)
expect(calls).toBe(2)
})
test('a reply that quotes "Error:" in a sentence is not an error', async ({ page }) => {
await openChat(page, [chatOf('c1', 'Quote', 'qwen3-8b', pair('what does it print', 'It prints Error: file not found when the path is wrong.'))])
await expect(msg(page, 'assistant')).toContainText('Error: file not found')
await expect(page.getByTestId('chat-error')).toHaveCount(0)
})
test('while a reply streams, Stop ends it and Esc does the same', async ({ page }) => {
await controlStream(page)
await openChat(page, [chatOf('c1', 'Stop', 'qwen3-8b', [])])
const input = page.getByTestId('chat-input')
await input.fill('first')
await page.keyboard.press('Enter')
await expect(page.getByTestId('chat-stop')).toBeVisible()
await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'Partial' } }] }))
await expect(page.getByTestId('chat-streaming')).toContainText('Partial')
await page.getByTestId('chat-stop').click()
await expect(page.getByTestId('chat-stop')).toHaveCount(0)
await expect(page.getByTestId('chat-send')).toBeVisible()
await input.fill('second')
await page.keyboard.press('Enter')
await expect(page.getByTestId('chat-stop')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByTestId('chat-stop')).toHaveCount(0)
await expect.poll(() => page.evaluate(() => window.__sse.calls)).toBe(2)
})
test('the reply streams under the model name, with its reasoning open until the answer starts', async ({ page }) => {
await controlStream(page)
await openChat(page, [chatOf('c1', 'Stream', 'qwen3-8b', [])])
await page.getByTestId('chat-input').fill('go')
await page.keyboard.press('Enter')
const turn = page.getByTestId('chat-streaming')
await expect(turn.locator('.cx-who b')).toHaveText('qwen3-8b')
await page.evaluate(() => window.__sse.push({ choices: [{ delta: { reasoning: 'Weighing the options.' } }] }))
await expect(turn.getByRole('button', { name: /Thinking/ })).toHaveAttribute('aria-expanded', 'true')
await expect(turn).toContainText('Weighing the options.')
await page.evaluate(() => window.__sse.push({ choices: [{ delta: { content: 'The answer.' } }] }))
await expect(turn.locator('.cx-prose')).toContainText('The answer.')
// Once the answer is coming, the reasoning folds to one line.
await expect(turn.getByRole('button', { name: /Thought/ })).toHaveAttribute('aria-expanded', 'false')
await page.evaluate(() => window.__sse.end())
await expect(page.getByTestId('chat-streaming')).toHaveCount(0)
await expect.poll(async () => (await storedChats(page)).chats[0].history.map(m => m.role)).toEqual(['user', 'thinking', 'assistant'])
})
})
+37 -36
View File
@@ -1,55 +1,56 @@
import { test, expect } from './coverage-fixtures.js'
import { chatOf, pair, mockChat, openChat } from './chat-fixtures.js'
// Chat reads as a transcript rather than a bubble thread (mock 04).
// The thread: your turns are raised blocks on the right, the model's turns are
// plain prose under its name, both at one 760 px measure. Both turns still say
// who is speaking in words, so the thread reads aloud and prints well.
const CHAT = {
chats: [{
id: 'c1', name: 'Transcript', model: 'mock-model',
history: [
{ role: 'user', content: 'Which backends do I have?' },
{ role: 'assistant', content: 'Seven are installed.' },
],
}],
activeChatId: 'c1',
}
const THREAD = chatOf('c1', 'Transcript', 'qwen3-8b', pair('Which backends do I have?', 'Seven are installed.'))
test.describe('Chat transcript', () => {
test.describe('Chat thread layout', () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(chat => {
localStorage.setItem('localai_chats_data', JSON.stringify(chat))
}, CHAT)
await page.goto('/app/chat')
await mockChat(page)
await openChat(page, [THREAD])
await expect(page.getByTestId('chat-message').first()).toBeVisible()
})
test('neither role is a filled, rounded bubble', async ({ page }) => {
const user = page.locator('.chat-message-user .chat-message-content').first()
await expect(user).toBeVisible()
test('your turn is a raised block, the reply is plain prose', async ({ page }) => {
const user = page.locator('[data-role="user"] .cx-bubble')
const cs = await user.evaluate(el => {
const s = getComputedStyle(el)
return { radius: s.borderTopLeftRadius, shadow: s.boxShadow }
return { radius: parseFloat(s.borderTopLeftRadius), bg: s.backgroundColor, shadow: s.boxShadow }
})
// A rounded filled bubble carries the speaker in shape and side; a
// transcript carries it in words, which survives being read aloud.
expect(cs.radius).toBe('0px')
expect(cs.shadow).toBe('none')
expect(cs.radius).toBeGreaterThan(8)
expect(cs.bg).not.toBe('rgba(0, 0, 0, 0)')
expect(cs.shadow).not.toBe('none')
const prose = await page.locator('[data-role="assistant"] .cx-prose').evaluate(el => {
const s = getComputedStyle(el)
return { bg: s.backgroundColor, border: s.borderLeftWidth, shadow: s.boxShadow }
})
// No fill, no rail, no shadow: the reply is text on the page.
expect(prose.bg).toBe('rgba(0, 0, 0, 0)')
expect(prose.border).toBe('0px')
expect(prose.shadow).toBe('none')
})
test('both turns run full width in one column, not left and right', async ({ page }) => {
const user = page.locator('.chat-message-user').first()
const assistant = page.locator('.chat-message-assistant').first()
const [u, a] = [await user.boundingBox(), await assistant.boundingBox()]
expect(Math.abs(u.x - a.x)).toBeLessThan(2)
test('your turn sits on the right and the reply on the left of one 760 px column', async ({ page }) => {
const thread = await page.getByTestId('chat-thread').boundingBox()
expect(thread.width).toBeLessThanOrEqual(760)
const user = await page.locator('[data-role="user"] .cx-bubble').boundingBox()
const reply = await page.locator('[data-role="assistant"] .cx-prose').boundingBox()
expect(Math.abs(user.x + user.width - (thread.x + thread.width))).toBeLessThan(2)
expect(Math.abs(reply.x - thread.x)).toBeLessThan(2)
})
test('every turn says who is speaking', async ({ page }) => {
await expect(page.locator('.chat-message-user .chat-message-model')).toHaveText('You')
await expect(page.locator('.chat-message-assistant .chat-message-model').first())
.toHaveText('mock-model')
await expect(page.locator('[data-role="assistant"] .cx-who b')).toHaveText('qwen3-8b')
// Your turn carries the word for assistive technology and for reading aloud.
await expect(page.locator('[data-role="user"]')).toHaveAttribute('aria-label', 'You')
})
test('turns are separated by a rule', async ({ page }) => {
const border = await page.locator('.chat-message').first()
.evaluate(el => getComputedStyle(el).borderBottomStyle)
expect(border).toBe('solid')
test('the reply names whether its model is loaded', async ({ page }) => {
// qwen3-8b is in the stubbed loaded list, so its dot is filled.
await expect(page.locator('[data-role="assistant"] .cx-who .home-dot')).not.toHaveClass(/home-dot--cold/)
})
})
+13 -7
View File
@@ -1,9 +1,9 @@
import { test, expect } from './coverage-fixtures.js'
// A standing guard against the two defects an earlier automated edit left
// scattered through the pages: icons stripped of their fa-* class (which render
// nothing at all), and controls left with the user agent's own chrome, which is
// a pale grey button on a dark ground.
// scattered through the pages: icons that render nothing at all (here, an svg
// whose sprite target is missing or that has no size), and controls left with
// the user agent's own chrome, which is a pale grey button on a dark ground.
const ROUTES = [
'/app', '/app/chat', '/app/models', '/app/studio', '/app/talk',
'/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
@@ -37,10 +37,16 @@ test('no page renders a dead icon or a default-chrome control', async ({ page })
out.push(`default-chrome: "${(el.textContent || '').trim().slice(0, 24)}" [${el.className}]`)
}
}
for (const i of document.querySelectorAll('i')) {
if (!/\bfa-/.test((i.className || '').toString())) {
out.push(`dead-icon: [${i.className}]`)
}
for (const svg of document.querySelectorAll('svg[data-icon]')) {
const use = svg.querySelector('use')
const target = use && document.querySelector(use.getAttribute('href'))
const box = svg.getBoundingClientRect()
if (use && !target) out.push(`dead-icon: no sprite symbol for [${svg.dataset.icon}]`)
if (box.width > 0 && (box.width < 4 || box.height < 4)) out.push(`dead-icon: [${svg.dataset.icon}] is ${box.width}x${box.height}`)
}
// A leftover icon-font element draws nothing now that the font is gone.
for (const i of document.querySelectorAll('i[class*="fa-"], .fas, .far, .fab')) {
out.push(`dead-icon: font class left on [${i.className}]`)
}
return [...new Set(out)]
})
+10 -42
View File
@@ -3,18 +3,11 @@ import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join, dirname, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
// Font Awesome's `fas` / `far` / `fab` classes do not draw an icon on their
// own — they set `font-family: "Font Awesome 6 Free"` and a weight on whatever
// element carries them, and the matching `fa-*` class supplies the glyph
// through ::before. Put them on a <button> and the button's own label text is
// rendered in the icon font; put two `fa-*` classes on one element and they
// fight over the same ::before.
//
// This is not hypothetical. A class-merging edit at some point collapsed
// several "wrapper + button + icon" trios into a single className string, which
// left icon classes on buttons and layout wrappers, and left the real buttons
// with no class at all — rendering them in the browser's own chrome. Both
// symptoms read as "unstyled button" and neither fails a build.
// The app draws icons with the Icon component (an svg from the kit sprite).
// Two kinds of slip are cheap to make in a bulk edit and do not fail a build:
// an icon-font class left on an element, which now draws nothing, and a class
// string merged from several elements into one, which leaves a wrapper styled
// as a button and the real buttons bare.
//
// These tests read the source rather than the DOM on purpose: several of the
// affected pages need agent or voice data before they render their header, so a
@@ -56,45 +49,20 @@ function classLiterals() {
return found
}
const isIconFont = (c) => c === 'fas' || c === 'far' || c === 'fab'
// Font Awesome's `fa-*` namespace holds two different kinds of class: the glyph
// (`fa-plus`), of which an element may have exactly one, and modifiers that
// size, spin or align it, of which it may have any number. `fa-spinner fa-spin`
// is the documented spinner idiom and must not be mistaken for a conflict.
const FA_MODIFIERS = new Set([
'fa-2xs', 'fa-xs', 'fa-sm', 'fa-lg', 'fa-xl', 'fa-2xl',
'fa-fw', 'fa-ul', 'fa-li', 'fa-border', 'fa-inverse',
'fa-pull-left', 'fa-pull-right',
'fa-beat', 'fa-fade', 'fa-beat-fade', 'fa-bounce', 'fa-flip', 'fa-shake',
'fa-spin', 'fa-spin-pulse', 'fa-spin-reverse', 'fa-pulse',
'fa-rotate-90', 'fa-rotate-180', 'fa-rotate-270', 'fa-rotate-by',
'fa-flip-horizontal', 'fa-flip-vertical', 'fa-flip-both',
'fa-stack', 'fa-stack-1x', 'fa-stack-2x',
'fa-sr-only', 'fa-sr-only-focusable',
])
const isGlyph = (c) =>
c.startsWith('fa-') && !FA_MODIFIERS.has(c) && !/^fa-\d+x$/.test(c)
const isIconFont = (c) => c === 'fas' || c === 'far' || c === 'fab' || c === 'fa-solid' || /^fa-[a-z0-9-]+$/.test(c)
test.describe('class hygiene', () => {
test('the icon font is only ever set on an icon element', () => {
// <i> and <span> are the icon carriers. Anything else wearing `fas` is
// rendering its own text in the icon font.
test('no element still carries a Font Awesome class', () => {
const offenders = classLiterals()
.filter(c => c.classes.some(isIconFont))
.filter(c => c.tag && c.tag !== 'i' && c.tag !== 'span')
.map(c => `${c.file}:${c.line} <${c.tag} class="${c.classes.join(' ')}">`)
.map(c => `${c.file}:${c.line} class="${c.classes.join(' ')}"`)
expect(offenders, 'icon-font classes belong on an <i>, not on the element whose text they would restyle').toEqual([])
expect(offenders, 'draw icons with <Icon name="..." />, not with an icon-font class').toEqual([])
})
test('no element carries two competing glyphs or two button variants', () => {
test('no element carries two button variants or a wrapper styled as a button', () => {
const offenders = []
for (const c of classLiterals()) {
const glyphs = c.classes.filter(isGlyph)
if (glyphs.length > 1) {
offenders.push(`${c.file}:${c.line} two glyphs (${glyphs.join(', ')}) on one ::before`)
}
const variants = c.classes.filter(x => /^btn-(primary|secondary|danger|ghost)$/.test(x))
if (variants.length > 1) {
offenders.push(`${c.file}:${c.line} two button variants (${variants.join(', ')}) on one element`)
+10 -8
View File
@@ -1,20 +1,22 @@
import { test, expect } from './coverage-fixtures.js'
// Collections (Knowledge Base) feature page (src/pages/Collections.jsx).
// Memory page (src/pages/Collections.jsx), against the real test server: no
// collections exist there, so the page shows its empty state.
test.describe('Collections page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/collections')
})
test('renders the knowledge base with an empty state and create control', async ({ page }) => {
test('renders memory with an empty state and create control', async ({ page }) => {
await expect(page).toHaveURL(/\/app\/collections$/)
await expect(page.getByRole('heading', { name: 'Knowledge Base' })).toBeVisible()
await expect(page.getByText(/No collections yet/i)).toBeVisible()
await expect(page.locator('button.btn-primary').filter({ hasText: 'Create' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Memory', exact: true })).toBeVisible()
await expect(page.getByText(/Memory holds documents an agent can search/i)).toBeVisible()
await expect(page.getByRole('button', { name: 'New collection' }).first()).toBeVisible()
})
test('new-collection name field accepts input', async ({ page }) => {
const input = page.locator('input, textarea').first()
await page.getByRole('button', { name: 'New collection' }).first().click()
const input = page.getByLabel('New collection name')
await expect(input).toBeVisible()
await input.fill('my-kb')
await expect(input).toHaveValue('my-kb')
@@ -37,10 +39,10 @@ test.describe('Collections page', () => {
})
await page.goto(`/app/collections/${collectionPath}`)
await page.getByRole('button', { name: 'Sources' }).click()
await page.getByTestId('add-source-toggle').click()
await page.locator('#source-url').fill('https://example.com/feed')
await page.locator('#source-interval').fill('3600')
await page.getByRole('button', { name: 'Add Source' }).click()
await page.getByRole('button', { name: 'Add URL' }).click()
await expect.poll(() => postedBody).toEqual({
url: 'https://example.com/feed',
+34 -30
View File
@@ -1,64 +1,68 @@
import { test, expect } from './coverage-fixtures.js'
test.describe('Operate console on a narrow screen', () => {
test('ignores the desktop collapsed preference', async ({ page }) => {
test.describe('Operate hub on a narrow screen', () => {
test('the tab bar scrolls sideways instead of widening the page', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.addInitScript(() => localStorage.setItem('localai_console_rail_collapsed', 'true'))
await page.goto('/app/operate')
const rail = page.locator('.console-rail')
await expect(rail).toHaveCSS('width', '374px')
await expect(rail.getByText('Operate', { exact: true })).toBeVisible()
await expect(rail.getByRole('button', { name: 'Expand Operate navigation' })).toBeVisible()
await expect(rail.locator('.console-rail-collapse')).toBeHidden()
const bar = page.locator('.dk-hubtabs')
await expect(bar).toBeVisible()
const box = await bar.boundingBox()
expect(box.width).toBeLessThanOrEqual(390)
const scrolls = await bar.evaluate(el => el.scrollWidth > el.clientWidth)
expect(scrolls).toBe(true)
const pageOverflows = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
expect(pageOverflows).toBe(false)
})
test('expanding the rail leaves the overview on screen', async ({ page }) => {
test('the current tab can be scrolled into view and the overview stays on screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/settings')
const current = page.locator('.dk-hubtabs [data-hub-tab="settings"]')
await current.scrollIntoViewIfNeeded()
await expect(current).toBeInViewport()
await expect(current).toHaveAttribute('aria-current', 'page')
await page.goto('/app/operate')
const toggle = page.locator('.console-rail-toggle')
await expect(toggle).toBeVisible()
await toggle.click()
await expect(page.locator('.console-rail-groups')).toBeVisible()
const heading = page.getByRole('heading', { name: 'Overview', exact: true })
const heading = page.getByTestId('operate-headline')
const box = await heading.boundingBox()
expect(box).not.toBeNull()
expect(box.y).toBeLessThan(800)
})
test('the rail scrolls internally rather than growing without bound', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
test('there is no second navigation rail', async ({ page }) => {
await page.goto('/app/operate')
await page.locator('.console-rail-toggle').click()
const groups = page.locator('.console-rail-groups')
await expect(groups).toBeVisible()
const height = await groups.evaluate(el => el.getBoundingClientRect().height)
expect(height).toBeLessThan(800)
await expect(page.locator('.console-rail')).toHaveCount(0)
await expect(page.locator('.console-layout')).toHaveCount(0)
})
})
test.describe('Operate headline figures', () => {
test.describe('Operate status rows', () => {
for (const width of [390, 768, 1024]) {
test(`labels remain legible at ${width}px`, async ({ page }) => {
test(`row names and the headline remain legible at ${width}px`, async ({ page }) => {
await page.setViewportSize({ width, height: 1000 })
await page.goto('/app/operate')
const labels = page.locator('.operate-headline dt')
await expect(labels.first()).toBeVisible()
const clipped = await labels.evaluateAll(els =>
const names = page.locator('.op-row__name')
await expect(names.first()).toBeVisible()
await expect(names).toHaveCount(4)
const clipped = await names.evaluateAll(els =>
els.filter(el => el.scrollWidth > el.clientWidth + 1).map(el => el.textContent))
expect(clipped).toEqual([])
const headline = await page.getByTestId('operate-headline').evaluate(el => el.scrollWidth <= el.clientWidth + 1)
expect(headline).toBe(true)
})
}
test('values remain legible in dark theme', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('localai-theme', 'dark'))
await page.setViewportSize({ width: 390, height: 950 })
await page.goto('/app/operate')
const invisible = await page.locator('.operate-headline__value').evaluateAll(els => els
const text = page.locator('.op-row__summary, .op-row__name, .op-status__title')
await expect(text.first()).toBeVisible()
const invisible = await text.evaluateAll(els => els
.map(el => ({ text: el.textContent, color: getComputedStyle(el).color }))
.filter(value => value.color === 'rgb(0, 0, 0)'))
expect(invisible).toEqual([])
+97 -4
View File
@@ -3,7 +3,7 @@ import { test, expect } from './coverage-fixtures.js'
test.describe('Editorial design system', () => {
test('page titles render in the sans display font (no serif)', async ({ page }) => {
await page.goto('/app/settings')
const title = page.locator('.page-title').first()
const title = page.locator('.page-title, .st-title').first()
await expect(title).toBeVisible({ timeout: 15_000 })
const family = await title.evaluate(el => getComputedStyle(el).fontFamily)
// Editorial-grotesk direction: headings use the Geist sans family, no serif.
@@ -13,7 +13,7 @@ test.describe('Editorial design system', () => {
test('active nav item is highlighted with a tinted background (no rail)', async ({ page }) => {
await page.goto('/app/settings')
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.page-title, .st-title').first()).toBeVisible({ timeout: 15_000 })
const active = page.locator('.sidebar-nav .nav-item.active').first()
await expect(active).toBeVisible()
const bg = await active.evaluate(el => getComputedStyle(el).backgroundColor)
@@ -22,6 +22,56 @@ test.describe('Editorial design system', () => {
expect(bg).not.toBe('transparent')
})
test('the current sidebar row carries an accent dot, other rows do not', async ({ page }) => {
await page.goto('/app/settings')
await expect(page.locator('.page-title, .st-title').first()).toBeVisible({ timeout: 15_000 })
const dot = (loc) => loc.evaluate((el) => {
const cs = getComputedStyle(el, '::after')
return { content: cs.content, width: cs.width, radius: cs.borderTopLeftRadius, bg: cs.backgroundColor }
})
const active = await dot(page.locator('.sidebar-nav .nav-item.active').first())
expect(active.content).not.toBe('none')
expect(active.width).toBe('6px')
expect(active.bg).not.toBe('rgba(0, 0, 0, 0)')
const idle = await dot(page.locator('.sidebar-nav .nav-item:not(.active)').first())
expect(idle.content).toBe('none')
})
test('the collapsed sidebar shows the icon mark and hides the dot', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('localai_sidebar_collapsed', 'true'))
await page.goto('/app')
const sidebar = page.locator('.sidebar.collapsed')
await expect(sidebar).toBeVisible({ timeout: 15_000 })
expect((await sidebar.boundingBox()).width).toBe(64)
await expect(page.locator('.sidebar-logo-icon-img')).toBeVisible()
await expect(page.locator('.sidebar-logo-img')).toBeHidden()
const display = await page.locator('.sidebar-nav .nav-item.active').first()
.evaluate((el) => getComputedStyle(el, '::after').display)
expect(display).toBe('none')
})
test('settings stacks its group list above the form on a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/app/settings')
const label = page.locator('.st-row__main').first()
await expect(label).toBeVisible({ timeout: 15_000 })
// Beside the list the label had a few characters of width.
expect((await label.boundingBox()).width).toBeGreaterThan(150)
const rail = await page.locator('.st-groups').boundingBox()
const content = await page.locator('.st-content').boundingBox()
expect(content.y).toBeGreaterThanOrEqual(rail.y + rail.height - 1)
})
test('the settings history button carries no icon class of its own', async ({ page }) => {
await page.goto('/app/settings')
const btn = page.locator('.st-bar__history').first()
await expect(btn).toBeVisible({ timeout: 15_000 })
// An icon class on the button itself once put a missing glyph before the
// label. The icon is a child svg.
await expect(btn).not.toHaveClass(/\b(fas|far|fab|lai-icon)\b/)
await expect(btn.locator('svg[data-icon]')).toHaveCount(1)
})
test('page reveal animation is defined on .page-transition', async ({ page }) => {
await page.goto('/app/settings')
const pt = page.locator('.page-transition').first()
@@ -38,11 +88,54 @@ test.describe('reduced motion', () => {
// coverage `page` fixture, so set it on the page directly.)
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/app') // Home renders .reveal-stagger children
// .home-status-line is staggerStyle(1) -> 60ms delay without the fix.
const child = page.locator('.home-status-line').first()
// .home-below is staggerStyle(2) -> 120ms delay without the fix.
const child = page.locator('.home-below').first()
await expect(child).toBeVisible({ timeout: 15_000 })
const delay = await child.evaluate(el => getComputedStyle(el).animationDelay)
// Under reduced motion the per-child delay must be ~0 (not 60ms+).
expect(parseFloat(delay)).toBeLessThan(0.05)
})
})
test.describe('Shared UI kit theme', () => {
const canvas = {
dark: 'rgb(12, 18, 17)',
light: 'rgb(243, 245, 244)',
}
for (const mode of ['dark', 'light']) {
test(`the ${mode} canvas reaches the legacy page variables`, async ({ page }) => {
await page.addInitScript((m) => localStorage.setItem('localai-theme', m), mode)
await page.goto('/app/settings')
await expect(page.locator('.page-title, .st-title').first()).toBeVisible({ timeout: 15_000 })
const bg = await page.evaluate(() => getComputedStyle(document.body).backgroundColor)
expect(bg).toBe(canvas[mode])
})
}
test('the theme is set before the app bundle runs', async ({ page }) => {
// Block every script chunk. Only the inline snippet in index.html can set
// the attribute, so a pass means first paint already has the right theme.
await page.route('**/assets/**/*.js', (route) => route.abort())
await page.route('**/assets/*.js', (route) => route.abort())
await page.addInitScript(() => localStorage.setItem('localai-theme', 'light'))
await page.goto('/app')
await expect(page.locator('html')).toHaveAttribute('data-theme', 'light')
})
test('the page reveal is a 250ms fade', async ({ page }) => {
await page.goto('/app/settings')
const pt = page.locator('.page-transition').first()
await expect(pt).toBeVisible({ timeout: 15_000 })
const duration = await pt.evaluate((el) => getComputedStyle(el).animationDuration)
expect(duration).toBe('0.25s')
})
test('the current sidebar row lifts onto a card', async ({ page }) => {
await page.goto('/app/settings')
await expect(page.locator('.page-title, .st-title').first()).toBeVisible({ timeout: 15_000 })
const active = page.locator('.sidebar-nav .nav-item.active').first()
const shadow = await active.evaluate((el) => getComputedStyle(el).boxShadow)
expect(shadow).not.toBe('none')
})
})
@@ -62,8 +62,8 @@ test('sparse raw slots, known/unavailable, explicit zero and duplicate names', a
expect(stored.map(x => x.id)).toEqual(['id-2', 'id-1'])
expect(JSON.stringify(stored)).not.toMatch(/embedding|speaker_profiles|sampleUrl/)
await page.getByRole('link', { name: 'Manage remembered voices' }).click()
await page.getByRole('tab', { name: 'Enrollment' }).click()
await expect(page.getByText('Known', { exact: true })).toHaveCount(2)
await expect(page.getByRole('heading', { name: 'Known speakers' })).toBeVisible()
await expect(page.getByTestId('known-list').locator('.dk-table-name', { hasText: 'Known' })).toHaveCount(2)
})
test('save failure preserves input, no premature relabel, duplicate submission disabled', async ({ page }) => {
@@ -32,7 +32,7 @@ test.describe('Models Explore - the view scrolls, not the page', () => {
const pageHeight = () => page.evaluate(() => document.documentElement.scrollHeight)
const railHeight = () => page.evaluate(
() => document.querySelector('.entity-rail')?.getBoundingClientRect().height,
() => document.querySelector('.ledger-wrap')?.getBoundingClientRect().height,
)
const beforePage = await pageHeight()
@@ -50,7 +50,7 @@ test.describe('Models Explore - the view scrolls, not the page', () => {
// The pane is the thing that scrolls.
const paneOverflows = await page.evaluate(() => {
const el = document.querySelector('.split-view__pane')
const el = document.querySelector('.ledger__pane')
return el ? getComputedStyle(el).overflowY : null
})
expect(paneOverflows).toBe('auto')
@@ -64,7 +64,7 @@ test.describe('Models Explore - the view scrolls, not the page', () => {
await expect(page.locator('[data-testid="discover-rail-item"]').first()).toBeVisible({ timeout: 10_000 })
const overflow = await page.evaluate(() => {
const el = document.querySelector('.split-view__pane')
const el = document.querySelector('.ledger__pane')
return el ? getComputedStyle(el).overflowY : null
})
expect(overflow).toBe('visible')
@@ -23,17 +23,17 @@ test.describe('Models Explore - searching keeps the view', () => {
})
await page.goto('/app/models')
const search = page.locator('.filter-bar-group__search input')
const search = page.getByTestId('models-search')
await expect(search).toBeVisible({ timeout: 10_000 })
await search.click()
await search.fill('alpha')
// Mid-refetch: the field is still mounted, still focused, still holding
// what was typed, and the rail is marked busy rather than replaced.
// what was typed, and the table is kept rather than replaced.
await expect(search).toBeFocused()
await expect(search).toHaveValue('alpha')
await expect(page.locator('.entity-rail')).toBeVisible()
await expect(page.locator('.ledger-wrap')).toBeVisible()
await page.waitForTimeout(900)
await expect(search).toBeFocused()
+160
View File
@@ -0,0 +1,160 @@
import { test, expect } from './coverage-fixtures.js'
import { NETWORKS } from './tools-fixtures.js'
// The Explorer page shows what a LocalAI explorer server returns from
// GET /networks and lists a swarm with POST /network/add. It claims nothing the
// two endpoints do not carry: no map, no latency, no worker details.
async function listNetworks(page, data = NETWORKS) {
await page.route('**/networks', route => route.fulfill({ json: data }))
}
test.describe('Explorer', () => {
test('lists the swarms, most workers first, with type, token and worker count', async ({ page }) => {
await listNetworks(page)
await page.goto('/explorer')
await expect(page.getByRole('heading', { name: 'Public swarms' })).toBeVisible()
await expect(page.getByText('3 swarms online')).toBeVisible()
const rows = page.getByTestId('explorer-list').locator('li')
await expect(rows).toHaveCount(3)
await expect(rows.nth(0)).toContainText('Open weekend cluster')
await expect(rows.nth(0)).toContainText('9 workers')
await expect(rows.nth(1)).toContainText('Home lab swarm')
await expect(rows.nth(1)).toContainText('federated')
await expect(rows.nth(1)).toContainText('4 workers')
await expect(rows.nth(2)).toContainText('2 workers')
// The token is cut for display and never shown whole in the list.
await expect(rows.nth(1)).toContainText('ab12cd…')
await expect(rows.nth(1)).not.toContainText(NETWORKS[0].token)
await expect(page.getByTestId('explorer-page')).toContainText('Use at your own risk')
})
test('How to join gives the token and, for a federated swarm, both commands', async ({ page }) => {
await listNetworks(page)
await page.goto('/explorer')
await page.locator('[data-network="Home lab swarm"]').getByTestId('join-open').click()
const sheet = page.getByTestId('join-sheet')
await expect(sheet).toContainText('Join Home lab swarm')
await expect(sheet).toContainText(NETWORKS[0].token)
await expect(sheet).toContainText('LOCALAI_P2P_NETWORK_ID=home-lab')
await expect(sheet).toContainText('docker run -d --restart=always')
await expect(sheet).toContainText('local-ai federated --debug')
await sheet.getByRole('button', { name: 'Close' }).last().click()
await expect(sheet).toHaveCount(0)
})
test('a swarm that is not federated has the token and no commands', async ({ page }) => {
await listNetworks(page)
await page.goto('/explorer')
await page.locator('[data-network="Studio render pool"]').getByTestId('join-open').click()
await expect(page.getByTestId('join-sheet')).toContainText(NETWORKS[2].token)
await expect(page.getByTestId('join-sheet')).not.toContainText('docker run')
await expect(page.getByTestId('join-sheet')).toContainText('The token above is what a node needs to join.')
})
test('copying the token reports it', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write'])
await listNetworks(page)
await page.goto('/explorer')
await page.locator('[data-network="Home lab swarm"]').getByTestId('join-open').click()
await page.getByTestId('copy-token').click()
await expect(page.getByTestId('copy-token')).toContainText('Copied')
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(NETWORKS[0].token)
})
test('List a swarm needs every field and the acknowledgement, then posts and refreshes', async ({ page }) => {
let posted = null
let list = NETWORKS.slice(1)
await page.route('**/networks', route => route.fulfill({ json: list }))
await page.route('**/network/add', route => { posted = route.request().postDataJSON(); list = NETWORKS; route.fulfill({ json: { message: 'Token added' } }) })
await page.goto('/explorer')
await expect(page.getByText('2 swarms online')).toBeVisible()
await page.getByTestId('list-open').click()
const submit = page.getByTestId('list-submit')
await expect(submit).toBeDisabled()
await page.fill('#ex-name', 'Home lab swarm')
await page.fill('#ex-desc', 'Four machines.')
await page.fill('#ex-token', 'dG9rZW4=')
await expect(submit).toBeDisabled()
await expect(page.getByTestId('list-sheet')).toContainText('Listing publishes the token')
await page.getByTestId('list-understood').check()
await expect(submit).toBeEnabled()
await submit.click()
await expect.poll(() => posted).toEqual({ name: 'Home lab swarm', description: 'Four machines.', token: 'dG9rZW4=' })
await expect(page.getByTestId('explorer-notice')).toContainText('The swarm is listed.')
await expect(page.getByText('3 swarms online')).toBeVisible()
})
test('a refused listing shows the server message and keeps the sheet', async ({ page }) => {
await listNetworks(page)
await page.route('**/network/add', route => route.fulfill({ status: 400, json: { error: 'Token already exists' } }))
await page.goto('/explorer')
await page.getByTestId('list-open').click()
await page.fill('#ex-name', 'x')
await page.fill('#ex-desc', 'y')
await page.fill('#ex-token', 'dG9rZW4=')
await page.getByTestId('list-understood').check()
await page.getByTestId('list-submit').click()
await expect(page.getByTestId('list-error')).toHaveText('Token already exists')
await expect(page.getByTestId('list-sheet')).toBeVisible()
})
test('no swarm with workers: an empty state that names the next step', async ({ page }) => {
await listNetworks(page, [])
await page.goto('/explorer')
await expect(page.getByTestId('explorer-empty')).toContainText('No swarm has workers online')
await expect(page.getByTestId('explorer-empty').getByRole('button', { name: 'List a swarm' })).toBeVisible()
})
test('a server that is not an explorer says so', async ({ page }) => {
await page.route('**/networks', route => route.fulfill({ status: 404, contentType: 'text/html', body: '<html>not found</html>' }))
await page.goto('/explorer')
await expect(page.getByTestId('explorer-off')).toContainText('This server is not an explorer')
await expect(page.getByTestId('explorer-off')).toContainText('local-ai explorer')
await expect(page.getByTestId('list-open')).toHaveCount(0)
})
test('an explorer that does not answer shows a retry', async ({ page }) => {
let fail = true
await page.route('**/networks', route => (fail ? route.abort() : route.fulfill({ json: NETWORKS })))
await page.goto('/explorer')
await expect(page.getByTestId('explorer-error')).toBeVisible()
fail = false
await page.getByRole('button', { name: 'Try again' }).click()
await expect(page.getByTestId('explorer-list')).toBeVisible()
})
test('the page has no sidebar and links back to the app', async ({ page }) => {
await listNetworks(page)
await page.goto('/explorer')
await expect(page.locator('.sidebar')).toHaveCount(0)
await expect(page.getByRole('link', { name: /Open LocalAI/ }).first()).toHaveAttribute('href', '/app')
})
})
test.describe('Explorer: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('the list, the join sheet and the listing sheet fit the screen', async ({ page }) => {
await listNetworks(page)
await page.goto('/explorer')
await expect(page.getByTestId('explorer-list')).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
await page.locator('[data-network="Home lab swarm"]').getByTestId('join-open').click()
await expect(page.getByTestId('join-sheet')).toBeVisible()
const box = await page.getByTestId('join-sheet').boundingBox()
expect(box.width).toBeLessThanOrEqual(390)
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
})
})
test.describe('Explorer: reduced motion', () => {
test('the page does not animate', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await listNetworks(page)
await page.goto('/explorer')
await expect(page.getByTestId('explorer-list')).toBeVisible()
const duration = await page.getByTestId('explorer-list').locator('li').first().evaluate(el => getComputedStyle(el).transitionDuration)
expect(parseFloat(duration)).toBeLessThan(0.01)
})
})
@@ -1,6 +1,8 @@
import { test, expect } from './coverage-fixtures.js'
import { mockSwarm, clusterNodes } from './swarm-fixtures.js'
// Failover overview page (Operate -> Runtime -> Failover) and the matching
// Failover overview page (Operate -> Runtime -> Failover on a single install,
// Operate -> Swarm -> Failover on a cluster) and the matching
// "chain" badge on an installed model that is itself a failover chain.
//
// The overview is a dense table over GET /api/failover, kept live by the
@@ -56,15 +58,16 @@ test.describe('Failover overview', () => {
})
})
test('admin sees the Failover entry in the Operate rail', async ({ page }) => {
test('admin reaches Failover from the Runtime tab', async ({ page }) => {
await mockAuth(page)
await mockFailover(page, [CHAIN_A])
await page.goto('/app/operate')
await page.goto('/app/backends')
const rail = page.locator('.console-layout > .console-rail')
const link = rail.locator('a.nav-item[href="/app/failover"]')
const link = page.locator('.hub-subnav a[href="/app/failover"]')
await expect(link).toBeVisible({ timeout: 10_000 })
await expect(link).toContainText('Failover')
await link.click()
await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"]')).toHaveAttribute('aria-current', 'page')
})
test('lists chains from GET /api/failover with their live health', async ({ page }) => {
@@ -103,12 +106,21 @@ test.describe('Failover overview', () => {
await expect(targetB).toHaveClass(/status-pill--error/)
})
test('a single install shows the chains and none of the node sections', async ({ page }) => {
await mockAuth(page)
await mockFailover(page, [CHAIN_A])
await page.goto('/app/failover')
await expect(page.locator('[data-testid="failover-overview"] tbody tr')).toHaveCount(1)
await expect(page.getByText('When a node stops answering')).toHaveCount(0)
await expect(page.getByTestId('failover-try')).toHaveCount(0)
})
test('shows an empty state pointing at the Failover Chain template', async ({ page }) => {
await mockAuth(page)
await mockFailover(page, [])
await page.goto('/app/failover')
await expect(page.locator('.empty-state')).toBeVisible({ timeout: 10_000 })
await expect(page.locator('.dk-empty')).toBeVisible({ timeout: 10_000 })
const cta = page.getByRole('link', { name: /create a failover chain/i })
await expect(cta).toHaveAttribute('href', '/app/model-editor?template=failover')
})
@@ -132,3 +144,89 @@ test.describe('Installed Models - chain badge', () => {
await expect(page.getByText('chain → a')).toBeVisible({ timeout: 10_000 })
})
})
// With distributed mode on, Failover is a Swarm page. It keeps the chains and
// adds what the router and the health monitor do when a worker stops answering,
// and works out from the loaded replicas and the rules what would stop if a
// node went. The second half is a preview computed in the browser.
test.describe('Failover on a cluster', () => {
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err) => { throw new Error(`uncaught page error: ${err.message}`) })
})
test('is a Swarm page, and no longer a Runtime one', async ({ page }) => {
await mockSwarm(page, { chains: [CHAIN_A] })
await page.goto('/app/failover')
await expect(page.locator('.dk-hubtabs [data-hub-tab="swarm"]')).toHaveAttribute('aria-current', 'page', { timeout: 15_000 })
await expect(page.locator('.hub-subnav a[href="/app/failover"]')).toHaveAttribute('aria-current', 'page')
await expect(page.locator('.dk-hubtabs [data-hub-tab="runtime"]')).not.toHaveAttribute('aria-current', 'page')
await page.goto('/app/backends')
await expect(page.locator('.hub-subnav a[href="/app/failover"]')).toHaveCount(0)
})
test('says what happens when a node stops answering, in four lines', async ({ page }) => {
await mockSwarm(page, { chains: [CHAIN_A] })
await page.goto('/app/failover')
const section = page.getByRole('region', { name: 'When a node stops answering' })
await expect(section.locator('dt')).toHaveText(['Declared not answering', 'Requests for its models', 'Replicas it was running', 'When it comes back'], { timeout: 15_000 })
await expect(section).toContainText('After 5 minutes without a heartbeat by default')
await expect(section).toContainText('timings are server settings that this page does not read')
})
test('names the node that is not answering and links to it', async ({ page }) => {
await mockSwarm(page, { chains: [] })
await page.goto('/app/failover')
const banner = page.getByTestId('failover-lost')
await expect(banner).toContainText('gpu-box-2 is not answering.', { timeout: 15_000 })
await expect(banner.getByRole('link', { name: 'Open node' })).toHaveAttribute('href', '/app/nodes/n-gpu2')
})
test('shows no lost-node banner when every node answers', async ({ page }) => {
const nodes = clusterNodes().map(node => (node.status === 'unhealthy' ? { ...node, status: 'healthy' } : node))
await mockSwarm(page, { nodes, chains: [] })
await page.goto('/app/failover')
await expect(page.getByRole('region', { name: 'When a node stops answering' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('failover-lost')).toHaveCount(0)
})
test('works out what would stop if a node went away, labelled as a preview', async ({ page }) => {
await mockSwarm(page, { chains: [] })
await page.goto('/app/failover')
const section = page.getByTestId('failover-try')
await expect(section).toContainText('Preview', { timeout: 15_000 })
// It starts on the healthy node that holds the most models.
await expect(section.getByRole('radio', { name: 'gpu-box-1' })).toHaveAttribute('aria-checked', 'true')
await expect(section.getByTestId('leave-list')).toContainText('3 requests were in flight on this node and cannot finish')
await expect(section.getByTestId('leave-list')).toContainText('llama-3.3-70b-q4 is unavailable until this node returns')
await expect(section).toContainText('The server does not compute it')
await section.getByRole('radio', { name: 'edge-cpu' }).click()
await expect(section.getByTestId('leave-list')).toContainText('bge-m3 is unavailable until this node returns')
await expect(section.getByTestId('leave-list')).not.toContainText('requests were in flight')
})
test('lists models that run on one replica and links each to a rule for two', async ({ page }) => {
await mockSwarm(page, { chains: [] })
await page.goto('/app/failover')
const single = page.getByTestId('failover-single')
await expect(single).toContainText('4 models run on a single replica', { timeout: 15_000 })
await expect(single.getByRole('link', { name: 'Run 2 replicas' }).first()).toHaveAttribute('href', '/app/scheduling?new=bge-m3&min=2')
await single.getByRole('link', { name: 'Run 2 replicas' }).first().click()
await expect(page.getByTestId('rule-sheet')).toBeVisible()
await expect(page.getByLabel('Min replicas')).toHaveValue('2')
})
test('says the preview cannot be worked out when the replicas cannot be read', async ({ page }) => {
await mockSwarm(page, { replicas: null, chains: [] })
await page.goto('/app/failover')
await expect(page.getByTestId('failover-try')).toContainText('cannot be worked out now', { timeout: 15_000 })
})
test('fits a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockSwarm(page, { chains: [CHAIN_A] })
await page.goto('/app/failover')
await expect(page.getByTestId('failover-try')).toBeVisible({ timeout: 15_000 })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
})
})
+22 -22
View File
@@ -44,13 +44,13 @@ test('saved message edits persist without sending a completion request', async (
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
const firstUser = page.locator('.chat-message-user').first()
const firstUser = page.locator('[data-testid="chat-message"][data-role="user"]').first()
await firstUser.hover()
await firstUser.getByTitle('Edit').click()
await firstUser.getByRole('textbox').fill('edited first question')
await firstUser.getByRole('button', { name: 'Save' }).click()
const firstAssistant = page.locator('.chat-message-assistant').first()
const firstAssistant = page.locator('[data-testid="chat-message"][data-role="assistant"]').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Edit').click()
await firstAssistant.getByRole('textbox').fill('edited first answer')
@@ -64,8 +64,8 @@ test('saved message edits persist without sending a completion request', async (
})).toEqual(['edited first question', 'edited first answer'])
await page.reload()
await expect(page.locator('.chat-message-user').first()).toContainText('edited first question')
await expect(page.locator('.chat-message-assistant').first()).toContainText('edited first answer')
await expect(page.locator('[data-testid="chat-message"][data-role="user"]').first()).toContainText('edited first question')
await expect(page.locator('[data-testid="chat-message"][data-role="assistant"]').first()).toContainText('edited first answer')
expect(completionRequests).toBe(0)
})
@@ -74,7 +74,7 @@ test('cancelling a message edit leaves the original content unchanged', async ({
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
const firstUser = page.locator('.chat-message-user').first()
const firstUser = page.locator('[data-testid="chat-message"][data-role="user"]').first()
await firstUser.hover()
await firstUser.getByTitle('Edit').click()
await firstUser.getByRole('textbox').fill('discard this draft')
@@ -97,9 +97,9 @@ test('duplicate creates an independent copy and switches to it', async ({ page }
await page.getByTitle('Duplicate chat').first().click()
// A new active chat named "Seeded Chat (fork)" with the same 4 messages.
await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
await expect(page.locator('.chat-message-user')).toHaveCount(2)
await expect(page.locator('.chat-message-assistant')).toHaveCount(2)
await expect(page.getByTestId('chat-title')).toHaveText('Seeded Chat (fork)')
await expect(page.locator('[data-testid="chat-message"][data-role="user"]')).toHaveCount(2)
await expect(page.locator('[data-testid="chat-message"][data-role="assistant"]')).toHaveCount(2)
})
async function mockCompletion(page, replyText) {
@@ -129,15 +129,15 @@ test('retry regenerates the first answer and drops the later turn', async ({ pag
await page.goto('/app/chat')
// Hover the FIRST assistant message and click its retry button.
const firstAssistant = page.locator('.chat-message-assistant').first()
const firstAssistant = page.locator('[data-testid="chat-message"][data-role="assistant"]').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Regenerate').click()
// History is truncated to the first user turn, then the new answer streams in;
// the second Q/A turn is gone.
await expect(page.locator('.chat-message-assistant')).toContainText(['REGENERATED first answer'])
await expect(page.locator('.chat-message-user')).toHaveCount(1)
await expect(page.locator('.chat-message-assistant')).toHaveCount(1)
await expect(page.locator('[data-testid="chat-message"][data-role="assistant"]')).toContainText(['REGENERATED first answer'])
await expect(page.locator('[data-testid="chat-message"][data-role="user"]')).toHaveCount(1)
await expect(page.locator('[data-testid="chat-message"][data-role="assistant"]')).toHaveCount(1)
// The OUTBOUND payload must also be truncated: the resent user turn is present,
// but the downstream turn and the stale first answer must be gone.
@@ -168,7 +168,7 @@ test('editing a file prompt preserves its content blocks and attachment metadata
await seedChat(page, FILE_TURNS)
await page.goto('/app/chat')
const firstUser = page.locator('.chat-message-user').first()
const firstUser = page.locator('[data-testid="chat-message"][data-role="user"]').first()
await firstUser.hover()
await firstUser.getByTitle('Edit').click()
await firstUser.getByRole('textbox').fill('edited file question')
@@ -201,16 +201,16 @@ test('regenerating a non-last answer in a fork still sends the uploaded file con
await page.goto('/app/chat')
// Fork after the second turn, then regenerate the FIRST (now non-last) answer.
const secondAssistant = page.locator('.chat-message-assistant').nth(1)
const secondAssistant = page.locator('[data-testid="chat-message"][data-role="assistant"]').nth(1)
await secondAssistant.hover()
await secondAssistant.getByTitle('Branch from here').click()
await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
await expect(page.getByTestId('chat-title')).toHaveText('Seeded Chat (fork)')
const firstAssistant = page.locator('.chat-message-assistant').first()
const firstAssistant = page.locator('[data-testid="chat-message"][data-role="assistant"]').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Regenerate').click()
await expect(page.locator('.chat-message-assistant')).toContainText(['REGENERATED file answer'])
await expect(page.locator('[data-testid="chat-message"][data-role="assistant"]')).toContainText(['REGENERATED file answer'])
// The outbound payload for the regenerated turn must still carry the file text.
const contents = (sentMessages || []).map(m =>
@@ -242,13 +242,13 @@ test('branch from the first answer forks history up to that point', async ({ pag
await seedChat(page, TWO_TURNS)
await page.goto('/app/chat')
const firstAssistant = page.locator('.chat-message-assistant').first()
const firstAssistant = page.locator('[data-testid="chat-message"][data-role="assistant"]').first()
await firstAssistant.hover()
await firstAssistant.getByTitle('Branch from here').click()
// New active chat "Seeded Chat (fork)" contains only the first Q/A turn.
await expect(page.locator('.chat-header-title')).toHaveText('Seeded Chat (fork)')
await expect(page.locator('.chat-message-user')).toHaveCount(1)
await expect(page.locator('.chat-message-assistant')).toHaveCount(1)
await expect(page.locator('.chat-message-assistant')).toContainText(['first answer'])
await expect(page.getByTestId('chat-title')).toHaveText('Seeded Chat (fork)')
await expect(page.locator('[data-testid="chat-message"][data-role="user"]')).toHaveCount(1)
await expect(page.locator('[data-testid="chat-message"][data-role="assistant"]')).toHaveCount(1)
await expect(page.locator('[data-testid="chat-message"][data-role="assistant"]')).toContainText(['first answer'])
})
+730
View File
@@ -0,0 +1,730 @@
import { test, expect } from './coverage-fixtures.js'
import { mockHome, mockUser, seedChats, sampleChats, CHATS_KEY, LOADED } from './home-fixtures.js'
// The Home console: command bar, slash menu, memory strip, resume list, first
// run and the phone layout. Data is stubbed the way the other Home specs do it;
// conversations are written to the browser storage the Chat page reads.
const GB = 1024 * 1024 * 1024
async function ready(page) {
await page.goto('/app')
await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 })
}
async function storedChats(page) {
return page.evaluate((key) => JSON.parse(localStorage.getItem(key) || '{}'), CHATS_KEY)
}
test.describe('Home slash menu', () => {
test.beforeEach(async ({ page }) => {
await mockHome(page)
await seedChats(page)
})
test('typing a slash opens the grouped action list', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/')
const list = page.getByRole('listbox', { name: 'Actions' })
await expect(list).toBeVisible()
// Grouped, with a label above each group.
await expect(list.locator('.dk-cmd-label').first()).toHaveText('Chat')
await expect(list.getByRole('option').first()).toHaveAttribute('aria-selected', 'true')
})
test('only actions with a destination are offered', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/')
const names = await page.locator('.dk-cmd-name').allTextContents()
expect(names).toEqual([
'/model', '/new', '/chat', '/assistant',
'/gallery', '/installed', '/import', '/stop',
'/studio', '/settings', '/docs',
])
})
test('filters as you type and marks the typed part', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/ga')
const options = page.getByRole('option')
await expect(options).toHaveCount(1)
await expect(options.first()).toContainText('/gallery')
await expect(options.first().locator('mark')).toHaveText('/ga')
})
test('matches on the label too', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/documentation')
await expect(page.getByRole('option')).toHaveCount(1)
await expect(page.getByRole('option').first()).toContainText('/docs')
})
test('arrow keys move the choice and Enter runs it', async ({ page }) => {
await ready(page)
const box = page.locator('.home-textarea')
await box.fill('/')
await box.press('ArrowDown')
await expect(page.getByRole('option').nth(1)).toHaveAttribute('aria-selected', 'true')
await box.press('ArrowUp')
await box.press('ArrowUp')
// Wraps to the last action.
await expect(page.getByRole('option').last()).toHaveAttribute('aria-selected', 'true')
await box.fill('/gal')
await box.press('Enter')
await expect(page).toHaveURL(/\/app\/models$/)
})
test('Escape closes the menu and keeps the text', async ({ page }) => {
await ready(page)
const box = page.locator('.home-textarea')
await box.fill('/ga')
await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible()
await box.press('Escape')
await expect(page.getByRole('listbox', { name: 'Actions' })).toHaveCount(0)
await expect(box).toHaveValue('/ga')
})
test('a message with a space is a message, not a command', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/etc/hosts what is this file')
await expect(page.getByRole('listbox', { name: 'Actions' })).toHaveCount(0)
})
test('a click on an action runs it and clears the box', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/')
await page.getByTestId('home-slash-studio').click()
await expect(page).toHaveURL(/\/app\/studio/)
})
test('"/" outside a text field starts a command', async ({ page }) => {
await ready(page)
await page.locator('.home-greeting').click()
await page.keyboard.press('/')
await expect(page.locator('.home-textarea')).toBeFocused()
await expect(page.locator('.home-textarea')).toHaveValue('/')
await expect(page.getByRole('listbox', { name: 'Actions' })).toBeVisible()
})
test('/model opens the model list', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/model')
await page.locator('.home-textarea').press('Enter')
await expect(page.getByRole('listbox', { name: 'Model' })).toBeVisible()
})
test('/stop opens the memory strip', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/stop')
await page.locator('.home-textarea').press('Enter')
await expect(page.locator('.home-loaded')).toBeVisible()
})
test('/new opens an empty chat on the chosen model', async ({ page }) => {
await ready(page)
const before = (await storedChats(page)).chats.length
await page.locator('.home-textarea').fill('/new')
await page.locator('.home-textarea').press('Enter')
await expect(page).toHaveURL(/\/app\/chat/)
await expect.poll(async () => {
const data = await storedChats(page)
const active = data.chats.find(c => c.id === data.activeChatId)
return data.chats.length === before + 1 && active?.history.length === 0 && active?.model
}).toBe('qwen3-8b-instruct')
})
test('a person who is not an admin sees only the actions they can use', async ({ page }) => {
await mockUser(page)
await ready(page)
await page.locator('.home-textarea').fill('/')
const names = await page.locator('.dk-cmd-name').allTextContents()
expect(names).toEqual(['/model', '/new', '/chat', '/studio', '/docs'])
})
test('and the library row keeps only the documentation link', async ({ page }) => {
await mockUser(page)
await ready(page)
const row = page.getByTestId('home-library')
await expect(row.getByText(/documentation/i)).toBeVisible()
await expect(row.getByText(/browse gallery/i)).toHaveCount(0)
await expect(page.getByTestId('home-assistant-tip')).toHaveCount(0)
})
})
test.describe('Home model chip', () => {
test.beforeEach(async ({ page }) => {
await mockHome(page)
await seedChats(page)
})
test('shows warm and cold models and picks with the keyboard', async ({ page }) => {
await ready(page)
const chip = page.getByTestId('home-model-chip')
await expect(chip).toContainText('qwen3-8b-instruct')
await chip.click()
const list = page.getByRole('listbox', { name: 'Model' })
await expect(list.getByRole('option')).toHaveCount(4)
await expect(list.getByRole('option', { name: /qwen3-8b-instruct/ }).locator('.home-dot')).not.toHaveClass(/home-dot--cold/)
await expect(list.getByRole('option', { name: /gemma-4-e4b/ }).locator('.home-dot')).toHaveClass(/home-dot--cold/)
await expect(list.getByRole('option', { name: /gemma-4-e4b/ })).toContainText('not loaded')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('Enter')
await expect(chip).toContainText('gemma-4-e4b-it-qat-q4_0')
await expect(list).toHaveCount(0)
await expect(chip).toBeFocused()
})
test('Escape closes the list and returns to the chip', async ({ page }) => {
await ready(page)
const chip = page.getByTestId('home-model-chip')
await chip.click()
await page.keyboard.press('Escape')
await expect(page.getByRole('listbox', { name: 'Model' })).toHaveCount(0)
await expect(chip).toBeFocused()
})
})
test.describe('Home memory strip', () => {
test('reads memory use in one line and opens into the loaded models', async ({ page }) => {
await mockHome(page)
await ready(page)
const head = page.locator('.home-strip__head')
await expect(head).toContainText('2')
await expect(head).toContainText('models loaded')
await expect(head).toContainText('8.8 / 24 GB')
await expect(head).toContainText('RTX 4090')
await expect(head).toHaveAttribute('aria-expanded', 'false')
await head.click()
await expect(head).toHaveAttribute('aria-expanded', 'true')
await expect(page.locator('.home-loaded__row')).toHaveCount(2)
// System RAM is listed under the models when the memory shown is GPU.
await expect(page.locator('.home-ram')).toContainText('18.2 / 64 GB')
})
test('the bar is the memory in use, one solid fill', async ({ page }) => {
await mockHome(page)
await ready(page)
const fill = page.locator('.home-strip__head .home-bar__fill')
const width = await fill.evaluate(el => el.getBoundingClientRect().width / el.parentElement.getBoundingClientRect().width)
expect(width).toBeGreaterThan(0.25)
expect(width).toBeLessThan(0.45)
const bg = await fill.evaluate(el => getComputedStyle(el).backgroundImage)
expect(bg).toBe('none')
})
test('stopping one model asks first, then calls the backend', async ({ page }) => {
await mockHome(page)
const stops = []
await page.route('**/backend/shutdown', route => {
stops.push(route.request().postDataJSON())
return route.fulfill({ json: { message: 'ok' } })
})
await ready(page)
await page.locator('.home-strip__head').click()
const row = page.locator('.home-loaded__row', { hasText: 'whisper-large-v3-turbo' })
await row.hover()
await row.getByRole('button', { name: /stop/i }).click()
await expect(page.getByRole('alertdialog')).toContainText('whisper-large-v3-turbo')
expect(stops).toEqual([])
await page.getByRole('button', { name: /^Stop whisper-large-v3-turbo$/ }).click()
await expect.poll(() => stops).toEqual([{ model: 'whisper-large-v3-turbo' }])
})
test('stop all stops every loaded model', async ({ page }) => {
await mockHome(page)
const stops = []
await page.route('**/backend/shutdown', route => {
stops.push(route.request().postDataJSON().model)
return route.fulfill({ json: { message: 'ok' } })
})
await ready(page)
await page.locator('.home-strip__head').click()
await page.getByRole('button', { name: 'Stop all', exact: true }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop all' }).click()
await expect.poll(() => stops.sort()).toEqual(LOADED.map(m => m.id).sort())
})
test('opens by itself while a model is being staged', async ({ page }) => {
await mockHome(page, { operations: [{ jobID: 'j1', name: 'qwen3-32b-instruct', taskType: 'staging', nodeName: 'gpu-b', progress: 41, isBackend: false }] })
await ready(page)
const head = page.locator('.home-strip__head')
await expect(head).toContainText('Staging qwen3-32b-instruct')
await expect(head).toContainText('41%')
await expect(head).toHaveAttribute('aria-expanded', 'true')
})
test('opens by itself after a failure and says what happened in plain words', async ({ page }) => {
await mockHome(page, { operations: [{ jobID: 'j2', name: 'qwen3-32b-instruct', taskType: 'staging', nodeName: 'gpu-b', progress: 0, error: 'no node has enough free memory', isBackend: false }] })
await ready(page)
await expect(page.locator('.home-strip__head')).toHaveAttribute('aria-expanded', 'true')
const notice = page.getByTestId('home-staging-error')
await expect(notice).toContainText('qwen3-32b-instruct could not be staged')
await expect(notice).toContainText('gpu-b')
// One action; the raw error is behind a disclosure.
await expect(notice.getByRole('button')).toHaveCount(1)
await notice.getByText('Details').click()
await expect(notice).toContainText('no node has enough free memory')
})
test('says it could not reach the server instead of showing a first run', async ({ page }) => {
await page.route('**/system', route => route.abort())
await page.route('**/v1/models', route => route.abort())
await ready(page)
const notice = page.getByTestId('home-load-error')
await expect(notice).toContainText('could not load your models')
await expect(page.getByTestId('home-first-run')).toHaveCount(0)
// Retry works once the server answers.
await page.unroute('**/system')
await page.unroute('**/v1/models')
await mockHome(page)
await notice.getByRole('button', { name: 'Try again' }).click()
await expect(notice).toHaveCount(0)
await expect(page.locator('.home-strip__head')).toContainText('models loaded')
})
test('shows a skeleton while the first answer is on its way', async ({ page }) => {
await mockHome(page)
await page.route('**/v1/models', async route => {
await new Promise(resolve => setTimeout(resolve, 800))
await route.fulfill({ json: { data: [{ id: 'a' }] } })
})
await page.goto('/app')
await expect(page.getByTestId('home-strip-skeleton')).toBeVisible()
await expect(page.locator('.home-strip__head')).toBeVisible()
})
})
test.describe('Home in a cluster', () => {
const nodes = [
{ id: 'a', name: 'gpu-a', status: 'healthy', total_vram: 24 * GB, available_vram: 17 * GB },
{ id: 'b', name: 'gpu-b', status: 'healthy', total_vram: 48 * GB, available_vram: 21 * GB },
{ id: 'c', name: 'gpu-c', status: 'healthy', total_vram: 24 * GB, available_vram: 7 * GB },
]
test('the strip says models, nodes and aggregate memory in one line', async ({ page }) => {
await mockHome(page, { features: { distributed: true, localai_assistant: true }, nodes })
await ready(page)
const head = page.locator('.home-strip__head')
await expect(head).toContainText('2')
await expect(head).toContainText('models on')
await expect(head).toContainText('3/3')
await expect(head).toContainText('nodes')
await expect(head).toContainText('51 / 96 GB')
await head.click()
await expect(page.locator('.home-nodes li')).toHaveCount(3)
await expect(page.locator('.home-nodes')).toContainText('27 / 48 GB')
})
})
test.describe('Home resume list', () => {
test.beforeEach(async ({ page }) => {
await mockHome(page)
await seedChats(page)
})
test('groups conversations by day, one card per day', async ({ page }) => {
await ready(page)
await expect(page.getByTestId('home-day-today').locator('.home-day__label')).toContainText('Today')
await expect(page.getByTestId('home-day-yesterday').locator('.home-day__label')).toContainText('Yesterday')
await expect(page.getByTestId('home-day-week').locator('.home-day__label')).toContainText('Earlier this week')
await expect(page.getByTestId('home-day-today').getByTestId('home-conversation')).toHaveCount(2)
await expect(page.getByTestId('home-day-yesterday').getByTestId('home-conversation')).toHaveCount(2)
await expect(page.getByTestId('home-day-week').getByTestId('home-conversation')).toHaveCount(1)
})
test('a row shows time, title, reply preview, model and message count', async ({ page }) => {
await ready(page)
const row = page.getByTestId('home-conversation').first()
await expect(row.locator('.home-row__time')).toHaveText(/^\d\d:\d\d$/)
await expect(row.locator('.home-row__title')).toHaveText('Rewrite the 3.4 release notes for clarity')
await expect(row.locator('.home-row__reply')).toContainText('Anytime.')
await expect(row.locator('.home-row__model')).toHaveText('qwen3-8b-instruct')
await expect(row.locator('.home-row__count')).toHaveText('6 messages')
})
test('a conversation with an image carries an eye', async ({ page }) => {
await ready(page)
const row = page.getByTestId('home-conversation').filter({ hasText: 'architecture diagram' })
await expect(row.locator('.home-row__model svg')).toBeVisible()
})
test('hover swaps the count for Resume and offers delete', async ({ page }) => {
await ready(page)
const row = page.getByTestId('home-conversation').nth(1)
await expect(row.locator('.home-row__count')).toBeVisible()
await expect(row.locator('.home-row__resume')).toBeHidden()
await row.hover()
await expect(row.locator('.home-row__count')).toBeHidden()
await expect(row.locator('.home-row__resume')).toBeVisible()
await expect(row.getByRole('button', { name: /delete conversation/i })).toBeVisible()
})
test('j and k move between rows; Enter resumes the focused one', async ({ page }) => {
await ready(page)
await page.locator('.home-greeting').click()
await page.keyboard.press('j')
await expect(page.locator('.home-row__open').nth(0)).toBeFocused()
await page.keyboard.press('j')
await expect(page.locator('.home-row__open').nth(1)).toBeFocused()
await page.keyboard.press('k')
await expect(page.locator('.home-row__open').nth(0)).toBeFocused()
await page.keyboard.press('j')
await page.keyboard.press('j')
await page.keyboard.press('Enter')
await expect(page).toHaveURL(/\/app\/chat/)
const data = await storedChats(page)
expect(data.activeChatId).toBe('c-yday-a')
})
test('j and k are ignored while typing', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('jk')
await expect(page.locator('.home-textarea')).toHaveValue('jk')
await expect(page.locator('.home-row__open').first()).not.toBeFocused()
})
test('clicking a row resumes that conversation in Chat', async ({ page }) => {
await ready(page)
await page.getByTestId('home-conversation').filter({ hasText: 'invoice' }).locator('.home-row__open').click()
await expect(page).toHaveURL(/\/app\/chat/)
expect((await storedChats(page)).activeChatId).toBe('c-yday-b')
})
test('delete hides the row and offers undo; undo brings it back', async ({ page }) => {
await ready(page)
const title = 'Why does llama-cpp stall at a 4096 context'
const row = page.getByTestId('home-conversation').filter({ hasText: title })
await row.hover()
await row.getByRole('button', { name: /delete conversation/i }).click()
await expect(page.getByTestId('home-conversation').filter({ hasText: title })).toHaveCount(0)
const toast = page.getByTestId('home-undo-toast')
await expect(toast).toContainText(title)
// The chat is kept until the undo time ends.
expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-today-b')
await toast.getByRole('button', { name: 'Undo' }).click()
await expect(page.getByTestId('home-conversation').filter({ hasText: title })).toHaveCount(1)
await expect(toast).toHaveCount(0)
expect((await storedChats(page)).chats.map(c => c.id)).toContain('c-today-b')
})
test('without undo the delete becomes final when the time ends', async ({ page }) => {
await page.clock.install()
await ready(page)
const row = page.getByTestId('home-conversation').filter({ hasText: 'invoice' })
await row.hover()
await row.getByRole('button', { name: /delete conversation/i }).click()
await expect(page.getByTestId('home-undo-toast')).toBeVisible()
await page.clock.runFor(6500)
await expect(page.getByTestId('home-undo-toast')).toHaveCount(0)
expect((await storedChats(page)).chats.map(c => c.id)).not.toContain('c-yday-b')
})
test('the Delete key removes the focused row and moves focus on', async ({ page }) => {
await ready(page)
await page.locator('.home-greeting').click()
await page.keyboard.press('j')
await page.keyboard.press('Delete')
await expect(page.getByTestId('home-conversation')).toHaveCount(4)
await expect(page.locator('.home-row__open').first()).toBeFocused()
await expect(page.getByTestId('home-undo-toast')).toBeVisible()
})
test('old conversations wait behind one button', async ({ page }) => {
const chats = sampleChats()
const old = Array.from({ length: 8 }, (_, i) => ({
...chats[0],
id: `old-${i}`,
name: `Old chat ${i}`,
updatedAt: Date.now() - (10 + i) * 24 * 60 * 60 * 1000,
}))
await page.addInitScript(([key, data]) => localStorage.setItem(key, JSON.stringify(data)),
[CHATS_KEY, { chats: [...chats, ...old], activeChatId: chats[0].id }])
await ready(page)
await expect(page.getByTestId('home-conversation')).toHaveCount(8)
await page.getByRole('button', { name: /show 5 older conversations/i }).click()
await expect(page.getByTestId('home-conversation')).toHaveCount(13)
await expect(page.getByTestId('home-day-older').locator('.home-day__label')).toContainText('Older')
})
test('with no conversations it says where they will appear', async ({ page }) => {
await page.addInitScript((key) => localStorage.removeItem(key), CHATS_KEY)
await page.unroute('**/api/features')
await mockHome(page)
await page.addInitScript((key) => localStorage.setItem(key, JSON.stringify({ chats: [], activeChatId: null })), CHATS_KEY)
await ready(page)
await expect(page.locator('.home-empty')).toContainText('Your conversations will wait here')
})
})
test.describe('Home first run', () => {
async function mockFirstRun(page) {
await mockHome(page, { loaded: [], models: [], chatModels: [] })
await page.route('**/api/models/estimate/*', route => route.fulfill({
json: { sizeBytes: 2 * GB, sizeDisplay: '2 GB', estimates: { 4096: { vramBytes: 3 * GB, vramDisplay: '3 GB' } } },
}))
await page.route('**/api/models?*', route => route.fulfill({
json: {
models: [
{ name: 'tiny-chat', backend: 'llama-cpp', installed: false, tags: ['chat'] },
{ name: 'small-chat', backend: 'llama-cpp', installed: false, tags: ['chat'] },
],
allBackends: [], allTags: [], availableModels: 2, installedModels: 0, totalPages: 1, currentPage: 1,
},
}))
}
test('teaches the three steps, with the recommended models inside the second', async ({ page }) => {
await mockFirstRun(page)
await ready(page)
const guide = page.getByTestId('home-first-run')
await expect(guide.getByRole('listitem')).toHaveCount(3)
await expect(guide.locator('.home-step[data-state="now"]')).toContainText('Browse the Model Gallery')
await expect(guide.locator('.home-step').nth(1).locator('.home-starters')).toContainText('tiny-chat')
await expect(guide.getByRole('button', { name: /browse model gallery/i })).toBeVisible()
await expect(guide.getByRole('button', { name: /import model/i })).toBeVisible()
})
test('the command bar waits for a model and the strip says nothing runs', async ({ page }) => {
await mockFirstRun(page)
await ready(page)
await expect(page.getByTestId('home-model-chip')).toContainText('No model selected')
await expect(page.getByTestId('home-model-chip')).toBeDisabled()
await expect(page.locator('.home-textarea')).toHaveAttribute('placeholder', 'Install a model to start chatting')
await expect(page.locator('.home-send-btn')).toBeDisabled()
await expect(page.getByTestId('home-strip-idle')).toContainText('No models running')
})
test('slash actions already work before a model is installed', async ({ page }) => {
await mockFirstRun(page)
await ready(page)
await page.locator('.home-textarea').fill('/imp')
await page.locator('.home-textarea').press('Enter')
await expect(page).toHaveURL(/\/app\/import-model/)
})
test('install starts from a recommended row', async ({ page }) => {
await mockFirstRun(page)
const installs = []
await page.route('**/api/models/install/*', route => {
installs.push(new URL(route.request().url()).pathname)
return route.fulfill({ json: { uuid: 'x', status: 'ok' } })
})
await ready(page)
await page.locator('.home-starters-item', { hasText: 'tiny-chat' }).getByRole('button', { name: /install/i }).click()
await expect.poll(() => installs.length).toBe(1)
expect(installs[0]).toContain('tiny-chat')
})
test('a person who is not an admin gets a calm explanation, not install steps', async ({ page }) => {
await mockFirstRun(page)
await mockUser(page)
await page.goto('/app')
await expect(page.getByRole('heading', { name: 'No Models Available' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('home-first-run')).toHaveCount(0)
await expect(page.locator('.home-textarea')).toHaveCount(0)
await expect(page.locator('.home-wizard-actions').getByRole('link', { name: /documentation/i })).toBeVisible()
})
})
test.describe('Home send hand-off', () => {
async function record(page) {
await page.addInitScript(() => {
window.__handoff = null
const set = Storage.prototype.setItem
Storage.prototype.setItem = function (key, value) {
if (key === 'localai_index_chat_data') window.__handoff = JSON.parse(value)
return set.call(this, key, value)
}
})
}
test.beforeEach(async ({ page }) => {
await mockHome(page)
await seedChats(page)
await record(page)
})
test('Send hands the message and model to Chat and opens it', async ({ page }) => {
await ready(page)
const send = page.locator('.home-send-btn')
await expect(send).toBeDisabled()
await page.locator('.home-textarea').fill('Compare the release plans')
await expect(send).toBeEnabled()
await send.click()
await expect(page).toHaveURL(/\/app\/chat\/qwen3-8b-instruct/)
const handoff = await page.evaluate(() => window.__handoff)
expect(handoff).toMatchObject({
message: 'Compare the release plans',
model: 'qwen3-8b-instruct',
newChat: true,
mcpMode: false,
mcpServers: [],
clientMCPServers: [],
files: [],
})
})
test('Enter sends and Shift+Enter adds a line', async ({ page }) => {
await ready(page)
const box = page.locator('.home-textarea')
await box.fill('first')
await box.press('Shift+Enter')
await box.type('second')
await expect(box).toHaveValue('first\nsecond')
await box.press('Enter')
await expect(page).toHaveURL(/\/app\/chat\//)
expect((await page.evaluate(() => window.__handoff)).message).toBe('first\nsecond')
})
test('the picked model travels with it', async ({ page }) => {
await ready(page)
await page.getByTestId('home-model-chip').click()
await page.getByRole('option', { name: /gemma-4-e4b/ }).click()
await page.locator('.home-textarea').fill('hello')
await page.locator('.home-send-btn').click()
await expect(page).toHaveURL(/\/app\/chat\/gemma-4-e4b-it-qat-q4_0/)
expect((await page.evaluate(() => window.__handoff)).model).toBe('gemma-4-e4b-it-qat-q4_0')
})
test('attachments travel with it', async ({ page }) => {
await ready(page)
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('Meeting notes'),
})
const tag = page.locator('.home-file-tag', { hasText: 'notes.txt' })
await expect(tag).toBeVisible()
// An attachment alone is enough to send.
await expect(page.locator('.home-send-btn')).toBeEnabled()
await page.locator('.home-textarea').fill('Summarise')
await page.locator('.home-send-btn').click()
await expect(page).toHaveURL(/\/app\/chat\//)
const handoff = await page.evaluate(() => window.__handoff)
expect(handoff.files).toHaveLength(1)
expect(handoff.files[0]).toMatchObject({ name: 'notes.txt', textContent: 'Meeting notes' })
})
test('an attachment can be removed before sending', async ({ page }) => {
await ready(page)
await page.locator('input[type=file][accept*="pdf"]').setInputFiles({
name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('x'),
})
await page.getByRole('button', { name: 'Remove notes.txt' }).click()
await expect(page.locator('.home-file-tag')).toHaveCount(0)
await expect(page.locator('.home-send-btn')).toBeDisabled()
})
test('the MCP selection travels with it', async ({ page }) => {
await page.route('**/api/models/config-json/qwen3-8b-instruct', route => route.fulfill({
json: { name: 'qwen3-8b-instruct', mcp: { remote: 'mcpServers:\n ordino:\n url: http://ordino:8080/mcp' } },
}))
await page.route('**/v1/mcp/servers/qwen3-8b-instruct', route => route.fulfill({
json: { model: 'qwen3-8b-instruct', servers: [{ name: 'ordino', type: 'remote', tools: [{ name: 't' }] }] },
}))
await ready(page)
await page.locator('.chat-mcp-dropdown > button').click()
await page.getByRole('button', { name: 'Servers', exact: true }).click()
await page.locator('.chat-mcp-server-item', { hasText: 'ordino' }).getByRole('checkbox').check()
await page.locator('.home-textarea').fill('use the tool')
await page.locator('.home-send-btn').click()
await expect(page).toHaveURL(/\/app\/chat\//)
expect((await page.evaluate(() => window.__handoff)).mcpServers).toEqual(['ordino'])
})
test('the assistant line opens a chat in assistant mode and then steps aside', async ({ page }) => {
await ready(page)
const tip = page.getByTestId('home-assistant-tip')
await expect(tip).toContainText('Manage LocalAI by chatting')
await tip.getByRole('button', { name: 'Open assistant' }).click()
await expect(page).toHaveURL(/\/app\/chat$/)
const handoff = await page.evaluate(() => window.__handoff)
expect(handoff).toMatchObject({ localaiAssistant: true, newChat: true })
})
test('the assistant line can be dismissed and stays dismissed', async ({ page }) => {
await ready(page)
await page.getByTestId('home-assistant-tip').getByRole('button', { name: 'Dismiss' }).click()
await expect(page.getByTestId('home-assistant-tip')).toHaveCount(0)
await page.reload()
await expect(page.locator('.home-greeting')).toBeVisible()
await expect(page.getByTestId('home-assistant-tip')).toHaveCount(0)
// It moves to the library row.
await expect(page.getByTestId('home-library')).toContainText('Manage by chat')
})
})
test.describe('Home on a phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test.beforeEach(async ({ page }) => {
await mockHome(page)
await seedChats(page)
})
test('nothing scrolls sideways', async ({ page }) => {
await ready(page)
await expect(page.getByTestId('home-conversation').first()).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
test('the command bar, strip and resume list fit the width', async ({ page }) => {
await ready(page)
for (const sel of ['.home-cmd', '.home-strip', '.home-day__card']) {
const box = await page.locator(sel).first().boundingBox()
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
}
// The hints that need a keyboard are hidden.
await expect(page.locator('.home-slash-hint')).toBeHidden()
await expect(page.locator('.home-resume__keys')).toBeHidden()
})
test('Send stays on the right and the three attach buttons stay', async ({ page }) => {
await ready(page)
await expect(page.locator('.home-attach-btn')).toHaveCount(3)
const send = await page.locator('.home-send-btn').boundingBox()
const cmd = await page.locator('.home-cmd').boundingBox()
expect(send.x + send.width).toBeGreaterThan(cmd.x + cmd.width - 16)
})
test('a conversation can be deleted without a hover', async ({ page }) => {
await ready(page)
const row = page.getByTestId('home-conversation').first()
const del = row.getByRole('button', { name: /delete conversation/i })
await expect(del).toBeVisible()
await del.click()
await expect(page.getByTestId('home-undo-toast')).toBeVisible()
await expect(page.getByTestId('home-conversation')).toHaveCount(4)
})
test('the slash list stays inside the screen', async ({ page }) => {
await ready(page)
await page.locator('.home-textarea').fill('/')
const box = await page.locator('.home-slash').boundingBox()
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
})
test('first run fits too', async ({ page }) => {
await page.unroute('**/system')
await page.unroute('**/v1/models')
await page.route('**/system', route => route.fulfill({ json: { backends: [], loaded_models: [] } }))
await page.route('**/v1/models', route => route.fulfill({ json: { data: [] } }))
await ready(page)
await expect(page.getByTestId('home-first-run')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
})
test.describe('Home reduced motion', () => {
test('rows and menus do not animate', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockHome(page)
await seedChats(page)
await ready(page)
await page.locator('.home-textarea').fill('/')
const name = await page.locator('.home-slash').evaluate(el => getComputedStyle(el).animationName)
expect(name).toBe('none')
})
})
+98
View File
@@ -0,0 +1,98 @@
// Shared fixtures for the Home page specs: stubbed API routes and conversations
// written to the browser storage the Chat page uses.
export const CHATS_KEY = 'localai_chats_data'
export const CHAT_MODELS = [
'qwen3-8b-instruct',
'gemma-4-e4b-it-qat-q4_0',
'qwen2.5-vl-7b-instruct',
'qwen3-32b-instruct',
]
const GB = 1024 * 1024 * 1024
// One GPU, 24 GB, 8.8 GB in use.
export const GPU_RESOURCES = {
type: 'gpu',
available: true,
gpus: [{ name: 'RTX 4090', vendor: 'nvidia', total_vram: 24 * GB, used_vram: 8.8 * GB, usage_percent: 36.7 }],
ram: { total: 64 * GB, used: 18.2 * GB, free: 45.8 * GB, available: 45.8 * GB, usage_percent: 28.4 },
aggregate: { total_memory: 24 * GB, used_memory: 8.8 * GB, free_memory: 15.2 * GB, usage_percent: 36.7, gpu_count: 1 },
}
export const LOADED = [
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp' },
{ id: 'whisper-large-v3-turbo', backend: 'whisper' },
]
// Stub what Home reads. `models` are the installed ids (v1 list) and
// `chatModels` the ones the picker offers.
export async function mockHome(page, {
loaded = LOADED,
models = [...CHAT_MODELS, 'whisper-large-v3-turbo'],
chatModels = CHAT_MODELS,
resources = GPU_RESOURCES,
features = { distributed: false, localai_assistant: true, agents: true, mcp: true },
nodes = null,
operations = [],
} = {}) {
await page.route('**/api/features', route => route.fulfill({ json: features }))
await page.route('**/system', route => route.fulfill({ json: { backends: ['llama-cpp'], loaded_models: loaded } }))
await page.route('**/v1/models', route => route.fulfill({ json: { data: models.map(id => ({ id })) } }))
await page.route('**/api/models/capabilities', route =>
route.fulfill({ json: { data: chatModels.map(id => ({ id, capabilities: ['FLAG_CHAT'] })) } }))
await page.route('**/api/resources', route => route.fulfill({ json: resources }))
await page.route('**/api/operations', route => route.fulfill({ json: operations }))
if (nodes) await page.route('**/api/nodes', route => route.fulfill({ json: nodes }))
}
// Conversations: today twice, yesterday twice, three days ago once.
export function sampleChats(now = Date.now()) {
const at = (daysAgo, hour, minute) => {
const d = new Date(now)
d.setDate(d.getDate() - daysAgo)
d.setHours(hour, minute, 0, 0)
return d.getTime()
}
// 90 minutes ago, but never before midnight: it must stay in today's group.
const earlierToday = Math.max(now - 90 * 60_000, at(0, 0, 1))
const chat = (id, name, model, updatedAt, history) => ({
id, name, model, history, updatedAt, createdAt: updatedAt - 60_000,
systemPrompt: '', mcpMode: false, mcpServers: [], clientMCPServers: [],
temperature: null, topP: null, topK: null,
tokenUsage: { prompt: 0, completion: 0, total: 0 }, contextSize: null,
})
const pair = (q, a) => [{ role: 'user', content: q }, { role: 'assistant', content: a }]
return [
chat('c-today-a', 'Rewrite the 3.4 release notes for clarity', 'qwen3-8b-instruct', now - 60_000,
[...pair('Rewrite the release notes', 'Moved Migration to the top and cut the intro to two sentences.'), ...pair('Shorter', 'Done.'), ...pair('Thanks', 'Anytime.')]),
chat('c-today-b', 'Why does llama-cpp stall at a 4096 context', 'qwen3-8b-instruct', earlierToday,
[...pair('Why does it stall?', 'Check flash attention and the KV cache type first.'), ...pair('Which type?', 'q8_0 is a safe default.')]),
chat('c-yday-a', 'Describe this architecture diagram', 'qwen2.5-vl-7b-instruct', at(1, 18, 22),
[{ role: 'user', content: [{ type: 'text', text: 'Describe this diagram' }, { type: 'image_url', image_url: { url: 'data:image/png;base64,AAAA' } }] },
{ role: 'assistant', content: 'Three services behind one gateway, two share a queue.' },
...pair('Which two?', 'The billing and the audit service.')].slice(0, 3)),
chat('c-yday-b', 'Draft a polite reply about the invoice', 'gemma-4-e4b-it-qat-q4_0', at(1, 9, 5),
pair('Draft a reply about the invoice', 'Thanks for flagging this, I will check with accounting.')),
chat('c-week-a', 'Regex for semver with prerelease tags', 'qwen3-8b-instruct', at(3, 16, 30),
[...pair('Regex for semver', 'Use a named group for the prerelease part, then test it.'), { role: 'user', content: 'Thanks' }]),
]
}
export async function seedChats(page, chats = sampleChats(), activeChatId = chats[0]?.id) {
await page.addInitScript(([key, data]) => {
if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(data))
}, [CHATS_KEY, { chats, activeChatId, lastSaved: Date.now() }])
}
export async function setTheme(page, mode) {
await page.addInitScript((m) => localStorage.setItem('localai-theme', m), mode)
}
// A signed-in person who is not an admin.
export async function mockUser(page, permissions = {}) {
await page.route('**/api/auth/status', route => route.fulfill({
json: { authEnabled: true, staticApiKeyRequired: false, providers: ['local'], user: { id: 'u1', name: 'Sam', role: 'user', permissions } },
}))
}
+43 -35
View File
@@ -1,84 +1,92 @@
import { test, expect } from './coverage-fixtures.js'
import { mockHome } from './home-fixtures.js'
// Home's resident-model list and the app footer.
// Home's resident models (inside the memory strip) and the app footer.
const SYS_INFO = {
backends: ['llama-cpp'],
loaded_models: [
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp' },
{ id: 'parakeet-tdt-0.6b' },
],
}
const LOADED = [
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp' },
{ id: 'parakeet-tdt-0.6b' },
]
async function mockLoaded(page) {
await page.route('**/system', route => route.fulfill({ json: SYS_INFO }))
await page.route('**/v1/models', route =>
route.fulfill({ json: { data: [{ id: 'qwen3-8b-instruct' }, { id: 'parakeet-tdt-0.6b' }] } }))
await mockHome(page, { loaded: LOADED, models: ['qwen3-8b-instruct', 'parakeet-tdt-0.6b'], chatModels: ['qwen3-8b-instruct'] })
}
async function openStrip(page) {
await page.locator('.home-strip__head').click()
await expect(page.locator('.home-loaded')).toBeVisible()
}
test.describe('Home resident models', () => {
test('resident models read as lanes, not status chips', async ({ page }) => {
test('resident models read as rows, not status chips', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lanes = page.locator('.home-loaded .lane')
await expect(lanes).toHaveCount(2)
await openStrip(page)
const rows = page.locator('.home-loaded__row')
await expect(rows).toHaveCount(2)
// Model ids are identifiers, so they are set in mono like every other
// identifier in the app.
const family = await lanes.first().locator('.lane__name').evaluate(
const family = await rows.first().locator('code').evaluate(
el => getComputedStyle(el).fontFamily.toLowerCase())
expect(family).toMatch(/mono|consol|menlo/)
})
test('each lane keeps its stop control', async ({ page }) => {
test('each row keeps its stop control', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lane = page.locator('.home-loaded .lane').first()
await expect(lane.getByRole('button', { name: /stop/i })).toBeVisible()
await openStrip(page)
const row = page.locator('.home-loaded__row').first()
await expect(row.getByRole('button', { name: /stop/i })).toBeAttached()
// Hidden until the row is hovered or focused, but reachable by keyboard.
await row.hover()
await expect(row.getByRole('button', { name: /stop/i })).toBeVisible()
})
test('the header reports how many are resident as a figure', async ({ page }) => {
test('the strip reports how many are resident as a figure', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const stat = page.locator('[data-testid="home-stat-loaded"]')
await expect(stat).toBeVisible()
await expect(stat).toContainText('2')
// Digits that sit in a column need to line up.
const numeric = await stat.locator('.home-stat__value').evaluate(
el => getComputedStyle(el).fontVariantNumeric)
// The count sits in a line of figures; digits that sit in a column need to
// line up.
const numeric = await page.locator('.home-strip__head').evaluate(
el => getComputedStyle(el.querySelector('.home-fig') || el).fontVariantNumeric)
expect(numeric).toContain('tabular-nums')
})
test('a resident model names the engine serving it', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
// Lanes are sorted by id, so target by content rather than position.
const qwen = page.locator('.home-loaded .lane', { hasText: 'qwen3-8b-instruct' })
await openStrip(page)
const qwen = page.locator('.home-loaded__row', { hasText: 'qwen3-8b-instruct' })
await expect(qwen).toContainText('llama-cpp')
})
test('a model without a config shows no engine rather than a guess', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
// parakeet has no backend in the payload; the column stays blank.
const parakeet = page.locator('.home-loaded .lane', { hasText: 'parakeet-tdt-0.6b' })
await openStrip(page)
// parakeet has no backend in the payload; no engine is written.
const parakeet = page.locator('.home-loaded__row', { hasText: 'parakeet-tdt-0.6b' })
await expect(parakeet).not.toContainText('llama-cpp')
await expect(parakeet.locator('small')).toHaveCount(0)
})
test('jump-back-in offers the three places worth returning to', async ({ page }) => {
test('no per-model size is invented: the API reports none', async ({ page }) => {
await mockLoaded(page)
await page.goto('/app')
const lanes = page.locator('.lanes--jump .lane')
await expect(lanes).toHaveCount(3)
await expect(lanes.first()).toContainText('Models')
await openStrip(page)
await expect(page.locator('.home-loaded')).not.toContainText(/\bGB\b/)
})
test('nothing resident still says so', async ({ page }) => {
await page.route('**/system', route =>
route.fulfill({ json: { backends: ['llama-cpp'], loaded_models: [] } }))
await page.route('**/v1/models', route => route.fulfill({ json: { data: [{ id: 'a-model' }] } }))
await mockHome(page, { loaded: [], models: ['a-model'], chatModels: ['a-model'] })
await page.goto('/app')
await expect(page.locator('.home-loaded-empty')).toBeVisible()
await expect(page.locator('.home-loaded .lane')).toHaveCount(0)
await expect(page.locator('[data-testid="home-stat-loaded-none"]')).toBeVisible()
await page.locator('.home-strip__head').click()
await expect(page.locator('.home-loaded__empty')).toBeVisible()
await expect(page.locator('.home-loaded__row')).toHaveCount(0)
})
})
+61 -14
View File
@@ -1,7 +1,8 @@
import { test, expect } from './coverage-fixtures.js'
import { mockHome } from './home-fixtures.js'
test.describe('Home editorial redesign', () => {
test('renders the editorial greeting header in the sans display font', async ({ page }) => {
test.describe('Home console', () => {
test('renders the greeting header in the sans display font', async ({ page }) => {
await page.goto('/app')
const greeting = page.locator('.home-greeting')
await expect(greeting).toBeVisible({ timeout: 15_000 })
@@ -11,21 +12,67 @@ test.describe('Home editorial redesign', () => {
expect(family.toLowerCase()).not.toContain('fraunces')
})
test('quick links expose a single primary action', async ({ page }) => {
test('the greeting carries the date', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.home-greeting, .empty-state-title').first()).toBeVisible({ timeout: 15_000 })
const primaries = page.locator('.home-quick-links .btn-primary')
// At most one primary CTA in the quick-links row.
expect(await primaries.count()).toBeLessThanOrEqual(1)
await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 })
const date = await page.locator('.home-date').textContent()
expect(date).toMatch(/\d/)
})
test('loaded-models block uses an editorial section heading', async ({ page }) => {
test('the library row stays quiet: no filled action in it', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.home-greeting').first()).toBeVisible({ timeout: 15_000 })
// The refined loaded-models block introduces a SectionHeading; the legacy
// inline ".home-loaded-text" label is gone.
const heading = page.locator('.home-loaded .section-heading')
await expect(heading).toBeVisible({ timeout: 15_000 })
await expect(heading).toHaveText(/active models/i)
await expect(page.locator('.home-greeting')).toBeVisible({ timeout: 15_000 })
// The only solid accent action on the page is Send. Library links are text.
await expect(page.locator('.home-libline .home-primary, .home-libline .btn-primary')).toHaveCount(0)
})
test('the library row keeps gallery, installed models, import and docs', async ({ page }) => {
await page.goto('/app')
const row = page.locator('[data-testid="home-library"]')
await expect(row).toBeVisible({ timeout: 15_000 })
for (const name of [/browse gallery/i, /installed models/i, /import model/i, /documentation/i]) {
await expect(row.getByText(name)).toBeVisible()
}
})
test('on a phone the memory strip keeps its sentence whole and wraps the figures below it', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockHome(page)
await page.goto('/app')
const head = page.locator('.home-strip__head')
await expect(head).toBeVisible({ timeout: 15_000 })
const sentence = head.locator('> span:not([class])').first()
await expect(sentence).toContainText(/models loaded/)
// Not clipped by an ellipsis: the text fits its box.
const clipped = await sentence.evaluate(el => el.scrollWidth > el.clientWidth + 1)
expect(clipped).toBe(false)
// The figure sits on its own line under the sentence, inside the strip.
const box = await head.boundingBox()
const sentenceBox = await sentence.boundingBox()
const figure = await head.locator('.home-fig').boundingBox()
expect(figure.y).toBeGreaterThanOrEqual(sentenceBox.y + sentenceBox.height - 1)
expect(figure.x + figure.width).toBeLessThanOrEqual(box.x + box.width)
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
})
test('loaded models sit behind the memory strip, labelled Active models', async ({ page }) => {
await mockHome(page)
await page.goto('/app')
const strip = page.locator('.home-strip')
await expect(strip).toBeVisible({ timeout: 15_000 })
// Collapsed by default: the list is one click away.
await expect(page.getByRole('list', { name: /active models/i })).toHaveCount(0)
await strip.locator('.home-strip__head').click()
await expect(page.getByRole('list', { name: /active models/i })).toBeVisible()
})
test('the API section is collapsed until asked for', async ({ page }) => {
await page.goto('/app')
const head = page.locator('.home-connect__head')
await expect(head).toBeVisible({ timeout: 15_000 })
await expect(head).toHaveAttribute('aria-expanded', 'false')
await expect(page.locator('.home-connect-apis')).toHaveCount(0)
await head.click()
await expect(page.locator('.home-connect-apis').first()).toBeVisible()
})
})
+232
View File
@@ -0,0 +1,232 @@
import { test, expect } from './coverage-fixtures.js'
// The Build and Operate hubs replace the second navigation rail with a tab bar
// (the kit's dk-hubtabs). Routes stay flat; the tab that owns the current
// path carries aria-current="page", and a tab nobody can use is not drawn.
const BAR = '.dk-hubtabs'
const tab = (page, id) => page.locator(`${BAR} [data-hub-tab="${id}"]`)
async function mockFeatures(page, features) {
await page.route('**/api/features', route => route.fulfill({ json: features }))
}
async function mockAuth(page, user, authEnabled = true) {
await page.route('**/api/auth/status', route => route.fulfill({
json: { authEnabled, staticApiKeyRequired: false, providers: ['local'], user },
}))
}
async function quietOperate(page) {
await page.route('**/api/backends/upgrades', route => route.fulfill({ json: {} }))
await page.route('**/api/operations', route => route.fulfill({ json: [] }))
await page.route('**/api/nodes', route => route.fulfill({ json: [{ id: 'n1', name: 'atlas', status: 'healthy', healthy: true }] }))
}
test.describe('Hub tabs: the right tab for each route', () => {
const build = [
['/app/build', 'overview'],
['/app/agents', 'agents'],
['/app/skills', 'skills'],
['/app/collections', 'memory'],
['/app/agent-jobs', 'jobs'],
['/app/fine-tune', 'fine-tune'],
['/app/quantize', 'quantize'],
['/app/import-model', 'import'],
['/app/voice', 'voices'],
['/app/voice-library', 'voices'],
['/app/face', 'faces'],
]
for (const [path, id] of build) {
test(`Build: ${path} highlights ${id}`, async ({ page }) => {
await page.goto(path)
await expect(tab(page, id)).toHaveAttribute('aria-current', 'page')
await expect(page.locator(`${BAR} [aria-current="page"]`)).toHaveCount(1)
})
}
const operate = [
['/app/operate', 'status'],
['/app/nodes', 'machine'],
['/app/backends', 'runtime'],
['/app/activity', 'runtime'],
['/app/failover', 'runtime'],
['/app/traffic', 'traffic'],
['/app/traffic/models', 'traffic'],
['/app/traffic/host', 'traffic'],
['/app/traffic/prometheus', 'traffic'],
['/app/usage', 'traffic'],
['/app/traces', 'traffic'],
['/app/traces/t1', 'traffic'],
['/app/middleware', 'traffic'],
['/app/settings', 'settings'],
]
for (const [path, id] of operate) {
test(`Operate: ${path} highlights ${id}`, async ({ page }) => {
await quietOperate(page)
await page.goto(path)
await expect(tab(page, id)).toHaveAttribute('aria-current', 'page')
await expect(page.locator(`${BAR} [aria-current="page"]`)).toHaveCount(1)
})
}
test('Operate: backend log pages keep the Runtime tab', async ({ page }) => {
await page.goto('/app/backend-logs/some-model')
await expect(tab(page, 'runtime')).toHaveAttribute('aria-current', 'page')
})
test('Swarm owns nodes, placement rules, failover and p2p when distributed mode is on', async ({ page }) => {
await mockFeatures(page, { distributed: true, agents: true, mcp: true })
await quietOperate(page)
for (const path of ['/app/nodes', '/app/nodes/n1', '/app/nodes/add', '/app/scheduling', '/app/failover', '/app/p2p', '/app/node-backend-logs/n1/some-model']) {
await page.goto(path)
await expect(tab(page, 'swarm')).toHaveAttribute('aria-current', 'page')
await expect(tab(page, 'machine')).toHaveCount(0)
}
})
test('the second row marks the current route inside a tab', async ({ page }) => {
await page.goto('/app/activity')
const sub = page.locator('.hub-subnav')
await expect(sub.getByRole('link', { name: 'Activity' })).toHaveAttribute('aria-current', 'page')
await expect(sub.getByRole('link', { name: 'Backends' })).not.toHaveAttribute('aria-current', 'page')
await sub.getByRole('link', { name: 'Backends' }).click()
await expect(page).toHaveURL(/\/app\/backends$/)
await expect(sub.getByRole('link', { name: 'Backends' })).toHaveAttribute('aria-current', 'page')
})
test('a tab link goes to the first route of the tab', async ({ page }) => {
await page.goto('/app/settings')
await tab(page, 'traffic').click()
await expect(page).toHaveURL(/\/app\/traffic$/)
await expect(tab(page, 'traffic')).toHaveAttribute('aria-current', 'page')
})
})
test.describe('Hub tabs: gating', () => {
test('Swarm is hidden on a single node and This machine is shown', async ({ page }) => {
await mockFeatures(page, { distributed: false })
await quietOperate(page)
await page.goto('/app/operate')
await expect(tab(page, 'machine')).toBeVisible()
await expect(tab(page, 'swarm')).toHaveCount(0)
})
test('Swarm shows with distributed mode on, and the tab opens the Nodes route', async ({ page }) => {
await mockFeatures(page, { distributed: true })
await quietOperate(page)
await page.goto('/app/operate')
await expect(tab(page, 'swarm')).toHaveAttribute('href', '/app/nodes')
await expect(tab(page, 'machine')).toHaveCount(0)
})
test('feature flags hide the tabs they gate', async ({ page }) => {
await mockFeatures(page, { agents: false, mcp: false, fine_tuning: false, quantization: false, distributed: false })
await page.goto('/app/build')
await expect(tab(page, 'overview')).toBeVisible()
for (const id of ['agents', 'skills', 'memory', 'jobs', 'fine-tune', 'quantize']) {
await expect(tab(page, id)).toHaveCount(0)
}
// The overview lists only what the bar lists.
await expect(page.getByRole('list', { name: 'Build tools' }).locator('a[href="/app/agents"]')).toHaveCount(0)
await expect(page.getByRole('list', { name: 'Build tools' }).locator('a[href="/app/import-model"]')).toBeVisible()
})
test('a user without admin or permissions sees only the tabs they can use', async ({ page }) => {
await mockFeatures(page, { agents: true, mcp: true, fine_tuning: true, quantization: true })
await mockAuth(page, { id: 'u1', name: 'Sam', role: 'user', permissions: { agents: true } })
await page.goto('/app/agents')
await expect(tab(page, 'agents')).toHaveAttribute('aria-current', 'page')
for (const id of ['skills', 'memory', 'jobs', 'import', 'voices', 'faces']) {
await expect(tab(page, id)).toHaveCount(0)
}
// Fine-tune and quantize are feature flags with no permission for this user.
await expect(tab(page, 'fine-tune')).toHaveCount(0)
})
test('a non-admin user gets no Operate entry in the sidebar', async ({ page }) => {
await mockAuth(page, { id: 'u1', name: 'Sam', role: 'user', permissions: { agents: true } })
await page.goto('/app')
await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toHaveCount(0)
await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toBeVisible()
})
test('Users appears in the Settings row only with auth on', async ({ page }) => {
await mockAuth(page, { id: 'a', name: 'Admin', role: 'admin', provider: 'local' })
await page.goto('/app/users')
await expect(tab(page, 'settings')).toHaveAttribute('aria-current', 'page')
await expect(page.locator('.hub-subnav a[href="/app/users"]')).toHaveAttribute('aria-current', 'page')
})
})
test.describe('Hub tabs: badges', () => {
test('no badge is drawn on a quiet install', async ({ page }) => {
await quietOperate(page)
await page.goto('/app/operate')
await expect(tab(page, 'status')).toBeVisible()
await expect(page.locator(`${BAR} .dk-hubtab-attn`)).toHaveCount(0)
})
test('failed operations raise the Status attention badge', async ({ page }) => {
await quietOperate(page)
await page.route('**/api/operations', route => route.fulfill({
json: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', error: 'no space left on device' }],
}))
await page.goto('/app/operate')
await expect(tab(page, 'status').locator('.dk-hubtab-attn')).toContainText('1')
})
test('the Build bar carries no badges', async ({ page }) => {
await page.goto('/app/agents')
await expect(tab(page, 'agents')).toBeVisible()
await expect(page.locator(`${BAR} .dk-hubtab-attn, ${BAR} .dk-hubtab-count`)).toHaveCount(0)
})
})
test.describe('Hub tabs: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('the Build bar scrolls inside its own box and the page does not', async ({ page }) => {
await page.goto('/app/build')
const bar = page.locator(BAR)
await expect(bar).toBeVisible()
expect((await bar.boundingBox()).width).toBeLessThanOrEqual(390)
expect(await bar.evaluate(el => el.scrollWidth > el.clientWidth)).toBe(true)
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
await expect(bar).toHaveCSS('overflow-x', 'auto')
})
test('scrolling reaches the last tab and keeps it tappable', async ({ page }) => {
await page.goto('/app/build')
const last = tab(page, 'faces')
await last.scrollIntoViewIfNeeded()
await expect(last).toBeInViewport()
await last.click()
await expect(page).toHaveURL(/\/app\/face$/)
await expect(tab(page, 'faces')).toHaveAttribute('aria-current', 'page')
})
test('every tab is at least 36px tall', async ({ page }) => {
await page.goto('/app/build')
const heights = await page.locator(`${BAR} .dk-hubtab`).evaluateAll(els => els.map(el => el.getBoundingClientRect().height))
expect(Math.min(...heights)).toBeGreaterThanOrEqual(36)
})
})
test.describe('Hub tabs: reveal on route', () => {
test('Swarm does not appear next to This machine on the Nodes route', async ({ page }) => {
await mockFeatures(page, { distributed: false })
await quietOperate(page)
await page.goto('/app/nodes')
await expect(tab(page, 'machine')).toHaveAttribute('aria-current', 'page')
await expect(tab(page, 'swarm')).toHaveCount(0)
})
test('a direct link to P2P keeps its Swarm tab on a single node', async ({ page }) => {
await mockFeatures(page, { distributed: false })
await quietOperate(page)
await page.goto('/app/p2p')
await expect(tab(page, 'swarm')).toHaveAttribute('aria-current', 'page')
await expect(page.locator('.hub-subnav a[href="/app/nodes"]')).toHaveCount(0)
})
})
+104
View File
@@ -0,0 +1,104 @@
import { test, expect } from './coverage-fixtures.js'
import { readFileSync } from 'node:fs'
import { join, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'
import { iconFromFa, faSpins, FALLBACK_ICON } from '../src/utils/faIcon.js'
// Icons are inline svg elements that point into the kit sprite, which the app
// inlines into the page once. A name the sprite lacks, or a sprite that does
// not resolve, draws nothing and fails no build, so these tests check both.
const HERE = dirname(fileURLToPath(import.meta.url))
const VENDOR = join(HERE, '..', 'src', 'vendor', 'ui-kit', 'icons')
const faMap = JSON.parse(readFileSync(join(VENDOR, 'fa-map.json'), 'utf8'))
const spriteIds = [...readFileSync(join(VENDOR, 'sprite.svg'), 'utf8').matchAll(/id="dk-icon-([^"]+)"/g)].map((m) => m[1])
const mappedIds = [...new Set(Object.entries(faMap).filter(([k]) => k !== 'unmapped').map(([, v]) => v))]
test.describe('icon sprite', () => {
test('every icon id in the Font Awesome map has a symbol in the sprite', () => {
expect(mappedIds.length).toBeGreaterThan(150)
expect(mappedIds.filter((id) => !spriteIds.includes(id))).toEqual([])
})
test('the page holds the sprite and every mapped symbol draws something', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar-nav svg[data-icon]').first()).toBeVisible({ timeout: 15_000 })
const result = await page.evaluate((ids) => {
const missing = []
const empty = []
for (const id of ids) {
const symbol = document.getElementById(`dk-icon-${id}`)
if (!symbol) missing.push(id)
else if (!symbol.querySelector('path, circle, rect, line, polyline, ellipse')) empty.push(id)
}
return { missing, empty, holders: document.querySelectorAll('#lai-icon-sprite').length }
}, mappedIds)
expect(result.missing).toEqual([])
expect(result.empty).toEqual([])
// Inlined once, however many icons the page draws.
expect(result.holders).toBe(1)
})
test('an icon paints pixels, in the root build', async ({ page }) => {
await page.goto('/app')
const icon = page.locator('.sidebar-nav svg[data-icon]').first()
await expect(icon).toBeVisible({ timeout: 15_000 })
await expectPainted(page, icon)
})
test('an icon still resolves when the app is served under a path prefix', async ({ browser, baseURL }) => {
const context = await browser.newContext({ baseURL, extraHTTPHeaders: { 'X-Forwarded-Prefix': '/llm' } })
const page = await context.newPage()
await page.goto('/llm/app')
const base = await page.evaluate(() => document.querySelector('base')?.href)
expect(base).toContain('/llm/')
const icon = page.locator('.sidebar-nav svg[data-icon]').first()
await expect(icon).toBeVisible({ timeout: 15_000 })
await expectPainted(page, icon)
await context.close()
})
})
// A uniform square compresses to a tiny PNG; a line icon does not. Compare with
// a blank element of the same size so the check does not depend on the theme.
async function expectPainted(page, icon) {
const box = await icon.boundingBox()
const drawn = await icon.screenshot()
const blank = await page.evaluate(({ w, h }) => {
const el = document.createElement('div')
el.id = 'icon-blank-probe'
el.style.cssText = `position:fixed;left:2px;bottom:2px;width:${w}px;height:${h}px;background:Canvas`
document.body.appendChild(el)
return el.id
}, { w: Math.round(box.width), h: Math.round(box.height) })
const empty = await page.locator(`#${blank}`).screenshot()
expect(drawn.length).toBeGreaterThan(empty.length)
}
test.describe('Font Awesome names', () => {
test('a name maps with or without its prefix and style class', () => {
expect(iconFromFa('trash')).toBe('trash')
expect(iconFromFa('fa-trash')).toBe('trash')
expect(iconFromFa('fas fa-trash')).toBe('trash')
expect(iconFromFa('fa-solid fa-xmark')).toBe('close')
expect(iconFromFa('far fa-copy')).toBe('copy')
expect(iconFromFa('fa-spinner fa-spin')).toBe('spinner')
expect(faSpins('fas fa-spinner fa-spin')).toBe(true)
expect(faSpins('fas fa-spinner')).toBe(false)
})
test('every mapped name resolves to a symbol in the sprite', () => {
const names = Object.keys(faMap).filter((k) => k !== 'unmapped')
expect(names.length).toBeGreaterThan(200)
expect(names.filter((n) => !spriteIds.includes(iconFromFa(`fas fa-${n}`)))).toEqual([])
})
test('brand marks resolve to the local glyphs and unknown names fall back', () => {
expect(iconFromFa('fab fa-github')).toBe('github')
expect(iconFromFa('fas fa-apple-whole')).toBe('apple')
expect(spriteIds).toContain(FALLBACK_ICON)
// An unknown name passes through; the Icon component swaps it for the fallback.
expect(iconFromFa('')).toBe(FALLBACK_ICON)
expect(iconFromFa('fa-fw')).toBe(FALLBACK_ICON)
})
})
+110
View File
@@ -0,0 +1,110 @@
// Stubs and sample files for the Voices and Faces specs: a short sine WAV, a
// small PNG, and the routes the two pages call.
import { deflateSync } from 'node:zlib'
export const SPEAKER_MODEL = 'wespeaker-resnet34'
export const FACE_MODEL = 'insightface-buffalo-l'
export const VOICE_KEY = 'localai_voice_enrollments'
export const FACE_KEY = 'localai_face_enrollments'
export function wav(seconds = 4, amplitude = 0.4) {
const rate = 16000
const n = rate * seconds
const buf = Buffer.alloc(44 + n * 2)
buf.write('RIFF', 0); buf.writeUInt32LE(36 + n * 2, 4); buf.write('WAVEfmt ', 8)
buf.writeUInt32LE(16, 16); buf.writeUInt16LE(1, 20); buf.writeUInt16LE(1, 22)
buf.writeUInt32LE(rate, 24); buf.writeUInt32LE(rate * 2, 28); buf.writeUInt16LE(2, 32); buf.writeUInt16LE(16, 34)
buf.write('data', 36); buf.writeUInt32LE(n * 2, 40)
for (let i = 0; i < n; i += 1) {
const env = 0.5 + 0.5 * Math.sin(i / 2400)
buf.writeInt16LE(Math.round(Math.sin(i * 0.06) * env * amplitude * 32767), 44 + i * 2)
}
return buf
}
const crcTable = Array.from({ length: 256 }, (_, n) => { let c = n; for (let k = 0; k < 8; k += 1) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; return c >>> 0 })
const crc = (b) => { let c = 0xffffffff; for (const x of b) c = crcTable[(c ^ x) & 255] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0 }
function chunk(type, data) {
const len = Buffer.alloc(4); len.writeUInt32BE(data.length)
const body = Buffer.concat([Buffer.from(type), data])
const sum = Buffer.alloc(4); sum.writeUInt32BE(crc(body))
return Buffer.concat([len, body, sum])
}
export function png(w = 160, h = 120) {
const raw = Buffer.alloc((w * 3 + 1) * h)
for (let y = 0; y < h; y += 1) {
raw[y * (w * 3 + 1)] = 0
for (let x = 0; x < w; x += 1) {
const o = y * (w * 3 + 1) + 1 + x * 3
const face = Math.hypot(x - w / 2, y - h / 2.2) < h / 3
raw[o] = face ? 196 : 120 + (y * 40) / h
raw[o + 1] = face ? 168 : 140
raw[o + 2] = face ? 150 : 150 + (x * 40) / w
}
}
const head = Buffer.alloc(13); head.writeUInt32BE(w, 0); head.writeUInt32BE(h, 4); head[8] = 8; head[9] = 2
return Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), chunk('IHDR', head), chunk('IDAT', deflateSync(raw)), chunk('IEND', Buffer.alloc(0))])
}
export const clipFile = (name = 'board-sync-clip.wav', seconds = 8) => ({ name, mimeType: 'audio/wav', buffer: wav(seconds) })
export const photoFile = (name = 'team-offsite.png') => ({ name, mimeType: 'image/png', buffer: png() })
export const people = [
{ id: 'v-marta', name: 'Marta Rossi', labels: { team: 'platform' }, registeredAt: new Date(Date.now() - 12 * 86400000).toISOString() },
{ id: 'v-jonas', name: 'Jonas Weber', labels: { team: 'infra' }, registeredAt: new Date(Date.now() - 12 * 86400000).toISOString() },
{ id: 'v-ilse', name: 'Ilse Janssen', labels: { role: 'guest' }, registeredAt: new Date(Date.now() - 5 * 86400000).toISOString() },
]
export const faces = [
{ id: 'f-anna', name: 'Anna Kowalski', labels: { team: 'design' }, registeredAt: new Date(Date.now() - 9 * 86400000).toISOString() },
{ id: 'f-tomas', name: 'Tomas Ribeiro', labels: { team: 'infra' }, registeredAt: new Date(Date.now() - 9 * 86400000).toISOString() },
]
export const match = (id, name, distance) => ({ id, name, distance, confidence: 0, match: false })
// Routes and stored state for both pages. `opts` swaps pieces:
// voiceModels / faceModels installed models (default: one each)
// stored / storedFaces the browser-side lists
// identify / verify a JSON body, or { status, body } for a failure
// faceIdentify / faceVerify the same for faces
// delay milliseconds to hold identify and verify
export async function mockIdentity(page, opts = {}) {
const calls = { identify: [], verify: [], register: [], forget: [], profile: [], faceIdentify: [], faceVerify: [], faceRegister: [] }
const voiceModels = opts.voiceModels ?? [SPEAKER_MODEL]
const faceModels = opts.faceModels ?? [FACE_MODEL]
await page.addInitScript(([vk, fk, voices, facesList]) => {
try {
if (voices) localStorage.setItem(vk, JSON.stringify(voices))
if (facesList) localStorage.setItem(fk, JSON.stringify(facesList))
} catch { /* storage blocked */ }
}, [VOICE_KEY, FACE_KEY, opts.stored ?? null, opts.storedFaces ?? null])
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [
...voiceModels.map(id => ({ id, capabilities: ['FLAG_SPEAKER_RECOGNITION'], backend: 'wespeaker' })),
...faceModels.map(id => ({ id, capabilities: ['FLAG_FACE_RECOGNITION'], backend: 'insightface' })),
{ id: 'qwen-base', capabilities: ['FLAG_TTS'], backend: 'qwen3-tts-cpp', voice_cloning: { reference_transcript_required: true, accepted_audio_formats: ['audio/wav'] } },
] } }))
await page.route('**/api/voice-profiles', async route => {
if (route.request().method() === 'POST') {
calls.profile.push(route.request().postData()?.length || 0)
return route.fulfill({ status: 201, json: { id: 'prof-1', name: 'Marta Rossi', voice: 'localai://voice-profiles/prof-1', transcript: 'x', created_at: '2026-07-01T12:00:00Z', consent_confirmed_at: '2026-07-01T12:00:00Z', audio: { duration_ms: 4000, sample_rate: 24000 } } })
}
return route.fulfill({ json: { data: opts.profiles ?? [] } })
})
await page.route('**/api/voice-profiles/*/audio', route => route.fulfill({ status: 200, contentType: 'audio/wav', body: wav(3) }))
await page.route('**/api/models?**', route => route.fulfill({ json: { models: opts.gallery ?? [] } }))
const answer = async (route, spec, fallback) => {
if (opts.delay) await new Promise(r => setTimeout(r, opts.delay))
if (spec?.status) return route.fulfill({ status: spec.status, json: spec.body ?? { error: { message: 'failed' } } })
return route.fulfill({ json: spec ?? fallback })
}
const body = (route) => { try { return route.request().postDataJSON() } catch { return null } }
await page.route('**/v1/voice/identify', route => { calls.identify.push(body(route)); return answer(route, opts.identify, { matches: [] }) })
await page.route('**/v1/voice/verify', route => { calls.verify.push(body(route)); return answer(route, opts.verify, { verified: true, distance: 0.19, threshold: 0.25, confidence: 24, model: SPEAKER_MODEL }) })
await page.route('**/v1/voice/register', route => { const b = body(route); calls.register.push(b); return route.fulfill({ json: { id: `new-${calls.register.length}`, name: b.name, registered_at: new Date().toISOString() } }) })
await page.route('**/v1/voice/forget', route => { calls.forget.push(body(route)); return route.fulfill({ status: opts.forgetStatus ?? 204, body: '' }) })
await page.route('**/v1/face/identify', route => { calls.faceIdentify.push(body(route)); return answer(route, opts.faceIdentify, { matches: [] }) })
await page.route('**/v1/face/verify', route => { calls.faceVerify.push(body(route)); return answer(route, opts.faceVerify, { verified: true, distance: 0.21, threshold: 0.35, confidence: 40, model: FACE_MODEL, img1_area: { x: 20, y: 10, w: 60, h: 70 }, img2_area: { x: 30, y: 12, w: 55, h: 66 } }) })
await page.route('**/v1/face/register', route => { const b = body(route); calls.faceRegister.push(b); return route.fulfill({ json: { id: `fnew-${calls.faceRegister.length}`, name: b.name, registered_at: new Date().toISOString() } }) })
await page.route('**/v1/face/forget', route => { calls.forget.push({ face: true, ...body(route) }); return route.fulfill({ status: 204, body: '' }) })
return calls
}
+530
View File
@@ -0,0 +1,530 @@
import { test, expect } from './coverage-fixtures.js'
import {
FACE_KEY, VOICE_KEY, clipFile, faces, match, mockIdentity, people, photoFile,
} from './identity-fixtures.js'
const marta = match('v-marta', 'Marta Rossi', 0.18)
const jonas = match('v-jonas', 'Jonas Weber', 0.41)
const ilse = match('v-ilse', 'Ilse Janssen', 0.52)
const closeSet = { matches: [marta, jonas, ilse] }
const farSet = { matches: [match('v-jonas', 'Jonas Weber', 0.31), match('v-ilse', 'Ilse Janssen', 0.47), match('v-marta', 'Marta Rossi', 0.55)] }
async function addProbe(page, name = 'board-sync-clip.wav', seconds = 8) {
await page.locator('#voice-probe-audio-file').setInputFiles(clipFile(name, seconds))
await expect(page.getByTestId('voice-probe-clip')).toHaveAttribute('data-state', 'filled')
}
test.describe('Voices: Speakers', () => {
test('empty: says nobody is enrolled and that the list lives in the browser', async ({ page }) => {
await mockIdentity(page)
await page.goto('/app/voice')
await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
await expect(page.getByTestId('registry-note')).toContainText('kept in this browser')
await expect(page.getByTestId('registry-note')).toContainText('no list call')
await expect(page.getByTestId('identify-go')).toBeDisabled()
// The Build bar keeps the Voices tab lit and has no second row.
await expect(page.locator('.dk-hubtabs [data-hub-tab="voices"]')).toHaveAttribute('aria-current', 'page')
await expect(page.locator('.hub-subnav')).toHaveCount(0)
})
test('who is this: a match is explained in words, on a scale, with the real numbers', async ({ page }) => {
const calls = await mockIdentity(page, { stored: people, identify: closeSet })
await page.goto('/app/voice')
await addProbe(page)
await expect(page.getByText('good length')).toBeVisible()
await page.getByTestId('identify-go').click()
const verdict = page.getByTestId('verdict')
await expect(verdict).toContainText('Probably Marta Rossi')
await expect(verdict).toContainText('Likely match')
await expect(verdict).toContainText('0.18 away')
await expect(verdict).toContainText('under 0.25')
await expect(page.getByTestId('distance-scale')).toBeVisible()
await expect(page.getByTestId('rank-row')).toHaveCount(3)
await expect(page.getByTestId('rank-row').first()).toContainText('within cut-off')
await expect(page.getByTestId('cutoff-value')).toHaveText('0.25')
// The request carries the model, the clip, a top_k that covers everyone, and the cut-off.
expect(calls.identify).toHaveLength(1)
expect(calls.identify[0]).toMatchObject({ model: 'wespeaker-resnet34', threshold: 0.25 })
expect(calls.identify[0].audio).toMatch(/^data:audio\//)
expect(calls.identify[0].top_k).toBeGreaterThanOrEqual(25)
await expect(page.getByTestId('known-row-v-marta')).toContainText('best match')
})
test('the cut-off slider re-reads the answer without another call', async ({ page }) => {
const calls = await mockIdentity(page, { stored: people, identify: closeSet })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
const slider = page.locator('#voice-find-cutoff')
await slider.fill('0.1')
await expect(page.getByTestId('cutoff-value')).toHaveText('0.10')
await expect(page.getByTestId('verdict')).toContainText('Nobody enrolled sounds like this')
await expect(page.getByRole('button', { name: 'Reset to 0.25' })).toBeVisible()
await page.getByRole('button', { name: 'Reset to 0.25' }).click()
await expect(page.getByTestId('verdict')).toContainText('Probably Marta Rossi')
expect(calls.identify).toHaveLength(1)
})
test('no match: names the closest person and says why it may be wrong', async ({ page }) => {
await mockIdentity(page, { stored: people, identify: farSet })
await page.goto('/app/voice')
await addProbe(page, 'hallway-recording.wav', 2.4)
await expect(page.getByText('short', { exact: true })).toBeVisible()
await page.getByTestId('identify-go').click()
const verdict = page.getByTestId('verdict')
await expect(verdict).toContainText('Nobody enrolled sounds like this')
await expect(verdict).toContainText('closest was Jonas Weber at 0.31')
await expect(page.getByRole('button', { name: 'Enrol this voice' })).toBeVisible()
await page.getByText('Why this might be wrong').click()
await expect(page.getByTestId('why-list')).toContainText('not a security check')
await expect(page.getByTestId('why-list')).toContainText('short: 2.4 s')
})
test('working: a progress bar and a disabled button while the call is out', async ({ page }) => {
await mockIdentity(page, { stored: people, identify: closeSet, delay: 1500 })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('identify-working')).toBeVisible()
await expect(page.getByTestId('identify-working')).toHaveAttribute('aria-busy', 'true')
await expect(page.getByTestId('identify-go')).toBeDisabled()
await expect(page.getByTestId('verdict')).toBeVisible()
})
test('failed: plain words, the server message, a retry and a trace link', async ({ page }) => {
const calls = await mockIdentity(page, { stored: people, identify: { status: 500, body: { error: { message: 'backend crashed' } } } })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
const err = page.getByTestId('identity-error')
await expect(err).toContainText('The call failed')
await expect(err).toContainText('backend crashed')
await expect(err.getByRole('link', { name: 'Open traces' })).toHaveAttribute('href', /\/app\/traces/)
await err.getByRole('button', { name: 'Try again' }).click()
await expect.poll(() => calls.identify.length).toBe(2)
})
test('failed: a missing model is named as such', async ({ page }) => {
await mockIdentity(page, { stored: people, identify: { status: 404, body: { error: { message: 'model not found' } } } })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('identity-error')).toContainText('cannot find this model')
})
test('same person: uses the model cut-off from the answer and shows the pair on the scale', async ({ page }) => {
const calls = await mockIdentity(page, { stored: people, verify: { verified: false, distance: 0.31, threshold: 0.28, confidence: 0, model: 'x' } })
await page.goto('/app/voice')
await page.getByRole('tab', { name: 'Same person?' }).click()
await page.locator('#voice-a-audio-file').setInputFiles(clipFile('a.wav'))
await page.locator('#voice-b-audio-file').setInputFiles(clipFile('b.wav', 6))
await page.getByTestId('compare-go').click()
const verdict = page.getByTestId('verdict')
await expect(verdict).toContainText('Probably different people')
await expect(verdict).toContainText('0.31 apart')
await expect(verdict).toContainText('under 0.28')
expect(calls.verify[0]).toMatchObject({ model: 'wespeaker-resnet34' })
expect(calls.verify[0]).not.toHaveProperty('threshold')
await expect(page.getByTestId('cutoff-value')).toHaveText('0.28')
await page.locator('#voice-pair-cutoff').fill('0.4')
await expect(verdict).toContainText('Probably the same person')
expect(calls.verify).toHaveLength(1)
})
test('mic blocked: says so and offers a file', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, 'mediaDevices', {
value: { getUserMedia: () => Promise.reject(Object.assign(new Error('Permission denied'), { name: 'NotAllowedError' })) },
configurable: true,
})
})
await mockIdentity(page, { stored: people })
await page.goto('/app/voice')
await page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' }).click()
await expect(page.getByTestId('clip-notice')).toContainText('microphone is blocked')
await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Choose a file' })).toBeVisible()
await expect(page.getByTestId('voice-probe-clip').getByRole('button', { name: 'Record' })).toHaveCount(0)
})
test('mic allowed: shows the state when the browser reports it', async ({ page }) => {
await page.addInitScript(() => {
const status = { state: 'granted', onchange: null }
Object.defineProperty(navigator, 'permissions', { value: { query: () => Promise.resolve(status) }, configurable: true })
Object.defineProperty(navigator, 'mediaDevices', { value: { getUserMedia: () => Promise.reject(new Error('unused')) }, configurable: true })
})
await mockIdentity(page, { stored: people })
await page.goto('/app/voice')
await expect(page.getByTestId('mic-allowed')).toContainText('Microphone allowed')
})
test('mic on an insecure origin is explained', async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(window, 'isSecureContext', { value: false, configurable: true })
Object.defineProperty(navigator, 'mediaDevices', { value: undefined, configurable: true })
})
await mockIdentity(page, { stored: people })
await page.goto('/app/voice')
await expect(page.getByTestId('clip-notice')).toContainText('https or on localhost')
})
})
test.describe('Voices: the registry tells the truth', () => {
const priya = { id: 'v-priya', name: 'Priya Nair', labels: { team: 'platform' }, registeredAt: new Date(Date.now() - 86400000).toISOString() }
test('a person the search did not return is marked, and a stranger is listed', async ({ page }) => {
await mockIdentity(page, {
stored: [...people, priya],
identify: { matches: [marta, jonas, ilse, match('v-other', 'Omar Haddad', 0.6)] },
})
await page.goto('/app/voice')
// Before a search nothing is claimed.
await expect(page.getByTestId('not-on-server')).toHaveCount(0)
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toBeVisible()
await expect(page.getByTestId('known-row-v-priya').getByTestId('not-on-server')).toContainText('not on the server')
await expect(page.getByTestId('known-row-v-priya')).toContainText('may have restarted')
await expect(page.getByTestId('known-row-v-marta').getByTestId('not-on-server')).toHaveCount(0)
await expect(page.getByTestId('known-row-v-other').getByTestId('not-in-browser')).toBeVisible()
await expect(page.getByTestId('registry-note')).toContainText('The last search checked it')
})
test('the server returning nobody is never read as proof when the search was cut short', async ({ page }) => {
// 25 asked, 25 returned: the search may have missed people, so nothing is marked.
const many = Array.from({ length: 25 }, (_, i) => match(`x-${i}`, `Person ${i}`, 0.3 + i / 100))
await mockIdentity(page, { stored: [...people, priya], identify: { matches: many } })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toBeVisible()
await expect(page.getByTestId('not-on-server')).toHaveCount(0)
})
test('an empty answer after a restart says the voices are gone', async ({ page }) => {
await mockIdentity(page, { stored: people, identify: { matches: [] } })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toContainText('The server returned no speakers')
await expect(page.getByTestId('verdict')).toContainText('enrol them again')
await expect(page.getByTestId('not-on-server')).toHaveCount(3)
})
test('what is stored, and where, is stated', async ({ page }) => {
await mockIdentity(page, { stored: people })
await page.goto('/app/voice')
await page.getByTestId('stored-note').getByText('What is stored, and where').click()
const note = page.getByTestId('stored-note')
await expect(note).toContainText('lost when it restarts')
await expect(note).toContainText('The recording is not kept')
await expect(note).toContainText('only if you tick Keep a copy')
})
})
test.describe('Voices: enrol sheet', () => {
test('lists what is still needed, then registers and saves only the metadata', async ({ page }) => {
const calls = await mockIdentity(page, { stored: [] })
await page.goto('/app/voice')
await page.getByTestId('enrol-open').click()
const sheet = page.getByTestId('enrol-sheet')
await expect(sheet).toBeVisible()
const submit = sheet.getByRole('button', { name: /^Enrol/ })
await expect(submit).toBeDisabled()
await expect(page.getByTestId('enrol-needs')).toContainText('a recording, a name, permission')
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 5))
await page.locator('#voice-enrol-name').fill('Marta Rossi')
await page.locator('#voice-enrol-labels').fill('team: platform')
await expect(page.getByTestId('enrol-needs')).toContainText('permission')
await sheet.getByText('This person agreed').click()
await expect(page.getByTestId('enrol-needs')).toHaveText('Ready to enrol.')
await submit.click()
await expect(sheet).toHaveCount(0)
expect(calls.register[0]).toMatchObject({ model: 'wespeaker-resnet34', name: 'Marta Rossi', labels: { team: 'platform' } })
expect(calls.register[0].audio).toMatch(/^data:audio\//)
await expect(page.getByTestId('known-list')).toContainText('Marta Rossi')
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY)
expect(stored).toHaveLength(1)
expect(stored[0]).toMatchObject({ name: 'Marta Rossi', labels: { team: 'platform' } })
// The copy of the recording is opt-in; it was not ticked.
expect(stored[0]).not.toHaveProperty('sampleUrl')
})
test('keeping a copy is opt-in and stays in the browser', async ({ page }) => {
const calls = await mockIdentity(page, { stored: [] })
await page.goto('/app/voice')
await page.getByTestId('enrol-open').click()
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2))
await page.locator('#voice-enrol-name').fill('Ada')
const sheet = page.getByTestId('enrol-sheet')
await sheet.getByText('This person agreed').click()
await sheet.getByText('Keep a copy in this browser').click()
await sheet.getByRole('button', { name: /^Enrol/ }).click()
await expect(sheet).toHaveCount(0)
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), VOICE_KEY)
expect(stored[0].sampleUrl).toMatch(/^data:audio\//)
expect(JSON.stringify(calls.register[0])).not.toContain('sampleUrl')
})
test('can also keep the recording as a speech voice (admin)', async ({ page }) => {
const calls = await mockIdentity(page, { stored: [] })
await page.goto('/app/voice')
await page.getByTestId('enrol-open').click()
const sheet = page.getByTestId('enrol-sheet')
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 3))
await page.locator('#voice-enrol-name').fill('Marta Rossi')
await sheet.getByText('Also keep it as a speech voice').click()
await expect(page.getByTestId('enrol-needs')).toContainText('a transcript')
await page.locator('#voice-enrol-transcript').fill('The words spoken.')
await sheet.getByText('This person agreed').click()
await sheet.getByRole('button', { name: /^Enrol/ }).click()
await expect(sheet).toHaveCount(0)
expect(calls.register).toHaveLength(1)
expect(calls.profile).toHaveLength(1)
})
test('a failed enrol keeps the sheet and says why', async ({ page }) => {
await mockIdentity(page, { stored: [] })
await page.route('**/v1/voice/register', route => route.fulfill({ status: 500, json: { error: { message: 'store unavailable' } } }))
await page.goto('/app/voice')
await page.getByTestId('enrol-open').click()
await page.locator('#voice-enrol-audio-file').setInputFiles(clipFile('x.wav', 2))
await page.locator('#voice-enrol-name').fill('Ada')
const sheet = page.getByTestId('enrol-sheet')
await sheet.getByText('This person agreed').click()
await sheet.getByRole('button', { name: /^Enrol/ }).click()
await expect(sheet.getByTestId('identity-error')).toContainText('store unavailable')
await expect(page.locator('#voice-enrol-name')).toHaveValue('Ada')
})
test('Escape closes the sheet', async ({ page }) => {
await mockIdentity(page, { stored: people })
await page.goto('/app/voice')
await page.getByTestId('enrol-open').click()
await expect(page.getByTestId('enrol-sheet')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByTestId('enrol-sheet')).toHaveCount(0)
})
})
test.describe('Voices: delete with undo', () => {
test('nothing is sent before the window ends, and Undo sends nothing', async ({ page }) => {
await page.clock.install()
const calls = await mockIdentity(page, { stored: people })
await page.goto('/app/voice')
await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
await expect(page.getByTestId('forget-undo-toast')).toContainText('Jonas Weber will be removed')
await expect(page.getByTestId('known-row-v-jonas')).toContainText('removing')
await page.clock.runFor(9000)
expect(calls.forget).toHaveLength(0)
await page.getByTestId('forget-undo-toast').getByRole('button', { name: 'Undo' }).click()
await page.clock.runFor(20000)
expect(calls.forget).toHaveLength(0)
await expect(page.getByTestId('known-row-v-jonas')).toBeVisible()
await expect(page.getByTestId('known-row-v-jonas')).not.toContainText('removing')
})
test('after ten seconds the forget call goes out and the row is gone', async ({ page }) => {
await page.clock.install()
const calls = await mockIdentity(page, { stored: people })
await page.goto('/app/voice')
await page.getByRole('button', { name: 'Remove Jonas Weber' }).click()
await page.clock.runFor(9500)
expect(calls.forget).toHaveLength(0)
await page.clock.runFor(1500)
await expect.poll(() => calls.forget.length).toBe(1)
expect(calls.forget[0]).toEqual({ id: 'v-jonas' })
await expect(page.getByTestId('known-row-v-jonas')).toHaveCount(0)
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)).map(x => x.id), VOICE_KEY)
expect(stored).toEqual(['v-marta', 'v-ilse'])
})
test('a 404 from the server still removes the row', async ({ page }) => {
await page.clock.install()
const calls = await mockIdentity(page, { stored: people, forgetStatus: 404 })
await page.goto('/app/voice')
await page.getByRole('button', { name: 'Remove Ilse Janssen' }).click()
await page.clock.runFor(10500)
await expect.poll(() => calls.forget.length).toBe(1)
await expect(page.getByTestId('known-row-v-ilse')).toHaveCount(0)
})
})
test.describe('Voices: states of the page', () => {
test('disabled: no speaker model says what turns it on and offers installs', async ({ page }) => {
await mockIdentity(page, {
voiceModels: [],
stored: people,
gallery: [{ id: 'localai@wespeaker-resnet34', name: 'wespeaker-resnet34', backend: 'wespeaker', installed: false }],
})
let installed = null
await page.route('**/api/models/install/**', route => { installed = decodeURIComponent(route.request().url().split('/').pop()); return route.fulfill({ json: { jobID: 'j' } }) })
await page.goto('/app/voice')
const needed = page.getByTestId('model-needed')
await expect(needed).toContainText('Voices need a speaker model')
await expect(needed).toContainText('Not enabled on this server')
await expect(needed.getByRole('button', { name: 'Install' })).toBeVisible()
await needed.getByText('What turns this on').click()
await expect(needed).toContainText('Voice recognition permission')
await needed.getByRole('button', { name: 'Install' }).click()
await expect.poll(() => installed).toBe('localai@wespeaker-resnet34')
// The remembered people stay manageable without a model.
await expect(page.getByTestId('known-list')).toContainText('Marta Rossi')
await expect(page.getByTestId('enrol-open')).toBeDisabled()
})
test('a user without the permission sees what is off, not a redirect', async ({ page }) => {
await mockIdentity(page)
await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: true, user: { id: 'u', role: 'user', permissions: {} } } }))
await page.goto('/app/voice')
await expect(page.getByTestId('feature-off')).toContainText('Voice recognition is off for your account')
await expect(page.getByTestId('feature-off')).toContainText('Voice recognition permission')
await page.goto('/app/face')
await expect(page.getByTestId('feature-off')).toContainText('Face recognition is off for your account')
})
test('From a recording links to the diarization workspace', async ({ page }) => {
await mockIdentity(page, { stored: people })
await page.goto('/app/voice?tab=recording')
await expect(page.getByTestId('recording-tab')).toContainText('Name the speakers in a recording')
await page.getByRole('link', { name: /Open the diarization workspace/ }).click()
await expect(page).toHaveURL(/\/app\/studio\/diarization/)
})
test('the route /app/voice/:model keeps working', async ({ page }) => {
await mockIdentity(page, { stored: people })
await page.goto('/app/voice/wespeaker-resnet34')
await expect(page.getByRole('heading', { name: 'Voices', level: 1 })).toBeVisible()
await expect(page.getByTestId('known-list')).toBeVisible()
})
test('phone: no sideways scroll with a result open, and the table stays inside the screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockIdentity(page, { stored: [...people, { id: 'v-priya', name: 'Priya Nair', labels: {}, registeredAt: new Date().toISOString() }], identify: closeSet })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
expect(overflow).toBeLessThanOrEqual(0)
const box = await page.getByTestId('distance-scale').boundingBox()
expect(box.x + box.width).toBeLessThanOrEqual(390)
await page.getByTestId('enrol-open').click()
const sheet = await page.getByTestId('enrol-sheet').boundingBox()
expect(sheet.width).toBeLessThanOrEqual(390)
expect(sheet.x).toBeGreaterThanOrEqual(0)
})
test('reduced motion: the working bar and recording pulse do not animate', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockIdentity(page, { stored: people, identify: closeSet, delay: 2500 })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
const bar = page.getByTestId('identify-working').locator('.dk-progress-bar')
await expect(bar).toBeVisible()
const name = await bar.evaluate(el => getComputedStyle(el).animationName)
expect(name).toBe('none')
})
test('no left rail marks on rows, cards or the verdict', async ({ page }) => {
await mockIdentity(page, { stored: people, identify: closeSet })
await page.goto('/app/voice')
await addProbe(page)
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('verdict')).toBeVisible()
const widths = await page.evaluate(() => [...document.querySelectorAll('.idn-page *')]
.map(el => getComputedStyle(el))
.filter(cs => parseFloat(cs.borderLeftWidth) >= 3 && cs.borderLeftStyle !== 'none' && parseFloat(cs.borderTopWidth) === 0)
.length)
expect(widths).toBe(0)
})
})
test.describe('Faces', () => {
const anna = match('f-anna', 'Anna Kowalski', 0.21)
const tomas = match('f-tomas', 'Tomas Ribeiro', 0.52)
test('who is this: a match, with the faceprint worded as such', async ({ page }) => {
const calls = await mockIdentity(page, { storedFaces: faces, faceIdentify: { matches: [anna, tomas] } })
await page.goto('/app/face')
await expect(page.getByRole('heading', { name: 'Faces', level: 1 })).toBeVisible()
await page.locator('#face-probe-image-file').setInputFiles(photoFile())
await page.getByTestId('identify-go').click()
const verdict = page.getByTestId('verdict')
await expect(verdict).toContainText('Probably Anna Kowalski')
await expect(verdict).toContainText('faceprint')
await expect(verdict).toContainText('under 0.35')
expect(calls.faceIdentify[0]).toMatchObject({ model: 'insightface-buffalo-l', threshold: 0.35 })
expect(calls.faceIdentify[0].img).toMatch(/^data:image\//)
await expect(page.getByTestId('known-row-f-anna')).toContainText('best match')
})
test('no face found: says what to try', async ({ page }) => {
await mockIdentity(page, { storedFaces: faces, faceIdentify: { status: 400, body: { error: { message: 'no face detected in the image' } } } })
await page.goto('/app/face')
await page.locator('#face-probe-image-file').setInputFiles(photoFile())
await page.getByTestId('identify-go').click()
await expect(page.getByTestId('identity-error')).toContainText('No face found in this photo')
await expect(page.getByTestId('identity-error')).toContainText('front-facing')
})
test('same person: sends both photos, optional anti-spoofing, draws the faces it found', async ({ page }) => {
const calls = await mockIdentity(page, { storedFaces: faces })
await page.goto('/app/face')
await page.getByRole('tab', { name: 'Same person?' }).click()
await page.locator('#face-a-image-file').setInputFiles(photoFile('a.png'))
await page.locator('#face-b-image-file').setInputFiles(photoFile('b.png'))
await page.getByText('Check for photos of photos').click()
await page.getByTestId('compare-go').click()
await expect(page.getByTestId('verdict')).toContainText('Probably the same person')
expect(calls.faceVerify[0]).toMatchObject({ anti_spoofing: true })
expect(calls.faceVerify[0].img1).toMatch(/^data:image\//)
await expect(page.locator('.biometrics-bbox')).toHaveCount(2)
await expect(page.getByTestId('cutoff-value')).toHaveText('0.35')
})
test('enrol a person: a photo copy is opt-in', async ({ page }) => {
const calls = await mockIdentity(page, { storedFaces: [] })
await page.goto('/app/face')
await expect(page.getByTestId('known-empty')).toContainText('Nobody enrolled in this browser yet')
await page.getByTestId('enrol-open').click()
const sheet = page.getByTestId('enrol-sheet')
await page.locator('#face-enrol-image-file').setInputFiles(photoFile())
await page.locator('#face-enrol-name').fill('Anna Kowalski')
await sheet.getByText('This person agreed').click()
await sheet.getByRole('button', { name: /^Enrol/ }).click()
await expect(sheet).toHaveCount(0)
expect(calls.faceRegister[0]).toMatchObject({ model: 'insightface-buffalo-l', name: 'Anna Kowalski' })
const stored = await page.evaluate((k) => JSON.parse(localStorage.getItem(k)), FACE_KEY)
expect(stored[0]).not.toHaveProperty('thumbnail')
await expect(page.getByTestId('known-list')).toContainText('Anna Kowalski')
})
test('delete uses the face forget call after the window', async ({ page }) => {
await page.clock.install()
const calls = await mockIdentity(page, { storedFaces: faces })
await page.goto('/app/face')
await page.getByRole('button', { name: 'Remove Anna Kowalski' }).click()
await page.clock.runFor(9000)
expect(calls.forget).toHaveLength(0)
await page.clock.runFor(2000)
await expect.poll(() => calls.forget.length).toBe(1)
expect(calls.forget[0]).toMatchObject({ face: true, id: 'f-anna' })
})
test('disabled: no face model', async ({ page }) => {
await mockIdentity(page, { faceModels: [] })
await page.goto('/app/face')
await expect(page.getByTestId('model-needed')).toContainText('Faces need a face model')
})
test('more tools stay reachable: detect and the raw faceprint', async ({ page }) => {
await mockIdentity(page, { storedFaces: faces })
await page.goto('/app/face')
await page.getByText('More tools').click()
await expect(page.getByRole('tab', { name: /Detect and analyze/ })).toBeVisible()
await expect(page.getByRole('tab', { name: 'Embedding' })).toBeVisible()
})
})
+279
View File
@@ -0,0 +1,279 @@
import { test, expect } from './coverage-fixtures.js'
import { CPU_HOST, GB, GPU_HOST } from './tools-fixtures.js'
// Import is a guided flow: say where the model is, review what LocalAI can tell
// from the source and what the form will send, run the import, see what it made.
// The server returns no preview before the import starts, so the review is built
// from the spelling of the source, the form, and the machine. These specs pin
// what it says and what it leaves out.
const BACKENDS = [
{ name: 'llama-cpp', modality: 'text', auto_detect: true, installed: true },
{ name: 'transformers', modality: 'text', auto_detect: true, installed: true, description: 'Runs the full-precision weights in Python.' },
{ name: 'vllm', modality: 'text', auto_detect: true, installed: false, description: 'A fast serving engine for NVIDIA GPUs.' },
{ name: 'piper', modality: 'tts', auto_detect: true, installed: false },
]
async function boot(page, { host = GPU_HOST, installed = ['qwen3-8b-instruct', 'my-model'] } = {}) {
await page.route('**/backends/known', route => route.fulfill({ json: BACKENDS }))
await page.route('**/api/resources', route => (host ? route.fulfill({ json: host }) : route.fulfill({ status: 403, json: { error: 'admin only' } })))
await page.route('**/v1/models', route => route.fulfill({ json: { data: installed.map(id => ({ id })) } }))
await page.addInitScript(() => { try { localStorage.removeItem('import-form-tab'); localStorage.removeItem('import-form-options') } catch { /* private mode */ } })
}
const expectStep = (page, key, state) => expect(page.locator(`[data-testid="tool-steps"] [data-step="${key}"]`)).toHaveAttribute('data-state', state)
const source = (page) => page.getByTestId('import-source-input')
const check = (page, id) => page.locator(`[data-testid="import-checks"] [data-check="${id}"]`)
test.describe('Import: the source', () => {
test('starts on Source with examples and nothing to review', async ({ page }) => {
await boot(page)
await page.goto('/app/import-model')
await expectStep(page, 'source', 'current')
await expect(page.getByTestId('import-found')).toHaveCount(0)
await expect(page.getByTestId('import-submit')).toBeDisabled()
await expect(page.getByTestId('import-bar')).toContainText('Enter a source to continue.')
await expect(page.getByTestId('import-examples').getByRole('button')).toHaveCount(5)
})
test('an example fills the field', async ({ page }) => {
await boot(page)
await page.goto('/app/import-model')
await page.getByTestId('import-examples').getByRole('button', { name: 'ollama://llama3.2:3b' }).click()
await expect(source(page)).toHaveValue('ollama://llama3.2:3b')
await expect(page.getByTestId('import-source-kind')).toHaveAttribute('data-kind', 'ollama')
await expect(page.getByTestId('import-examples')).toHaveCount(0)
})
for (const [uri, kind, label, ref] of [
['huggingface://Qwen/Qwen3-8B', 'huggingface', 'Hugging Face repository', 'Qwen/Qwen3-8B'],
['https://huggingface.co/owner/repo', 'huggingface', 'Hugging Face repository', 'owner/repo'],
['owner/repo', 'huggingface', 'Hugging Face repository', 'owner/repo'],
['https://example.com/models/model.gguf', 'url', 'Direct URL', 'example.com/models/model.gguf'],
['https://example.com/config.yaml', 'config', 'Configuration file', 'example.com/config.yaml'],
['oci://registry.example.com/model:tag', 'oci', 'OCI image', 'registry.example.com/model:tag'],
['ollama://llama3.2:3b', 'ollama', 'Ollama model', 'llama3.2:3b'],
['file:///models/model.gguf', 'local', 'File on this host', '/models/model.gguf'],
['/models/config.yml', 'config', 'Configuration file', '/models/config.yml'],
]) {
test(`reads ${uri} as ${label}`, async ({ page }) => {
await boot(page)
await page.goto('/app/import-model')
await source(page).fill(uri)
await expect(page.getByTestId('import-source-kind')).toHaveAttribute('data-kind', kind)
await expect(page.getByTestId('import-source-kind')).toContainText(label)
await expect(page.getByTestId('import-found')).toContainText(ref)
await expect(page.getByTestId('import-source-kind')).toContainText('Nothing is downloaded or contacted yet')
})
}
test('a source the importer does not know is a warning, not a block', async ({ page }) => {
await boot(page)
await page.goto('/app/import-model')
await source(page).fill('nonsense')
await expect(page.getByTestId('import-source-kind')).toContainText('Not recognised')
await expect(check(page, 'source')).toHaveAttribute('data-tone', 'warn')
await expect(page.getByTestId('import-submit')).toBeEnabled()
})
})
test.describe('Import: review', () => {
test('moves to Review and shows the found card, the request preview and the checks', async ({ page }) => {
await boot(page)
await page.goto('/app/import-model')
await source(page).fill('huggingface://bartowski/Qwen2.5-7B-Instruct-GGUF')
await expectStep(page, 'review', 'current')
const found = page.getByTestId('import-found')
await expect(found).toContainText('bartowski/Qwen2.5-7B-Instruct-GGUF')
await expect(found).toContainText('Chosen when the import starts')
await expect(page.getByTestId('import-preview-code')).toHaveText('uri: huggingface://bartowski/Qwen2.5-7B-Instruct-GGUF\npreferences: none (LocalAI decides)')
await expect(page.getByTestId('import-preview')).toContainText('LocalAI returns no preview of the model configuration')
await expect(check(page, 'source')).toContainText('Reads as a Hugging Face repository')
await expect(check(page, 'backend')).toContainText('picks the backend')
await expect(check(page, 'disk')).toContainText('118 GB free on the models disk')
await expect(check(page, 'memory')).toContainText('9.8 GB of GPU memory is free now')
await expect(page.getByTestId('import-bar')).toContainText('Imports bartowski/Qwen2.5-7B-Instruct-GGUF')
// What the server cannot say before the import starts is not claimed.
await expect(page.getByTestId('import-checks')).not.toContainText(/licen[cs]e/i)
await expect(page.getByTestId('import-checks')).not.toContainText(/reachable/i)
})
test('the preview is the request the form sends', async ({ page }) => {
await boot(page)
let sent = null
await page.route('**/models/import-uri', route => { sent = route.request().postDataJSON(); route.fulfill({ json: { uuid: 'j', ID: 'j' } }) })
await page.route('**/models/jobs/**', route => route.fulfill({ json: { processed: false, message: 'x', progress: 1 } }))
await page.goto('/app/import-model')
await source(page).fill('hf://o/r')
await page.getByTestId('import-adjust').click()
await page.fill('#import-name', 'my-new-model')
await page.fill('input[placeholder*="q4_k_m"]', 'q5_k_m')
await expect(page.getByTestId('import-preview-code')).toHaveText('uri: hf://o/r\npreferences:\n name: my-new-model\n quantizations: q5_k_m')
await page.getByTestId('import-submit').click()
await expect.poll(() => sent).toEqual({ uri: 'hf://o/r', preferences: { name: 'my-new-model', quantizations: 'q5_k_m' } })
})
test('the name check reads the installed models', async ({ page }) => {
await boot(page)
await page.goto('/app/import-model')
await source(page).fill('hf://o/r')
await page.getByTestId('import-adjust').click()
await page.fill('#import-name', 'my-model')
await expect(check(page, 'name')).toHaveAttribute('data-tone', 'warn')
await expect(check(page, 'name')).toContainText('A model named my-model is already installed.')
await page.fill('#import-name', 'fresh-name')
await expect(check(page, 'name')).toHaveAttribute('data-tone', 'ok')
})
test('a chosen backend that is not installed says it is downloaded first', async ({ page }) => {
await boot(page)
await page.goto('/app/import-model')
await source(page).fill('hf://o/r')
await page.getByTestId('import-adjust').click()
await page.locator('main button[aria-haspopup="listbox"]').first().click()
await page.getByRole('option', { name: 'vllm', exact: true }).click()
await expect(check(page, 'backend')).toContainText('vllm is not installed yet. LocalAI downloads it first.')
await expect(page.getByTestId('import-found')).toContainText('chosen by you, downloaded first')
})
test('on a machine with no GPU the memory line names RAM; for a user who cannot read it, there is none', async ({ page }) => {
await boot(page, { host: CPU_HOST })
await page.goto('/app/import-model')
await source(page).fill('hf://o/r')
await expect(check(page, 'memory')).toContainText('20 GB of RAM is free now')
const second = await page.context().newPage()
await boot(second, { host: null })
await second.goto('/app/import-model')
await second.getByTestId('import-source-input').fill('hf://o/r')
await expect(second.getByTestId('import-found')).toBeVisible()
await expect(second.locator('[data-testid="import-checks"] [data-check="disk"]')).toHaveCount(0)
await expect(second.locator('[data-testid="import-checks"] [data-check="memory"]')).toHaveCount(0)
})
test('a low disk warns', async ({ page }) => {
await boot(page, { host: { ...GPU_HOST, disk: { total: 500 * GB, used: 498 * GB, available: 2 * GB } } })
await page.goto('/app/import-model')
await source(page).fill('hf://o/r')
await expect(check(page, 'disk')).toHaveAttribute('data-tone', 'warn')
})
})
test.describe('Import: ambiguous source', () => {
test('asks which backend, says what each is, and imports with the pick', async ({ page }) => {
await boot(page)
const bodies = []
await page.route('**/models/import-uri', route => {
bodies.push(route.request().postDataJSON())
if (bodies.length === 1) {
return route.fulfill({ status: 400, json: { error: 'ambiguous import', detail: 'x', modality: 'text', candidates: ['transformers', 'vllm'], hint: '' } })
}
return route.fulfill({ json: { uuid: 'j', ID: 'j' } })
})
await page.route('**/models/jobs/**', route => route.fulfill({ json: { processed: false, message: 'x', progress: 5 } }))
await page.goto('/app/import-model')
await source(page).fill('huggingface://Qwen/Qwen3-8B')
await page.getByTestId('import-submit').click()
const alert = page.getByTestId('ambiguity-alert')
await expect(alert).toContainText('More than one backend could run this')
await expect(alert).toContainText('Runs the full-precision weights in Python.')
await expect(alert).toContainText('Not installed. LocalAI downloads it first.')
await expect(alert.getByTestId('ambiguity-chip-vllm')).toContainText('Use vllm')
await alert.getByTestId('ambiguity-chip-transformers').click()
await expect.poll(() => bodies.length).toBe(2)
expect(bodies[1]).toEqual({ uri: 'huggingface://Qwen/Qwen3-8B', preferences: { backend: 'transformers' } })
await expect(alert).toHaveCount(0)
})
})
test.describe('Import: running and done', () => {
test('shows the estimate against free memory and disk, then progress, while the review steps away', async ({ page }) => {
await boot(page)
await page.route('**/models/import-uri', route => route.fulfill({ json: { uuid: 'j', ID: 'j', estimated_size_display: '4.68 GB', estimated_size_bytes: 4.68 * GB, estimated_vram_display: '5.6 GB', estimated_vram_bytes: 5.6 * GB } }))
await page.route('**/models/jobs/**', route => route.fulfill({ json: { processed: false, message: 'downloading', progress: 37.5, file_name: 'Qwen2.5-7B-Instruct-Q4_K_M.gguf', downloaded_size: '1.8 GB', file_size: '4.68 GB' } }))
await page.goto('/app/import-model')
await source(page).fill('huggingface://bartowski/Qwen2.5-7B-Instruct-GGUF')
await page.getByTestId('import-submit').click()
await expectStep(page, 'import', 'current')
await expect(page.getByTestId('import-estimate')).toContainText('4.68 GB')
await expect(page.getByTestId('import-fit')).toHaveAttribute('data-fit', 'fits')
await expect(page.getByTestId('import-fit')).toContainText('Fits in the 9.8 GB that is free.')
await expect(page.getByTestId('import-progress').getByRole('progressbar')).toHaveAttribute('aria-valuenow', '38')
await expect(page.getByTestId('import-found')).toHaveCount(0)
await expect(page.getByTestId('import-bar')).toContainText('Importing.')
})
test('an estimate over the free memory says so', async ({ page }) => {
await boot(page)
await page.route('**/models/import-uri', route => route.fulfill({ json: { uuid: 'j', ID: 'j', estimated_size_display: '40 GB', estimated_size_bytes: 40 * GB, estimated_vram_display: '48 GB', estimated_vram_bytes: 48 * GB } }))
await page.route('**/models/jobs/**', route => route.fulfill({ json: { processed: false, message: 'x', progress: 1 } }))
await page.goto('/app/import-model')
await source(page).fill('hf://big/model')
await page.getByTestId('import-submit').click()
await expect(page.getByTestId('import-fit')).toHaveAttribute('data-fit', 'over')
await expect(page.getByTestId('import-fit')).toContainText('More than the 9.8 GB that is free.')
})
test('done: the model is named, with links to Chat and Models, and Import another starts over', async ({ page }) => {
await boot(page)
await page.route('**/models/import-uri', route => route.fulfill({ json: { uuid: 'j', ID: 'j' } }))
await page.route('**/models/jobs/**', route => route.fulfill({ json: { completed: true, message: 'done', gallery_element_name: 'qwen2.5-7b-instruct' } }))
await page.goto('/app/import-model')
await source(page).fill('huggingface://bartowski/Qwen2.5-7B-Instruct-GGUF')
await page.getByTestId('import-submit').click()
const done = page.getByTestId('import-done')
await expect(done).toContainText('qwen2.5-7b-instruct is imported')
await expectStep(page, 'done', 'current')
await expect(done.getByRole('link', { name: 'Chat with qwen2.5-7b-instruct' })).toHaveAttribute('href', '/app/chat/qwen2.5-7b-instruct')
await expect(done.getByTestId('import-open-models')).toHaveAttribute('href', '/app/models?view=installed')
await done.getByRole('button', { name: 'Import another' }).click()
await expect(source(page)).toHaveValue('')
await expect(page.getByTestId('import-done')).toHaveCount(0)
})
test('a failed import keeps the review and shows the message', async ({ page }) => {
await boot(page)
await page.route('**/models/import-uri', route => route.fulfill({ status: 500, json: { error: 'failed to discover model config: no such file' } }))
await page.goto('/app/import-model')
await source(page).fill('/models/missing.gguf')
await page.getByTestId('import-submit').click()
await expect(page.getByText('Failed to start import: failed to discover model config: no such file')).toBeVisible()
await expect(page.getByTestId('import-found')).toBeVisible()
await expect(page.getByTestId('import-submit')).toBeEnabled()
})
})
test.describe('Import: Write YAML', () => {
test('swaps the surface, keeps the steps away, and creates from the editor', async ({ page }) => {
await boot(page)
let body = null
await page.route('**/models/import', route => { body = route.request().postData(); route.fulfill({ json: { message: 'ok' } }) })
await page.goto('/app/import-model')
await page.getByTestId('import-tab-yaml').click()
await expect(page.getByTestId('import-yaml')).toContainText('Written to the models folder as it is')
await expect(page.getByTestId('tool-steps')).toHaveCount(0)
await expect(page.getByTestId('import-source-input')).toHaveCount(0)
await page.getByTestId('import-create').click()
await expect.poll(() => body).toContain('name: my-model')
await expect(page).toHaveURL(/\/app\/models\?view=installed/)
})
})
test.describe('Import: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
const noOverflow = (page) => page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)
test('empty, review and ambiguous states fit the screen', async ({ page }) => {
await boot(page)
await page.route('**/models/import-uri', route => route.fulfill({ status: 400, json: { error: 'ambiguous import', detail: 'x', modality: 'text', candidates: ['transformers', 'vllm'], hint: '' } }))
await page.goto('/app/import-model')
await expect(page.getByTestId('import-examples')).toBeVisible()
expect(await noOverflow(page)).toBe(false)
await source(page).fill('huggingface://bartowski/a-very-long-repository-name-GGUF')
await expect(page.getByTestId('import-found')).toBeVisible()
expect(await noOverflow(page)).toBe(false)
await page.getByTestId('import-submit').click()
await expect(page.getByTestId('ambiguity-alert')).toBeVisible()
expect(await noOverflow(page)).toBe(false)
await expect(page.getByTestId('import-submit')).toBeInViewport()
})
})
@@ -40,7 +40,7 @@ test.describe('Import form — page width', () => {
})
})
test.describe('Import form — the source field owns its action', () => {
test.describe('Import form — the form owns its action', () => {
test.beforeEach(async ({ page }) => {
await mockBackends(page)
})
@@ -49,8 +49,8 @@ test.describe('Import form — the source field owns its action', () => {
await page.goto('/app/import-model')
const submit = page.locator('[data-testid="import-submit"]')
await expect(submit).toBeVisible({ timeout: 15_000 })
await expect(submit).toHaveClass(/btn/)
await expect(submit).toHaveClass(/btn-primary/)
await expect(submit).toHaveClass(/dk-btn/)
await expect(submit).toHaveClass(/dk-btn--primary/)
// The regression this replaces was a <button> with no class at all, which
// renders in the user-agent's own chrome. Checking the computed background
@@ -62,15 +62,15 @@ test.describe('Import form — the source field owns its action', () => {
expect(background.toLowerCase()).not.toContain('buttonface')
})
test('no control on the page sets Font Awesome as its own font family', async ({ page }) => {
test('no control on the page is itself an icon element', async ({ page }) => {
await page.goto('/app/import-model')
await expect(page.locator('[data-testid="import-submit"]')).toBeVisible({ timeout: 15_000 })
// `class="btn btn-primary fas fa-save"` puts the icon font on the button
// itself, so its label inherits it. Icons belong in a child <i>.
// An icon class on the button itself would restyle its label. Icons belong
// in a child svg.
const offenders = await page.locator('main button').evaluateAll((buttons) =>
buttons
.filter((el) => getComputedStyle(el).fontFamily.toLowerCase().includes('font awesome'))
.filter((el) => /\b(fas|far|fab|lai-icon)\b/.test(el.getAttribute('class') || ''))
.map((el) => el.className)
)
expect(offenders).toEqual([])
@@ -10,7 +10,7 @@ test.describe('Installed model backend logs link', () => {
const logsItem = page.getByRole('menuitem', { name: 'Backend logs' })
await expect(logsItem).toBeVisible()
await expect(logsItem.locator('i.fa-terminal')).toBeVisible()
await expect(logsItem.locator('svg[data-icon="terminal"]')).toBeVisible()
})
test('Backend logs navigates to the selected model logs', async ({ page }) => {
+159
View File
@@ -0,0 +1,159 @@
// Shared fixtures for the Jobs specs: tasks and jobs the API would return, the
// stubbed agent jobs endpoints, and the requests the page sent.
const MIN = 60 * 1000
const HOUR = 60 * MIN
// Times are built from the start of today, so "today" and "yesterday" mean the
// same thing whenever the suite runs.
function clockTimes() {
const now = Date.now()
const d = new Date(now)
const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime()
const sinceMidnight = now - midnight
const todayAt = (ms) => new Date(Math.max(midnight + 1000, now - Math.min(ms, sinceMidnight - 1000))).toISOString()
const dayAt = (daysAgo, hour) => new Date(midnight - (daysAgo - 1) * 24 * HOUR - (24 - hour) * HOUR).toISOString()
return { todayAt, dayAt, now }
}
export function fixtureData() {
const { todayAt, dayAt } = clockTimes()
const tasks = [
{
id: 't1', name: 'daily-digest', description: 'Summarise overnight news.', model: 'qwen3-8b-instruct',
prompt: 'Summarise overnight news about {{.topic}} in {{.format}} format. Keep it to {{.items}} items.',
context: 'Audience: engineers who run models on their own hardware.',
enabled: true, cron: '0 7 * * *', cron_parameters: { topic: 'local inference', format: 'bullet points', items: '8' },
webhooks: [{ url: 'https://hooks.example.org/digest', method: 'POST', headers: { 'X-Token': 'abc' }, payload_template: '{"text": "{{.Status}}"}' }],
multimedia_sources: [],
},
{
id: 't2', name: 'repo-triage', description: 'Label and route new issues.', model: 'qwen3-coder-30b',
prompt: 'Read the new issues in example/app and label each one.', context: '',
enabled: true, cron: '*/30 * * * *', webhooks: [], multimedia_sources: [{ type: 'image', url: 'https://example.org/board.png', headers: {} }],
},
{
id: 't3', name: 'weekly-report', description: 'Write the Monday status report.', model: 'qwen3-14b-instruct',
prompt: 'Write the status report for the week.', context: '', enabled: true, cron: '0 9 * * 1', webhooks: [], multimedia_sources: [],
},
{
id: 't4', name: 'ad-hoc-research', description: '', model: 'qwen3-14b-instruct',
prompt: 'Research {{.subject}}.', context: '', enabled: false, cron: '', webhooks: [], multimedia_sources: [],
},
]
const jobs = [
{
id: 'job-fail-0001', task_id: 't1', status: 'failed', triggered_by: 'cron',
parameters: { topic: 'local inference', format: 'bullet points', items: '8' },
error: 'read_page: timeout after 20 s (3 of 3 attempts)',
created_at: todayAt(10 * MIN), started_at: todayAt(10 * MIN), completed_at: new Date(Date.parse(todayAt(10 * MIN)) + 66 * 1000).toISOString(),
traces: [
{ type: 'status', content: 'Started by the schedule', timestamp: todayAt(10 * MIN) },
{ type: 'tool_call', tool_name: 'web_search', content: 'Searching for local inference news', arguments: { query: 'local inference' }, timestamp: todayAt(10 * MIN) },
{ type: 'tool_result', tool_name: 'read_page', content: 'timeout after 20 s', timestamp: todayAt(10 * MIN) },
],
},
{
id: 'job-done-0002', task_id: 't2', status: 'completed', triggered_by: 'cron', parameters: {},
result: 'Labelled 12 issues and routed 3 of them to the person on call.\n\n| Issue | Label |\n| - | - |\n| #1204 | bug |\n| #1203 | question |',
created_at: todayAt(40 * MIN), started_at: todayAt(40 * MIN), completed_at: new Date(Date.parse(todayAt(40 * MIN)) + 52 * 1000).toISOString(),
webhook_sent: true, webhook_sent_at: todayAt(39 * MIN),
traces: [
{ type: 'reasoning', content: 'I need to read the new issues first.', timestamp: todayAt(40 * MIN) },
{ type: 'tool_call', tool_name: 'list_issues', content: 'Listing issues', arguments: { repo: 'example/app' }, timestamp: todayAt(40 * MIN) },
],
},
{
id: 'job-run-0003', task_id: 't2', status: 'running', triggered_by: 'manual', parameters: {},
created_at: todayAt(2 * MIN), started_at: todayAt(2 * MIN),
},
{
id: 'job-done-0004', task_id: 't1', status: 'completed', triggered_by: 'cron',
parameters: { topic: 'local inference', format: 'bullet points', items: '8' },
result: 'Wrote the digest, 7 items, saved.',
created_at: dayAt(1, 7), started_at: dayAt(1, 7), completed_at: new Date(Date.parse(dayAt(1, 7)) + 49 * 1000).toISOString(),
},
{
id: 'job-stop-0005', task_id: 't3', status: 'cancelled', triggered_by: 'api', parameters: {},
created_at: dayAt(3, 9), started_at: dayAt(3, 9),
},
]
return { tasks, jobs }
}
// Stub the models and the agent jobs endpoints. Returns { seen, state }: seen
// lists what the page sent, state is the tasks and jobs the stub serves.
export async function mockJobs(page, {
tasks, jobs, mcp = true, models = ['qwen3-8b-instruct', 'qwen3-coder-30b', 'qwen3-14b-instruct'],
updateStatus = 200, newJobId = 'job-new-0006',
} = {}) {
const data = fixtureData()
const state = { tasks: structuredClone(tasks ?? data.tasks), jobs: structuredClone(jobs ?? data.jobs) }
const seen = { puts: [], posts: [], deletes: [], executes: [], jobExecutes: [], cancels: [] }
await page.route('**/api/features', route => route.fulfill({
json: { distributed: false, localai_assistant: true, agents: true, mcp: true, mcp_jobs: true },
}))
await page.route('**/api/models/capabilities', route => route.fulfill({
json: { data: models.map(id => ({ id, capabilities: ['chat'] })) },
}))
await page.route('**/api/models/config-json/**', route => route.fulfill({
json: mcp ? { mcp: { stdio: { mcpServers: {} } } } : {},
}))
await page.route('**/api/agent/**', async route => {
const req = route.request()
const url = new URL(req.url())
const parts = url.pathname.split('/').filter(Boolean) // api agent tasks|jobs ...
const kind = parts[2]
const method = req.method()
if (kind === 'tasks') {
const rest = parts.slice(3)
if (rest.length === 0) {
if (method === 'POST') { seen.posts.push(req.postDataJSON()); return route.fulfill({ status: 201, json: { id: 't-new' } }) }
return route.fulfill({ json: state.tasks })
}
if (rest[1] === 'execute' && method === 'POST') {
let body = {}
try { body = req.postDataJSON() || {} } catch { body = {} }
seen.executes.push({ name: decodeURIComponent(rest[0]), body })
return route.fulfill({ status: 201, json: { job_id: newJobId, status: 'pending', url: '' } })
}
const id = decodeURIComponent(rest[0])
const task = state.tasks.find(t => t.id === id || t.name === id)
if (method === 'GET') return task ? route.fulfill({ json: task }) : route.fulfill({ status: 404, json: { error: 'task not found' } })
if (method === 'PUT') {
const body = req.postDataJSON()
seen.puts.push({ id, body })
if (updateStatus !== 200) return route.fulfill({ status: updateStatus, json: { error: 'could not update' } })
state.tasks = state.tasks.map(t => (t.id === id ? { ...t, ...body, id } : t))
return route.fulfill({ json: { message: 'Task updated' } })
}
if (method === 'DELETE') {
seen.deletes.push(id)
state.tasks = state.tasks.filter(t => t.id !== id)
return route.fulfill({ json: { message: 'Task deleted' } })
}
}
if (kind === 'jobs') {
const rest = parts.slice(3)
if (rest.length === 0) return route.fulfill({ json: state.jobs })
if (rest[0] === 'execute' && method === 'POST') {
seen.jobExecutes.push(req.postDataJSON())
return route.fulfill({ status: 201, json: { job_id: newJobId, status: 'pending', url: '' } })
}
const id = decodeURIComponent(rest[0])
if (rest[1] === 'cancel' && method === 'POST') {
seen.cancels.push(id)
state.jobs = state.jobs.map(j => (j.id === id ? { ...j, status: 'cancelled' } : j))
return route.fulfill({ json: { message: 'Job cancelled' } })
}
const job = state.jobs.find(j => j.id === id)
if (id === newJobId && !job) {
return route.fulfill({ json: { id: newJobId, task_id: 't1', status: 'pending', triggered_by: 'api', parameters: {}, created_at: new Date().toISOString() } })
}
return job ? route.fulfill({ json: job }) : route.fulfill({ status: 404, json: { error: 'job not found' } })
}
return route.fallback()
})
return { seen, state }
}
+548
View File
@@ -0,0 +1,548 @@
// Shared fixtures for the Models ledger specs: a believable gallery of 40+
// models, three machine profiles and every endpoint the Explore, Installed and
// cleanup views read, stubbed with Playwright route fulfilment.
//
// The listing stub does what the server does with the parameters the page
// sends (term, tag, backend, collapse_variants, sort, paging), so facet counts,
// search and pagination behave like the real thing without a real gallery.
export const GB = 1024 * 1024 * 1024
const CONTEXTS = [8192, 16384, 32768, 65536, 131072, 262144]
// bytes per weight for the quantisations the gallery uses
const BPW = { Q8_0: 1.06, Q4_K_M: 0.6, Q5_K_M: 0.7, IQ2_M: 0.33, F16: 2, MXFP4: 0.53 }
// name, backend, license, facets, params in billions, quantisation, extras
const ROWS = [
['qwen3-4b-instruct', 'llama-cpp', 'apache-2.0', ['chat'], 4, 'Q8_0'],
['qwen3-8b-instruct', 'llama-cpp', 'apache-2.0', ['chat'], 8, 'Q8_0'],
['qwen3-14b-instruct', 'llama-cpp', 'apache-2.0', ['chat'], 14, 'Q8_0', { variants: ['qwen3-14b-instruct', 'qwen3-14b-instruct-q4'], auto: 'qwen3-14b-instruct' }],
['qwen3-14b-instruct-q4', 'llama-cpp', 'apache-2.0', ['chat'], 14, 'Q4_K_M', { variantOf: 'qwen3-14b-instruct' }],
['qwen3-30b-a3b', 'llama-cpp', 'apache-2.0', ['chat'], 30, 'Q4_K_M'],
['qwen3-32b-instruct', 'llama-cpp', 'apache-2.0', ['chat'], 32, 'Q4_K_M'],
['qwen3-coder-30b-a3b', 'llama-cpp', 'apache-2.0', ['chat'], 30, 'Q4_K_M'],
['gemma-3-12b-it', 'llama-cpp', 'gemma', ['chat', 'vision', 'multimodal'], 12, 'Q8_0'],
['gemma-3-27b-it', 'llama-cpp', 'gemma', ['chat', 'vision', 'multimodal'], 27, 'Q4_K_M'],
['llama-3.3-70b-instruct-iq2', 'llama-cpp', 'llama3.3', ['chat'], 70, 'IQ2_M'],
['mistral-small-3.2-24b', 'llama-cpp', 'apache-2.0', ['chat'], 24, 'Q4_K_M'],
['phi-4-14b', 'llama-cpp', 'mit', ['chat'], 14, 'Q8_0'],
['phi-4-mini-3.8b', 'llama-cpp', 'mit', ['chat'], 3.8, 'Q8_0'],
['olmo-2-13b', 'llama-cpp', 'apache-2.0', ['chat'], 13, 'Q8_0'],
['smollm3-3b', 'llama-cpp', 'apache-2.0', ['chat'], 3, 'Q8_0'],
['gpt-oss-20b', 'llama-cpp', 'apache-2.0', ['chat'], 20, 'MXFP4'],
['gpt-oss-120b', 'llama-cpp', 'apache-2.0', ['chat'], 120, 'MXFP4'],
['deepseek-r1-distill-llama-8b', 'llama-cpp', 'mit', ['chat'], 8, 'Q8_0'],
['deepseek-r1-distill-qwen-32b', 'llama-cpp', 'mit', ['chat'], 32, 'Q4_K_M'],
['devstral-24b', 'llama-cpp', 'apache-2.0', ['chat'], 24, 'Q5_K_M'],
['command-r-32b', 'llama-cpp', 'cc-by-nc-4.0', ['chat'], 32, 'Q4_K_M'],
['glm-4-9b-chat', 'llama-cpp', 'glm-4', ['chat'], 9, 'Q8_0'],
['granite-3.3-8b', 'llama-cpp', 'apache-2.0', ['chat'], 8, 'Q8_0'],
['qwen2.5-vl-7b-instruct', 'llama-cpp', 'apache-2.0', ['chat', 'vision', 'multimodal'], 7, 'Q8_0'],
['minicpm-v-4', 'llama-cpp', 'apache-2.0', ['chat', 'vision', 'multimodal'], 4, 'Q8_0'],
['bge-m3', 'llama-cpp', 'mit', ['embeddings'], 0.57, 'F16', { kv: false }],
['nomic-embed-text-v1.5', 'llama-cpp', 'apache-2.0', ['embeddings'], 0.14, 'F16', { kv: false }],
['qwen3-embedding-4b', 'llama-cpp', 'apache-2.0', ['embeddings'], 4, 'Q8_0', { kv: false }],
['bge-reranker-v2-m3', 'llama-cpp', 'apache-2.0', ['rerank'], 0.57, 'Q8_0', { kv: false }],
['whisper-large-v3', 'whisper', 'mit', ['transcript'], 1.55, 'F16', { kv: false }],
['whisper-medium', 'whisper', 'mit', ['transcript'], 0.77, 'F16', { kv: false }],
['parakeet-tdt-0.6b', 'parakeet', 'cc-by-4.0', ['transcript'], 0.6, 'F16', { kv: false }],
['kokoro-82m', 'kokoro', 'apache-2.0', ['tts'], 0.082, 'F16', { kv: false }],
['piper-en-us-lessac', 'piper', 'mit', ['tts'], 0.06, 'F16', { kv: false }],
['vibevoice-1.5b', 'vibevoice', 'mit', ['tts'], 1.5, 'F16', { kv: false }],
['silero-vad', 'silero', 'mit', ['vad'], 0.002, 'F16', { kv: false }],
['pyannote-diarization-3.1', 'pyannote', 'mit', ['diarization'], 0.03, 'F16', { kv: false }],
['flux.1-schnell', 'stablediffusion-ggml', 'apache-2.0', ['image'], 12, 'Q8_0', { kv: false }],
['sdxl-turbo', 'diffusers', 'openrail++', ['image'], 3.5, 'F16', { kv: false }],
['stable-diffusion-3.5-medium', 'diffusers', 'stability', ['image'], 2.5, 'F16', { kv: false }],
['wan2.2-5b', 'diffusers', 'apache-2.0', ['video'], 5, 'Q8_0', { kv: false }],
['depth-anything-v2', 'depth-anything', 'apache-2.0', ['detection'], 0.3, 'F16', { kv: false }],
]
const DESCRIPTIONS = {
'qwen3-32b-instruct': 'Dense 32B chat model with a thinking mode and strong tool use.',
'qwen3-14b-instruct': 'General chat model, 14B parameters, strong at tool use.',
'bge-m3': 'Multilingual embeddings for search and retrieval.',
'whisper-large-v3': 'Speech to text in 99 languages.',
'kokoro-82m': 'Small, fast text to speech with several voices.',
'flux.1-schnell': 'Fast image generation in four steps.',
}
export function buildGallery() {
return ROWS.map(([name, backend, license, facets, params, quant, extra = {}]) => {
const size = params * (BPW[quant] || 1) * GB
const hasKv = extra.kv !== false
const kvPer1k = hasKv ? 0.02 * Math.sqrt(params) * GB : 0
const estimates = {}
for (const ctx of CONTEXTS) {
const vram = size * (hasKv ? 1.05 : 1.2) + (hasKv ? 0.4 * GB : 0.2 * GB) + (kvPer1k * ctx) / 1024
estimates[ctx] = { vramBytes: Math.round(vram), vramDisplay: `${(vram / GB).toFixed(2)} GB` }
}
return {
name,
backend,
license,
facets,
quant,
variantOf: extra.variantOf || null,
variants: extra.variants || null,
auto: extra.auto || null,
description: DESCRIPTIONS[name] || `${name} from the gallery.`,
tags: [...facets, backend],
urls: [`https://huggingface.co/example/${name}`],
estimate: {
sizeBytes: Math.round(size),
sizeDisplay: `${(size / GB).toFixed(2)} GB`,
estimates,
...(hasKv ? { modelMaxContext: 131072 } : {}),
},
}
})
}
export const GALLERY = buildGallery()
// ---- machine profiles (what GET /api/resources returns) ----
const DISK = { total: 931 * GB, used: 760 * GB, available: 171 * GB }
export const PROFILES = {
// One 24 GB card, 48 GB of free RAM.
gpu24: {
type: 'gpu',
available: true,
gpus: [{ index: 0, name: 'RTX 4090', vendor: 'nvidia', total_vram: 24 * GB, used_vram: 2.6 * GB, free_vram: 21.4 * GB, usage_percent: 10.8 }],
ram: { total: 64 * GB, used: 16 * GB, free: 48 * GB, available: 48 * GB, usage_percent: 25 },
aggregate: { total_memory: 24 * GB, used_memory: 2.6 * GB, free_memory: 21.4 * GB, usage_percent: 10.8, gpu_count: 1 },
disk: DISK,
storage_size: 107.6 * GB,
},
// An 8 GB laptop: 6 GB of GPU memory and 10 GB of RAM to spill into.
laptop: {
type: 'gpu',
available: true,
gpus: [{ index: 0, name: 'RTX 4050 Laptop', vendor: 'nvidia', total_vram: 6 * GB, used_vram: 0.6 * GB, free_vram: 5.4 * GB, usage_percent: 10 }],
ram: { total: 16 * GB, used: 6 * GB, free: 10 * GB, available: 10 * GB, usage_percent: 37 },
aggregate: { total_memory: 6 * GB, used_memory: 0.6 * GB, free_memory: 5.4 * GB, usage_percent: 10, gpu_count: 1 },
disk: { total: 238 * GB, used: 200 * GB, available: 38 * GB },
storage_size: 41 * GB,
},
// No GPU: system RAM is the budget.
nogpu: {
type: 'ram',
available: true,
gpus: [],
ram: { total: 32 * GB, used: 9 * GB, free: 23 * GB, available: 23 * GB, usage_percent: 28 },
aggregate: { total_memory: 32 * GB, used_memory: 9 * GB, free_memory: 23 * GB, usage_percent: 28, gpu_count: 0 },
disk: { total: 500 * GB, used: 404 * GB, available: 96 * GB },
storage_size: 52 * GB,
},
}
// A models disk under the low-disk rule (under 10 percent free, or under 20 GB).
export function withDisk(profile, disk, storage) {
return { ...profile, disk, storage_size: storage ?? profile.storage_size }
}
// ---- installed models (what the capabilities and system endpoints return) ----
export function installedSeed() {
return [
{ id: 'bge-m3', backend: 'llama-cpp', capabilities: ['FLAG_EMBEDDINGS'] },
{ id: 'flux.1-schnell', backend: 'stablediffusion-ggml', capabilities: ['FLAG_IMAGE'] },
{ id: 'gemma-3-12b-it', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'kokoro-82m', backend: 'kokoro', capabilities: ['FLAG_TTS'] },
{ id: 'llama-3.3-70b-instruct-iq2', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'mistral-small-3.2-24b', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'my-finetune-q4', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'qwen3-14b-instruct', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'qwen3-14b-instruct-q4', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'], pinned: true },
{ id: 'sdxl-turbo', backend: 'diffusers', capabilities: ['FLAG_IMAGE'] },
{ id: 'whisper-large-v3', backend: 'whisper', capabilities: ['FLAG_TRANSCRIPT'] },
{ id: 'whisper-medium', backend: 'whisper', capabilities: ['FLAG_TRANSCRIPT'], disabled: true },
]
}
// ---- the on-disk storage report (what GET /api/models/storage returns) ----
// Build the report the way the server does, from a compact description:
// files { path: sizeInBytes | { size, missing: true } }
// models { name: [path, ...] }
// A file several models list is shared; a path whose file is missing is
// reported under its models' `missing` and never counted.
export function makeStorage({ files = {}, models = {} } = {}) {
const info = (path) => {
const f = files[path]
return typeof f === 'object' && f !== null ? f : { size: f ?? 0 }
}
const users = {}
for (const [name, paths] of Object.entries(models)) for (const p of paths) (users[p] ||= []).push(name)
const fileRows = Object.keys(users).map((path) => ({
path,
size_bytes: info(path).missing ? 0 : Math.round(info(path).size),
...(info(path).missing ? { missing: true } : {}),
models: [...users[path]].sort(),
}))
const modelRows = Object.entries(models).map(([name, paths]) => {
const present = paths.filter((p) => !info(p).missing)
const missing = paths.filter((p) => info(p).missing)
const size = present.reduce((n, p) => n + Math.round(info(p).size), 0)
const shared = present.filter((p) => users[p].length > 1).reduce((n, p) => n + Math.round(info(p).size), 0)
return { name, size_bytes: size, shared_bytes: shared, files: [...present].sort(), ...(missing.length ? { missing: [...missing].sort() } : {}) }
}).sort((a, b) => b.size_bytes - a.size_bytes || a.name.localeCompare(b.name))
return {
models: modelRows,
files: fileRows.sort((a, b) => b.size_bytes - a.size_bytes || a.path.localeCompare(b.path)),
total_bytes: fileRows.reduce((n, f) => n + f.size_bytes, 0),
}
}
// A believable models directory for the installed seed: every model has its
// weights, and two share files. qwen3-14b-instruct and its q4 build use one
// tokenizer file, and the two whisper models use one mel filter file. One model
// has a file its config names that is not on disk.
export function storageSpec() {
return {
files: {
'bge-m3.gguf': 0.6 * GB,
'flux.1-schnell.gguf': 12.4 * GB,
'gemma-3-12b-it.gguf': 12.1 * GB,
'kokoro-82m.onnx': 0.31 * GB,
'llama-3.3-70b.gguf': 23.5 * GB,
'mistral-small-3.2-24b.gguf': 14.3 * GB,
'my-finetune-q4.gguf': 4.6 * GB,
'qwen3-14b-instruct.gguf': 15.7 * GB,
'qwen3-14b-instruct-q4.gguf': 9.0 * GB,
'qwen3-14b.tokenizer.json': 1.2 * GB,
'qwen3-8b-instruct.gguf': 8.9 * GB,
'sdxl-turbo/unet.safetensors': 6.9 * GB,
'whisper-large-v3.bin': 3.1 * GB,
'whisper-medium.bin': 1.5 * GB,
'whisper.mel-filters.bin': 0.2 * GB,
'my-finetune-q4.mmproj.gguf': { size: 0, missing: true },
},
models: {
'bge-m3': ['bge-m3.gguf'],
'flux.1-schnell': ['flux.1-schnell.gguf'],
'gemma-3-12b-it': ['gemma-3-12b-it.gguf'],
'kokoro-82m': ['kokoro-82m.onnx'],
'llama-3.3-70b-instruct-iq2': ['llama-3.3-70b.gguf'],
'mistral-small-3.2-24b': ['mistral-small-3.2-24b.gguf'],
'my-finetune-q4': ['my-finetune-q4.gguf', 'my-finetune-q4.mmproj.gguf'],
'qwen3-14b-instruct': ['qwen3-14b-instruct.gguf', 'qwen3-14b.tokenizer.json'],
'qwen3-14b-instruct-q4': ['qwen3-14b-instruct-q4.gguf', 'qwen3-14b.tokenizer.json'],
'qwen3-8b-instruct': ['qwen3-8b-instruct.gguf'],
'sdxl-turbo': ['sdxl-turbo/unet.safetensors'],
'whisper-large-v3': ['whisper-large-v3.bin', 'whisper.mel-filters.bin'],
'whisper-medium': ['whisper-medium.bin', 'whisper.mel-filters.bin'],
},
}
}
// Facet key to the gallery entries it matches, as the server decides it.
const matchesFacet = (entry, key) => (key === 'multimodal' ? entry.facets.includes('multimodal') : entry.facets.includes(key))
// Stub every endpoint the Models page reads. Returns a handle the spec can
// read and change: what was installed or deleted, and the live lists.
export async function mockLedger(page, options = {}) {
const {
profile = 'gpu24',
resources,
gallery = GALLERY,
installed = installedSeed(),
loaded = ['qwen3-8b-instruct', 'whisper-large-v3'],
agents = { 'research-helper': { model: 'qwen3-14b-instruct' } },
tasks = [{ id: 't1', name: 'nightly-digest', model: 'gemma-3-12b-it', cron: '0 6 * * *', enabled: true }],
agentsStatus = 200,
chains = [],
aliases = [{ name: 'default-chat', target: 'qwen3-8b-instruct' }],
listingDelay = 0,
listingStatus = 200,
operations = [],
failResources = false,
deleteFails = [],
// The report /api/models/storage answers with. The default is an empty one:
// the models directory says nothing about any model, so every size falls
// back to the gallery's estimate. Pass storageSpec() (or a makeStorage
// input) for real figures, or storageStatus 403 (not an admin), 500 or 404.
storage = { files: {}, models: {} },
storageStatus = 200,
} = options
const state = {
installed: [...installed],
loaded: [...loaded],
operations: [...operations],
installs: [],
deletes: [],
listingRequests: [],
resources: resources || PROFILES[profile],
// Changeable while a spec runs, to stand for the server changing under the
// page: a gallery that goes away, an agent that starts using a model.
listingStatus,
agents: { ...agents },
tasks: [...tasks],
agentsStatus,
dismissed: [],
storage: { files: { ...storage.files }, models: { ...storage.models } },
storageStatus,
storageRequests: 0,
}
const installedIds = () => new Set(state.installed.map(m => m.id))
const entryJSON = (entry) => ({
id: entry.name,
name: entry.name,
description: entry.description,
license: entry.license,
urls: entry.urls,
tags: entry.tags,
gallery: 'localai',
installed: installedIds().has(entry.name),
processing: false,
backend: entry.backend,
additionalFiles: [{ filename: `${entry.name}.gguf`, uri: `https://huggingface.co/example/${entry.name}/resolve/main/${entry.name}.gguf`, sha256: 'a'.repeat(64) }],
...(entry.variants ? { has_variants: true } : {}),
})
await page.route('**/api/models?*', async route => {
const url = new URL(route.request().url())
state.listingRequests.push(url.search)
if (listingDelay) await new Promise(r => setTimeout(r, listingDelay))
if (state.listingStatus !== 200) {
return route.fulfill({ status: state.listingStatus, contentType: 'application/json', body: JSON.stringify({ error: 'gallery unreachable' }) })
}
const q = url.searchParams
const collapse = q.get('collapse_variants') === 'true'
let list = gallery.filter(e => !(collapse && e.variantOf))
const term = (q.get('term') || '').toLowerCase()
if (term) list = list.filter(e => e.name.includes(term) || e.description.toLowerCase().includes(term) || e.backend.includes(term))
const tag = q.get('tag')
if (tag) {
const keys = tag.split(',').filter(Boolean)
list = list.filter(e => keys.some(key => matchesFacet(e, key)))
}
const backend = q.get('backend')
if (backend) list = list.filter(e => e.backend === backend)
if (q.get('sort') === 'name') list = [...list].sort((a, b) => a.name.localeCompare(b.name) * (q.get('order') === 'desc' ? -1 : 1))
if (q.get('sort') === 'status') list = [...list].sort((a, b) => (Number(installedIds().has(b.name)) - Number(installedIds().has(a.name))))
const items = Number(q.get('items') || 9)
const page_ = Number(q.get('page') || 1)
const total = list.length
const paged = list.slice((page_ - 1) * items, page_ * items)
return route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
models: paged.map(entryJSON),
allBackends: [...new Set(gallery.map(e => e.backend))].sort(),
allTags: [],
availableModels: total,
installedModels: state.installed.length,
totalPages: Math.max(1, Math.ceil(total / items)),
currentPage: page_,
}),
})
})
await page.route('**/api/models/storage', route => {
state.storageRequests += 1
if (state.storageStatus !== 200) {
return route.fulfill({ status: state.storageStatus, contentType: 'application/json', body: JSON.stringify({ error: { message: 'storage report unavailable' } }) })
}
return route.fulfill({ json: makeStorage(state.storage) })
})
await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: state.installed } }))
await page.route('**/api/models/estimate/*', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
const entry = gallery.find(e => e.name === name)
return entry ? route.fulfill({ json: entry.estimate }) : route.fulfill({ status: 404, json: { error: 'model not found' } })
})
await page.route('**/api/models/variants/*', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
const entry = gallery.find(e => e.name === name)
if (!entry?.variants) return route.fulfill({ json: {} })
return route.fulfill({
json: {
auto_selected: entry.auto,
variants: entry.variants.map(model => {
const v = gallery.find(e => e.name === model)
return { model, backend: v.backend, quantization: v.quant, memory_bytes: v.estimate.sizeBytes, fits: true, is_base: model === name }
}),
},
})
})
await page.route('**/api/backends/usecases', route => route.fulfill({
json: {
'llama-cpp': ['chat', 'embeddings', 'vision', 'rerank', 'token_classify'],
whisper: ['transcript'],
kokoro: ['tts'],
diffusers: ['image', 'video'],
},
}))
await page.route('**/api/resources', route => (failResources
? route.fulfill({ status: 500, json: { error: 'unavailable' } })
: route.fulfill({ json: state.resources })))
await page.route('**/system', route => route.fulfill({ json: { loaded_models: state.loaded.map(id => ({ id })) } }))
await page.route('**/api/aliases', route => route.fulfill({ json: aliases }))
await page.route('**/api/nodes', route => route.fulfill({ status: 404, json: { error: 'not distributed' } }))
await page.route('**/api/operations', route => route.fulfill({ json: { operations: state.operations } }))
await page.route('**/api/failover', route => route.fulfill({ json: { chains } }))
await page.route('**/api/failover/events', route => route.abort())
await page.route('**/api/agents*', route => (state.agentsStatus !== 200
? route.fulfill({ status: state.agentsStatus, json: { error: 'agent pool unavailable' } })
: route.fulfill({ json: { agents: Object.keys(state.agents), agentCount: Object.keys(state.agents).length } })))
await page.route('**/api/agents/*/config', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').slice(-2)[0])
return route.fulfill({ json: state.agents[name] || {} })
})
await page.route('**/api/agent/tasks*', route => route.fulfill({ json: state.tasks }))
await page.route('**/api/operations/*/dismiss', route => {
state.dismissed.push(new URL(route.request().url()).pathname.split('/').slice(-2)[0])
state.operations = state.operations.filter(op => op.jobID !== state.dismissed[state.dismissed.length - 1])
return route.fulfill({ json: {} })
})
await page.route('**/api/models/install/*', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
state.installs.push({ name, search: new URL(route.request().url()).search })
return route.fulfill({ json: { jobID: `job-${state.installs.length}`, message: 'Installation started' } })
})
await page.route('**/models/delete/*', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
if (deleteFails.includes(name)) {
return route.fulfill({ status: 500, json: { error: { message: 'model is busy' } } })
}
state.deletes.push(name)
delete state.storage.models[name]
state.installed = state.installed.filter(m => m.id !== name)
state.loaded = state.loaded.filter(id => id !== name)
return route.fulfill({ json: {} })
})
await page.route('**/models/toggle-state/**', route => route.fulfill({ json: {} }))
await page.route('**/models/toggle-pinned/**', route => route.fulfill({ json: {} }))
await page.route('**/api/models/job/*', route => route.fulfill({ json: { processed: true } }))
await page.route('**/api/recommended*', route => route.fulfill({ json: { models: [] } }))
return state
}
// The "Best for this machine" shelf sizes candidates at a 4K context, which the
// gallery estimates above do not carry (they start at 8K). Call this after
// mockLedger to add that figure, so the shelf has fits to rank. There is no
// recommendation endpoint: the shelf ranks the chat gallery against
// /api/resources and /api/models/estimate on the client.
export async function stubRecommendations(page, gallery = GALLERY) {
await page.route('**/api/models/estimate/*', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
const entry = gallery.find(e => e.name === name)
if (!entry) return route.fulfill({ status: 404, json: { error: 'model not found' } })
const base = entry.estimate.estimates[8192]
const vram = Math.round(base.vramBytes * 0.9)
return route.fulfill({
json: {
...entry.estimate,
estimates: { ...entry.estimate.estimates, 4096: { vramBytes: vram, vramDisplay: `${(vram / GB).toFixed(2)} GB` } },
},
})
})
}
// ---- the model page and the Placement section ----
// Two cards: 24 GB and 12 GB, with the first partly used by another program.
PROFILES.twogpu = {
type: 'gpu',
available: true,
gpus: [
{ index: 0, name: 'RTX 4090', vendor: 'nvidia', total_vram: 24 * GB, used_vram: 2.6 * GB, free_vram: 21.4 * GB, usage_percent: 10.8 },
{ index: 1, name: 'RTX 3060', vendor: 'nvidia', total_vram: 12 * GB, used_vram: 0.4 * GB, free_vram: 11.6 * GB, usage_percent: 3.3 },
],
ram: { total: 64 * GB, used: 16 * GB, free: 48 * GB, available: 48 * GB, usage_percent: 25 },
aggregate: { total_memory: 36 * GB, used_memory: 3 * GB, free_memory: 33 * GB, usage_percent: 8, gpu_count: 2 },
disk: DISK,
storage_size: 107.6 * GB,
}
export const EDITOR_METADATA = {
sections: [
{ id: 'general', label: 'General', icon: 'settings', order: 0 },
{ id: 'llm', label: 'LLM', icon: 'cpu', order: 10 },
],
fields: [
{ path: 'name', yaml_key: 'name', go_type: 'string', ui_type: 'string', section: 'general', label: 'Model Name', description: 'Unique identifier for this model', component: 'input', order: 0 },
{ path: 'backend', yaml_key: 'backend', go_type: 'string', ui_type: 'string', section: 'general', label: 'Backend', description: 'Inference backend to use', component: 'input', order: 10 },
{ path: 'context_size', yaml_key: 'context_size', go_type: '*int', ui_type: 'int', section: 'llm', label: 'Context Size', description: 'Maximum context window in tokens', component: 'number', vram_impact: true, order: 10 },
{ path: 'gpu_layers', yaml_key: 'gpu_layers', go_type: '*int', ui_type: 'int', section: 'llm', label: 'GPU Layers', description: 'Number of layers to offload to GPU (-1 = all)', component: 'number', vram_impact: true, order: 11 },
],
}
export const CONFIGS = {
'qwen3-14b-instruct': 'name: qwen3-14b-instruct\nbackend: llama-cpp\nparameters:\n model: qwen3-14b-instruct.gguf\ncontext_size: 8192\n',
'qwen3-8b-instruct': 'name: qwen3-8b-instruct\nbackend: llama-cpp\nparameters:\n model: qwen3-8b-instruct.gguf\ncontext_size: 8192\ngpu_layers: 99999999\n',
'llama-3.3-70b-instruct-iq2': 'name: llama-3.3-70b-instruct-iq2\nbackend: llama-cpp\nparameters:\n model: llama-3.3-70b.gguf\ncontext_size: 8192\ngpu_layers: 20\n',
'gemma-3-12b-it': 'name: gemma-3-12b-it\nbackend: llama-cpp\nparameters:\n model: gemma-3-12b-it.gguf\ncontext_size: 16384\ngpu_layers: 0\n',
}
// What the estimate endpoint would say for a gallery model at a context size
// and layer count, following the server's own arithmetic: the context term is
// linear, and a partial offload scales only the weights.
export function vramFor(entry, ctx, layers, layerCount = 40) {
const e = entry.estimate.estimates
const slope = (e[262144].vramBytes - e[8192].vramBytes) / (262144 - 8192)
const kv = slope * ctx
const weights = entry.estimate.sizeBytes * 1.05 + 0.4 * GB
const share = layers != null && layers > 0 && layers < layerCount ? layers / layerCount : 1
return Math.round(weights * share + kv)
}
// Stub the endpoints the Placement section and the model page's Configuration
// tab read and write, on top of mockLedger.
//
// estimate 'ok', 'unavailable' (200 with only a message, as the server
// answers for a model with no weight files), 'error' (500) or
// 'slow' (answers after a pause).
// layerCount when set, the estimate also reports block_count.
// configs the YAML each installed model's edit endpoint returns.
export async function mockPlacement(page, options = {}) {
const { estimate = 'ok', layerCount = 0, configs = CONFIGS, gallery = GALLERY, slowMs = 1500, modelLayers = 40, metadata = EDITOR_METADATA } = options
const state = { estimateCalls: [], patches: [], edits: [], configs: { ...configs }, estimate }
await page.route('**/api/models/vram-estimate', async route => {
const body = route.request().postDataJSON()
state.estimateCalls.push(body)
if (state.estimate === 'slow') await new Promise(r => setTimeout(r, slowMs))
if (state.estimate === 'error') return route.fulfill({ status: 500, json: { error: 'estimate failed' } })
const entry = gallery.find(e => e.name === body.model)
if (state.estimate === 'unavailable' || !entry) return route.fulfill({ json: { message: 'no weight files found for estimation' } })
const vram = vramFor(entry, body.context_size || 8192, body.gpu_layers ?? null, modelLayers)
return route.fulfill({
json: {
size_bytes: entry.estimate.sizeBytes,
size_display: entry.estimate.sizeDisplay,
context_length: body.context_size || 8192,
vram_bytes: vram,
vram_display: `${(vram / GB).toFixed(2)} GB`,
model_max_context: 131072,
...(layerCount ? { block_count: layerCount } : {}),
},
})
})
await page.route('**/api/models/config-metadata*', route => route.fulfill({ json: metadata }))
await page.route('**/api/models/edit/*', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
if (route.request().method() === 'POST') {
state.edits.push({ name, body: route.request().postData() })
return route.fulfill({ json: { success: true } })
}
return state.configs[name] !== undefined
? route.fulfill({ json: { config: state.configs[name], name } })
: route.fulfill({ status: 404, json: { error: 'not found' } })
})
await page.route('**/api/models/config-json/*', route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/').pop())
if (route.request().method() === 'PATCH') {
state.patches.push({ name, patch: route.request().postDataJSON() })
return route.fulfill({ json: { success: true } })
}
return route.fulfill({ json: {} })
})
await page.route('**/api/models/config-metadata/autocomplete/*', route => route.fulfill({ json: { values: [] } }))
return state
}
@@ -0,0 +1,24 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLibrary } from './library-fixtures.js'
// The agent form's skill and memory pickers say what an addition costs and
// who else uses it, from the same facts the Library pages read.
test.describe('Agent form pickers', () => {
test('each skill says who else uses it and what it costs, with a total', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/agents/handbook/edit')
await page.locator('[data-section="AdvancedSettings"] .ag-fold__button').click()
await expect(page.getByTestId('agent-skill-hint-summarise-pdf')).toHaveText('Used by research-assistant · +114 tokens per message (estimate)')
await expect(page.getByTestId('agent-skill-hint-translate-document')).toHaveText('Not used yet · +90 tokens per message (estimate)')
await expect(page.getByTestId('agent-skills-budget')).toContainText('about 114 tokens')
})
test('the memory section names the collection the agent reads', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/agents/handbook/edit')
await page.locator('[data-section="MemorySettings"] .ag-fold__button').click()
await expect(page.getByTestId('agent-kb-note')).toContainText('collection named handbook')
await expect(page.getByTestId('agent-kb-note')).toContainText('fill and search it in Memory')
})
})
+173
View File
@@ -0,0 +1,173 @@
// Shared fixtures for the Skills and Memory specs: stubbed API routes for
// skills, git repositories, collections and the agents that use them. The
// stubs keep the agent configs in memory, so a save changes what the next
// read returns, as the real server does.
export const SKILLS = [
{ name: 'summarise-pdf', description: 'Summarise a PDF into a short brief with page references.', content: 'Read the file the user attached. Write five lines, each with a page number.\n'.repeat(6), license: 'MIT', readOnly: false },
{ name: 'query-database', description: 'Answer questions from the reporting database.', content: 'Use the read-only query tool. Never write.\n'.repeat(4), readOnly: false },
{ name: 'triage-issue', description: 'Sort an incoming issue by area and urgency.', content: 'Label the issue with one area and one urgency.\n'.repeat(3), readOnly: false },
{ name: 'translate-document', description: 'Translate a document and keep the layout.', content: 'Translate the text. Keep headings and lists.\n'.repeat(8), readOnly: false },
{ name: 'weekly-digest', description: 'Collect the week into a digest.', content: 'Collect what changed.\n'.repeat(2), readOnly: true },
]
export const AGENT_CONFIGS = {
'research-assistant': {
name: 'research-assistant', model: 'qwen3-14b-instruct', system_prompt: 'You are a careful research assistant. Search first, read two sources, then summarise.',
enable_skills: true, selected_skills: ['summarise-pdf', 'query-database'], enable_kb: false, skills_mode: 'prompt',
},
handbook: {
name: 'handbook', model: 'qwen3-8b-instruct', system_prompt: 'You answer questions about staff policies.',
enable_skills: true, selected_skills: ['summarise-pdf'], enable_kb: true, kb_mode: 'auto_search', kb_results: 3,
},
'support-desk': {
name: 'support-desk', model: 'qwen3-8b-instruct', system_prompt: 'You help customers.',
enable_skills: true, selected_skills: ['triage-issue'], enable_kb: false, skills_mode: 'tools',
},
'idle-agent': {
name: 'idle-agent', model: 'qwen3-8b-instruct', system_prompt: 'Idle.', enable_skills: false, enable_kb: false,
},
}
export const COLLECTIONS = ['handbook', 'release-notes', 'meeting-notes']
export const PASSAGES = [
{ content: 'Remote work. Employees may work remotely up to three days per week with manager approval.', similarity: 0.74, metadata: { filename: 'employee-handbook.pdf' } },
{ content: 'Connecting from outside the office requires the approved VPN client. Install it from the IT portal.', similarity: 0.69, metadata: { filename: 'employee-handbook.pdf' } },
{ content: 'If the VPN drops every few minutes, switch the protocol to TCP in the client settings.', similarity: 0.51, metadata: { filename: 'vpn-setup.md' } },
]
const META = {
Fields: [
{ name: 'name', label: 'Name', type: 'text', required: true, tags: { section: 'BasicInfo' } },
{ name: 'model', label: 'Model', type: 'text', required: true, tags: { section: 'ModelSettings' } },
{ name: 'enable_kb', label: 'Knowledge base', type: 'checkbox', tags: { section: 'MemorySettings' } },
{ name: 'system_prompt', label: 'System prompt', type: 'textarea', tags: { section: 'PromptsGoals' } },
{ name: 'enable_skills', label: 'Skills', type: 'checkbox', tags: { section: 'AdvancedSettings' } },
{ name: 'skills_mode', label: 'Skills mode', type: 'text', tags: { section: 'AdvancedSettings' } },
],
Connectors: [], Actions: [], Filters: [], DynamicPrompts: [],
}
const clone = value => JSON.parse(JSON.stringify(value))
// Stub every endpoint the two pages read or write. Returns what the page sent
// and the live state, so a spec can assert on both.
export async function mockLibrary(page, {
skills = SKILLS,
agents = AGENT_CONFIGS,
collections = COLLECTIONS,
entries = { handbook: ['employee-handbook.pdf', 'vpn-setup.md'] },
sources = { handbook: [{ url: 'https://docs.example.org/guide', update_interval: 60, last_update: '2026-10-05T09:30:00Z' }] },
passages = PASSAGES,
uploadError = null,
skillsStatus = 200,
agentsStatus = 200,
repos = [],
resources = { scripts: [{ path: 'scripts/extract.py', size: 1200 }], references: [{ path: 'references/style.md', size: 300 }], assets: [] },
features = { distributed: false, localai_assistant: true, agents: true, skills: true, collections: true, mcp: true },
} = {}) {
const state = { agents: clone(agents), collections: [...collections], entries: clone(entries), sources: clone(sources), repos: clone(repos) }
const seen = { saves: [], searches: [], uploads: [], sourcePosts: [], creates: [], resets: [], imports: 0 }
const json = (route, body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) })
await page.route('**/api/features', route => json(route, features))
await page.route('**/api/agents/config/metadata', route => json(route, META))
// Agents: list, config and save.
await page.route(/\/api\/agents(\?.*)?$/, route => {
if (agentsStatus !== 200) return json(route, { error: 'boom' }, agentsStatus)
return json(route, { agents: Object.keys(state.agents), statuses: Object.fromEntries(Object.keys(state.agents).map(n => [n, true])) })
})
await page.route(/\/api\/agents\/[^/?]+\/config(\?.*)?$/, route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[3])
return state.agents[name] ? json(route, state.agents[name]) : json(route, { error: 'Agent not found' }, 404)
})
await page.route(/\/api\/agents\/[^/?]+(\?.*)?$/, async route => {
const url = new URL(route.request().url())
const name = decodeURIComponent(url.pathname.split('/')[3])
if (['skills', 'collections', 'git-repos', 'config', 'import', 'actions'].includes(name)) return route.fallback()
if (route.request().method() === 'PUT') {
const body = route.request().postDataJSON()
seen.saves.push({ name, body })
state.agents[name] = body
return json(route, { status: 'ok' })
}
return state.agents[name] ? json(route, { active: true }) : json(route, { error: 'Agent not found' }, 404)
})
// Skills.
await page.route(/\/api\/agents\/skills(\?.*)?$/, route => {
if (skillsStatus !== 200) return json(route, { error: 'skills unavailable' }, skillsStatus)
return json(route, { skills })
})
await page.route('**/api/agents/skills/search?**', route => {
const q = (new URL(route.request().url()).searchParams.get('q') || '').toLowerCase()
return json(route, skills.filter(s => s.name.includes(q) || (s.description || '').toLowerCase().includes(q)))
})
await page.route(/\/api\/agents\/skills\/[^/]+\/resources(\?.*)?$/, route => json(route, { ...resources, readOnly: false }))
await page.route('**/api/agents/skills/import', route => { seen.imports += 1; return json(route, { status: 'ok' }) })
await page.route(/\/api\/agents\/git-repos(\/.*)?(\?.*)?$/, route => {
const req = route.request()
if (req.method() === 'POST' && !req.url().includes('/sync') && !req.url().includes('/toggle')) {
const body = req.postDataJSON()
state.repos.push({ id: `r${state.repos.length + 1}`, url: body.url, name: body.url.split('/').pop(), enabled: true })
return json(route, { status: 'ok' })
}
return json(route, req.method() === 'GET' ? state.repos : { status: 'ok' })
})
// Collections.
await page.route(/\/api\/agents\/collections(\?.*)?$/, route => {
const req = route.request()
if (req.method() === 'POST') {
const body = req.postDataJSON()
seen.creates.push(body.name)
state.collections.push(body.name)
return json(route, { status: 'ok', name: body.name }, 201)
}
return json(route, { collections: state.collections, count: state.collections.length })
})
await page.route(/\/api\/agents\/collections\/[^/]+\/entries(\?.*)?$/, route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4])
return json(route, { entries: state.entries[name] || [], count: (state.entries[name] || []).length })
})
await page.route(/\/api\/agents\/collections\/[^/]+\/entries\/.+/, route => json(route, { content: 'Full text of the entry.', chunk_count: 12 }))
await page.route(/\/api\/agents\/collections\/[^/]+\/sources(\?.*)?$/, route => {
const req = route.request()
const name = decodeURIComponent(new URL(req.url()).pathname.split('/')[4])
if (req.method() === 'POST') {
const body = req.postDataJSON()
seen.sourcePosts.push({ name, ...body })
state.sources[name] = [...(state.sources[name] || []), { url: body.url, update_interval: body.update_interval || 0, last_update: '0001-01-01T00:00:00Z' }]
return json(route, { status: 'ok' })
}
if (req.method() === 'DELETE') {
const body = req.postDataJSON()
state.sources[name] = (state.sources[name] || []).filter(s => s.url !== body.url)
return json(route, { status: 'ok' })
}
return json(route, { sources: state.sources[name] || [], count: (state.sources[name] || []).length })
})
await page.route(/\/api\/agents\/collections\/[^/]+\/search(\?.*)?$/, route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4])
const body = route.request().postDataJSON()
seen.searches.push({ name, ...body })
return json(route, { results: passages.slice(0, body.max_results || passages.length), count: passages.length })
})
await page.route(/\/api\/agents\/collections\/[^/]+\/upload(\?.*)?$/, route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4])
seen.uploads.push(name)
if (uploadError) return json(route, { error: uploadError }, 409)
state.entries[name] = [...(state.entries[name] || []), 'uploaded.txt']
return json(route, { status: 'ok', filename: 'uploaded.txt', key: 'uploaded.txt' })
})
await page.route(/\/api\/agents\/collections\/[^/]+\/reset(\?.*)?$/, route => {
const name = decodeURIComponent(new URL(route.request().url()).pathname.split('/')[4])
seen.resets.push(name)
state.entries[name] = []
return json(route, { status: 'ok' })
})
return { seen, state }
}
@@ -0,0 +1,111 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLibrary } from './library-fixtures.js'
// The Simulate a message sheet. It runs only what the API can run: a search on
// a collection, and the skills an agent has on. It never shows a model answer.
test.describe('Simulate a message', () => {
test('a collection on its own returns real passages with scores and saves nothing', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections')
await page.getByTestId('open-simulate').click()
const sheet = page.getByTestId('simulate-sheet')
await expect(sheet.getByRole('heading', { name: 'Simulate a message' })).toBeVisible()
await expect(sheet).toContainText('Nothing is sent to a model')
await expect(sheet).toContainText('no answer is simulated')
await sheet.getByLabel('Message').fill('When can I work remotely?')
await sheet.getByTestId('simulate-run').click()
const groups = sheet.getByTestId('simulate-passages-handbook')
await expect(groups).toContainText('3 passages, ranked by similarity')
await expect(groups.locator('.lib-passage').first()).toContainText('0.74')
expect(seen.searches[0]).toEqual({ name: 'handbook', query: 'When can I work remotely?', max_results: 5 })
expect(seen.saves).toEqual([])
// No agent was chosen, so there is no budget to show.
await expect(sheet.getByTestId('simulate-total')).toHaveCount(0)
})
test('an agent shows its skills, its estimated budget and the passages of its own collection', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/skills')
await page.getByTestId('open-simulate').click()
const sheet = page.getByTestId('simulate-sheet')
await sheet.getByLabel('Context').selectOption('handbook')
await sheet.getByLabel('Message').fill('When can I work remotely?')
await sheet.getByTestId('simulate-run').click()
await expect(sheet.getByTestId('simulate-skill-summarise-pdf')).toContainText('114 tokens')
// The agent asks for 3 passages, so the search asks for 3.
expect(seen.searches[0]).toEqual({ name: 'handbook', query: 'When can I work remotely?', max_results: 3 })
await expect(sheet.getByTestId('simulate-passages-handbook').locator('.lib-passage')).toHaveCount(3)
await expect(sheet.getByTestId('simulate-total')).toContainText('About')
await expect(sheet).toContainText('Estimates: characters divided by four')
})
test('an agent in tools mode loads skills only when the model asks', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/skills')
await page.getByTestId('open-simulate').click()
const sheet = page.getByTestId('simulate-sheet')
await sheet.getByLabel('Context').selectOption('support-desk')
await sheet.getByLabel('Message').fill('Where is my order?')
await sheet.getByTestId('simulate-run').click()
await expect(sheet.getByTestId('simulate-skill-triage-issue')).toContainText('0 up front')
await expect(sheet).toContainText('Read only when the model asks for one')
await expect(sheet).toContainText('no knowledge base')
})
test('an unused skill is tried only for this test and never saved', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/skills?skill=translate-document')
await page.getByTestId('skill-try').click()
const sheet = page.getByTestId('simulate-sheet')
await expect(sheet.getByTestId('simulate-extras')).toContainText('translate-document')
await sheet.getByLabel('Context').selectOption('idle-agent')
await sheet.getByLabel('Message').fill('Translate this contract')
await sheet.getByTestId('simulate-run').click()
await expect(sheet.getByTestId('simulate-skill-translate-document')).toContainText('Only for this test')
await expect(sheet.getByTestId('simulate-skill-translate-document')).toContainText('90 tokens')
expect(seen.saves).toEqual([])
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
await expect(page.getByTestId('skill-used-translate-document')).toHaveText('Not used yet')
})
test('an unused collection can be searched for this test inside an agent context', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections/release-notes')
await page.getByTestId('collection-try').click()
const sheet = page.getByTestId('simulate-sheet')
await sheet.getByLabel('Context').selectOption('research-assistant')
await sheet.getByLabel('Message').fill('What changed?')
await sheet.getByTestId('simulate-run').click()
await expect(sheet.getByTestId('simulate-passages-release-notes')).toContainText('only for this test')
expect(seen.searches.map(s => s.name)).toEqual(['release-notes'])
expect(seen.saves).toEqual([])
})
test('running without a message asks for one, and a failed search says why', async ({ page }) => {
await mockLibrary(page)
await page.route('**/api/agents/collections/handbook/search', route => route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ error: 'index offline' }) }))
await page.goto('/app/collections')
await page.getByTestId('open-simulate').click()
const sheet = page.getByTestId('simulate-sheet')
await sheet.getByTestId('simulate-run').click()
await expect(sheet.getByRole('alert')).toContainText('Type a message first')
await sheet.getByLabel('Message').fill('hello')
await sheet.getByTestId('simulate-run').click()
await expect(sheet.getByRole('alert')).toContainText('The search failed')
})
test('on a phone the sheet is a bottom sheet that closes with Escape', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockLibrary(page)
await page.goto('/app/collections')
await page.getByTestId('open-simulate').click()
const sheet = page.getByTestId('simulate-sheet')
const box = await sheet.boundingBox()
expect(box.width).toBeLessThanOrEqual(390)
expect(box.y + box.height).toBeGreaterThan(800)
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
})
})
+81 -4
View File
@@ -144,6 +144,7 @@ test.describe('This machine (single node)', () => {
test('the Operate overview previews the heaviest five and links to the full view', async ({ page }) => {
await mockSingleNode(page, [1, 2, 3, 4, 5, 6].map(n => model(`m${n}`, 'llama-cpp', n, 1)))
await page.goto('/app/operate')
await page.getByTestId('operate-row-running').locator('.op-row__head').click()
const preview = page.getByTestId('local-running-models')
await expect(preview.getByTestId('local-model-row')).toHaveCount(5, { timeout: 15_000 })
@@ -152,9 +153,9 @@ test.describe('This machine (single node)', () => {
// The preview is not a second search surface.
await expect(preview.getByRole('searchbox')).toHaveCount(0)
const rail = page.locator('.console-rail a.nav-item[href="/app/nodes"]')
await expect(rail).toContainText('This machine')
await expect(rail.locator('.nav-signal')).toContainText('6')
const tab = page.locator('.dk-hubtabs a[href="/app/nodes"]')
await expect(tab).toContainText('This machine')
await expect(tab.locator('.dk-hubtab-count')).toContainText('6')
await preview.getByRole('link', { name: /Open this machine/ }).click()
await expect(page).toHaveURL(/\/app\/nodes$/)
@@ -169,10 +170,86 @@ test.describe('This machine (distributed)', () => {
await page.route('**/api/nodes', route => route.fulfill({ json: [{ id: 'n1', name: 'atlas', status: 'healthy' }] }))
await page.route('**/system', route => { systemCalls.push(route.request().url()); return route.fulfill({ json: { loaded_models: [] } }) })
await page.goto('/app/operate')
await expect(page.getByTestId('operate-row-running')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('operate-row-running').locator('.op-row__head').click()
await expect(page.getByRole('link', { name: /Running models/ })).toHaveAttribute('href', '/app/nodes', { timeout: 15_000 })
await expect(page.getByTestId('local-running-models')).toHaveCount(0)
await expect(page.locator('.console-rail a.nav-item', { hasText: 'This machine' })).toHaveCount(0)
await expect(page.locator('.dk-hubtabs a', { hasText: 'This machine' })).toHaveCount(0)
expect(systemCalls).toEqual([])
})
})
test.describe('This machine layout', () => {
test('puts the GPU memory first, with what is free and the next-model hint', async ({ page }) => {
await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 35.5)])
await page.goto('/app/nodes')
const strip = page.getByTestId('gpu-strip')
await expect(strip).toContainText('GPU memory, 18.0 GB of 24.0 GB in use', { timeout: 15_000 })
await expect(strip).toContainText('6.0 GB free.')
await expect(strip).toContainText('The next model must fit in that')
await expect(strip.getByRole('img', { name: /GPU memory: 18.0 GB of 24.0 GB in use, 75 percent/ })).toBeVisible()
})
test('says there is no GPU instead of drawing an empty bar', async ({ page }) => {
await mockSingleNode(page, [])
await page.route('**/api/resources', route => route.fulfill({ json: { ...RESOURCES, type: 'ram', gpus: [] } }))
await page.goto('/app/nodes')
await expect(page.getByTestId('gpu-strip')).toContainText('No GPU detected', { timeout: 15_000 })
await expect(page.getByTestId('gpu-strip').getByRole('img')).toHaveCount(0)
})
test('lists each GPU when there is more than one', async ({ page }) => {
await mockSingleNode(page, [])
await page.route('**/api/resources', route => route.fulfill({
json: {
...RESOURCES,
gpus: [
{ name: 'GPU A', total_vram: 24 * GB, used_vram: 12 * GB, free_vram: 12 * GB },
{ name: 'GPU B', total_vram: 24 * GB, used_vram: 20 * GB, free_vram: 4 * GB },
],
},
}))
await page.goto('/app/nodes')
const list = page.getByRole('list', { name: 'GPUs' })
await expect(list).toContainText('GPU A', { timeout: 15_000 })
await expect(list).toContainText('20.0 GB / 24.0 GB')
})
test('shows no temperature or power, because the host does not report them', async ({ page }) => {
await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 10)])
await page.goto('/app/nodes')
await expect(page.getByTestId('host-overview')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('local-machine')).not.toContainText(/temperature|power draw/i)
})
test('Add a machine opens the real setup, and closes it again', async ({ page }) => {
await mockSingleNode(page, [])
await page.goto('/app/nodes')
const button = page.getByRole('button', { name: 'Add a machine' })
await expect(button).toHaveAttribute('aria-expanded', 'false', { timeout: 15_000 })
await button.click()
await expect(page.getByTestId('scale-out')).toContainText('Distributed mode is not enabled')
await page.getByRole('button', { name: 'Hide setup' }).click()
await expect(page.getByTestId('scale-out')).toHaveCount(0)
})
test('fits a phone, with the running models as a table that keeps its actions', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 10)])
await page.goto('/app/nodes')
await expect(page.getByTestId('local-model-row')).toHaveCount(1, { timeout: 15_000 })
await expect(page.getByRole('button', { name: 'Actions for qwen3-8b' })).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
})
test('the memory bar and the models in it do not move when motion is reduced', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockSingleNode(page, [model('qwen3-8b', 'llama-cpp', 12, 10)])
await page.goto('/app/nodes')
await expect(page.getByTestId('gpu-strip')).toBeVisible({ timeout: 15_000 })
const duration = await page.getByTestId('gpu-strip').locator('.dk-meter-seg').evaluate(el => parseFloat(getComputedStyle(el).transitionDuration))
expect(duration).toBeLessThan(0.001)
})
})
+219
View File
@@ -0,0 +1,219 @@
import { test, expect } from './coverage-fixtures.js'
import { mockAccess } from './access-fixtures.js'
// The sign-in screen offers only what /api/auth/status lists, one field per
// step. login.spec.js covers the token option; these cover the variants.
const body = (req) => req.postDataJSON()
test.describe('Sign-in', () => {
test('asks for the email, then the password, and sends both in one call', async ({ page }) => {
await mockAccess(page, { status: 'localOnly', statusExtra: { user: null } })
let sent = null
await page.route('**/api/auth/login', async route => { sent = body(route.request()); await route.fulfill({ status: 401, json: { error: 'invalid email or password' } }) })
await page.goto('/login')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Sign in')
await expect(page.getByLabel('Password')).toHaveCount(0)
await page.getByLabel('Email').fill('Alice@Lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
await expect(page.getByLabel('Email')).toHaveCount(0)
await expect(page.getByText('Signing in as Alice@Lab.example')).toBeVisible()
await page.getByLabel('Password').fill('wrong-password')
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page.getByRole('alert')).toHaveText('invalid email or password')
expect(sent).toEqual({ email: 'Alice@Lab.example', password: 'wrong-password' })
})
test('Back and Use another email return to the first step with the email kept', async ({ page }) => {
await mockAccess(page, { status: 'localOnly', statusExtra: { user: null } })
await page.goto('/login')
await page.getByLabel('Email').fill('alice@lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByRole('button', { name: 'Back' }).click()
await expect(page.getByLabel('Email')).toHaveValue('alice@lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByRole('button', { name: 'Use another email' }).click()
await expect(page.getByLabel('Email')).toHaveValue('alice@lab.example')
})
test('draws a provider button only for a provider the server lists', async ({ page }) => {
await mockAccess(page, { status: 'localOnly', statusExtra: { user: null } })
await page.goto('/login')
await expect(page.getByRole('link', { name: /GitHub/ })).toHaveCount(0)
await expect(page.getByRole('link', { name: /SSO/ })).toHaveCount(0)
await mockAccess(page, { status: 'signedOut', statusExtra: { providers: ['local', 'github'], user: null } })
await page.goto('/login')
await expect(page.getByRole('link', { name: 'Continue with GitHub' })).toHaveAttribute('href', /\/api\/auth\/github\/login$/)
await expect(page.getByRole('link', { name: /SSO/ })).toHaveCount(0)
await expect(page.getByLabel('Email')).toBeVisible()
})
test('with only OAuth providers there is no email form', async ({ page }) => {
await mockAccess(page, { status: 'oauthOnly' })
await page.goto('/login')
await expect(page.getByRole('link', { name: 'Continue with GitHub' })).toBeVisible()
await expect(page.getByRole('link', { name: 'Continue with SSO' })).toBeVisible()
await expect(page.getByLabel('Email')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Register' })).toHaveCount(0)
})
test('a sign-up that waits for approval says so on the sign-in screen', async ({ page }) => {
await mockAccess(page, { status: 'localOnly' })
let sent = null
await page.route('**/api/auth/register', async route => { sent = body(route.request()); await route.fulfill({ json: { pending: true, message: '' } }) })
await page.goto('/login')
await page.getByRole('button', { name: 'Register' }).click()
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Create an account')
await page.getByLabel('Email').fill('dave@lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByLabel('Name').fill('Dave')
await page.getByLabel('Password', { exact: true }).fill('correct horse battery')
await page.getByLabel('Confirm Password').fill('correct horse battery')
await page.getByRole('button', { name: 'Register' }).click()
const pending = page.getByTestId('login-pending')
await expect(pending).toContainText('Account created.')
await expect(pending).toContainText('An admin needs to approve it')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Sign in')
expect(sent).toEqual({ email: 'dave@lab.example', password: 'correct horse battery', name: 'Dave' })
})
test('registration refuses mismatched passwords before it asks the server', async ({ page }) => {
await mockAccess(page, { status: 'localOnly' })
let calls = 0
await page.route('**/api/auth/register', route => { calls++; return route.fulfill({ json: {} }) })
await page.goto('/login')
await page.getByRole('button', { name: 'Register' }).click()
await page.getByLabel('Email').fill('dave@lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByLabel('Password', { exact: true }).fill('correct horse battery')
await page.getByLabel('Confirm Password').fill('different')
await page.getByRole('button', { name: 'Register' }).click()
await expect(page.getByRole('alert')).toHaveText('Passwords do not match')
expect(calls).toBe(0)
})
test('a weak password the server allows is sent after the person says so', async ({ page }) => {
await mockAccess(page, { status: 'localOnly' })
const sent = []
await page.route('**/api/auth/register', async route => {
sent.push(body(route.request()))
if (sent.length === 1) return route.fulfill({ status: 400, json: { error: 'Password is weak', overridable: true } })
return route.fulfill({ json: { pending: true } })
})
await page.goto('/login')
await page.getByRole('button', { name: 'Register' }).click()
await page.getByLabel('Email').fill('dave@lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
await page.getByLabel('Password', { exact: true }).fill('password1234')
await page.getByLabel('Confirm Password').fill('password1234')
await page.getByRole('button', { name: 'Register' }).click()
await expect(page.getByRole('alert')).toContainText('Password is weak')
await page.getByLabel('Use this password anyway').check()
await page.getByRole('button', { name: 'Register' }).click()
await expect(page.getByTestId('login-pending')).toBeVisible()
expect(sent[1].acknowledge_weak_password).toBe(true)
})
test('with invite-only registration the sign-in screen offers no Register link', async ({ page }) => {
await mockAccess(page, { status: 'inviteOnly', statusExtra: { user: null } })
await page.goto('/login')
await expect(page.getByRole('button', { name: 'Register' })).toHaveCount(0)
})
test('a signed-in person is sent to the app', async ({ page }) => {
await mockAccess(page, { status: 'admin' })
await page.goto('/login')
await page.waitForURL(/\/app\/?$/)
})
})
test.describe('First admin', () => {
test('the first person creates the admin account, with no way to sign in', async ({ page }) => {
await mockAccess(page, { status: 'firstAdmin' })
let sent = null
await page.route('**/api/auth/register', async route => { sent = body(route.request()); await route.fulfill({ status: 400, json: { error: 'stop here' } }) })
await page.goto('/login')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Create the admin account')
await expect(page.getByText('this account becomes the admin')).toBeVisible()
await expect(page.getByText('Already have an account?')).toHaveCount(0)
await page.getByLabel('Email').fill('alice@lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
await expect(page.getByLabel('Invite Code')).toHaveCount(0)
await page.getByLabel('Password', { exact: true }).fill('correct horse battery')
await page.getByLabel('Confirm Password').fill('correct horse battery')
await page.getByRole('button', { name: 'Create Admin Account' }).click()
await expect(page.getByRole('alert')).toHaveText('stop here')
expect(sent).toEqual({ email: 'alice@lab.example', password: 'correct horse battery', name: '' })
})
})
test.describe('API-key-only server', () => {
test('asks for one key and says what it is for', async ({ page }) => {
await mockAccess(page, { status: 'keyOnly' })
let sent = null
await page.route('**/api/auth/token-login', async route => { sent = body(route.request()); await route.fulfill({ status: 401, json: { error: 'Invalid token' } }) })
await page.goto('/login')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Enter your API key')
await expect(page.getByText('This server is protected by a key.')).toBeVisible()
await expect(page.getByLabel('Email')).toHaveCount(0)
await expect(page.getByRole('link', { name: /GitHub|SSO/ })).toHaveCount(0)
await page.getByLabel('API key').fill(' sk-test ')
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page.getByRole('alert')).toHaveText('Invalid token')
expect(sent).toEqual({ token: 'sk-test' })
})
test('an empty key is refused in the page', async ({ page }) => {
await mockAccess(page, { status: 'keyOnly' })
await page.goto('/login')
await page.getByRole('button', { name: 'Sign In' }).click()
await expect(page.getByRole('alert')).toHaveText('Please enter a token')
})
})
test.describe('Invite page', () => {
test('fills in the code from the link and sends it with the registration', async ({ page }) => {
await mockAccess(page, { status: 'localOnly', statusExtra: { user: null, registrationMode: 'invite' } })
let sent = null
await page.route('**/api/auth/register', async route => { sent = body(route.request()); await route.fulfill({ json: { pending: true } }) })
await page.goto('/invite/3f9a1c2d77be0a14')
await expect(page.getByRole('heading', { level: 1 })).toHaveText('Join LocalAI')
await page.getByLabel('Email').fill('erin@lab.example')
await page.getByRole('button', { name: 'Continue' }).click()
const code = page.getByLabel('Invite Code')
await expect(code).toHaveValue('3f9a1c2d77be0a14')
await expect(code).toHaveAttribute('readonly', '')
await expect(page.getByText('Invite code from the link')).toBeVisible()
await page.getByLabel('Password', { exact: true }).fill('correct horse battery')
await page.getByLabel('Confirm Password').fill('correct horse battery')
await page.getByRole('button', { name: 'Register' }).click()
await expect(page.getByTestId('login-pending')).toBeVisible()
expect(sent).toMatchObject({ email: 'erin@lab.example', inviteCode: '3f9a1c2d77be0a14' })
})
test('the OAuth buttons carry the code', async ({ page }) => {
await mockAccess(page, { status: 'signedOut', statusExtra: { user: null } })
await page.goto('/invite/abc123')
await expect(page.getByRole('link', { name: 'Continue with GitHub' })).toHaveAttribute('href', /invite_code=abc123/)
})
test('an OAuth redirect that lacks a valid invite says so', async ({ page }) => {
await mockAccess(page, { status: 'localOnly', statusExtra: { user: null } })
await page.goto('/login?error=invite_required')
await expect(page.getByRole('alert')).toHaveText('A valid invite code is required to register')
})
})
test.describe('Sign-in: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('the brand panel becomes a strip and the form fits', async ({ page }) => {
await mockAccess(page, { status: 'localOnly', statusExtra: { user: null } })
await page.goto('/login')
const brand = await page.locator('.lg-brand').boundingBox()
const form = await page.locator('.lg-form').boundingBox()
expect(brand.height).toBeLessThan(120)
expect(form.y).toBeGreaterThanOrEqual(brand.y + brand.height - 1)
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
})
})
+1 -1
View File
@@ -262,7 +262,7 @@ test.describe('Media History - Video Generation', () => {
await page.goto('/app/video')
await expect(page.getByRole('button', { name: 'test-video-model' })).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: 'Reference media' }).click()
await page.getByRole('button', { name: 'Avatar audio' }).click()
await page.getByLabel('Avatar audio', { exact: true }).setInputFiles({
name: 'speech.wav',
mimeType: 'audio/wav',
+224
View File
@@ -0,0 +1,224 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLibrary } from './library-fixtures.js'
// Memory library: collections, who uses each one, a question box that works
// on the collection alone, and what the API reports about sources and files.
test.describe('Memory library', () => {
test('a collection is used by the agent that carries its name, and otherwise by nobody', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections')
await expect(page.getByTestId('collection-used-handbook')).toHaveText('Used by handbook')
await expect(page.getByTestId('collection-used-release-notes')).toHaveText('Not used yet')
await expect(page.getByTestId('collection-used-meeting-notes')).toHaveText('Not used yet')
for (const name of ['handbook', 'release-notes', 'meeting-notes']) {
await expect(page.getByTestId(`collection-used-${name}`)).not.toContainText(/chat/i)
}
})
test('an agent with the knowledge base off does not use its collection', async ({ page }) => {
await mockLibrary(page, { collections: ['idle-agent'] })
await page.goto('/app/collections')
await expect(page.getByTestId('collection-used-idle-agent')).toHaveText('Not used yet')
})
test('the Used and Not used yet filters split the list', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections')
await page.getByTestId('collections-filter-used').click()
await expect(page.locator('[data-testid^="collection-row-"]')).toHaveCount(1)
await page.getByTestId('collections-filter-unused').click()
await expect(page.locator('[data-testid^="collection-row-"]')).toHaveCount(2)
await page.getByTestId('collections-filter-all').click()
await page.getByTestId('collections-filter').fill('notes')
await expect(page.locator('[data-testid^="collection-row-"]')).toHaveCount(2)
await page.getByTestId('collections-filter').fill('nothing-like-this')
await expect(page.getByTestId('collections-none')).toBeVisible()
})
test('the pane lists web sources with their refresh interval and the files', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections/handbook')
await expect(page.getByTestId('collection-title')).toHaveText('handbook')
await expect(page.getByTestId('collection-counts')).toHaveText('2 files, 1 web sources')
const sources = page.getByTestId('sources-list')
await expect(sources).toContainText('https://docs.example.org/guide')
await expect(sources).toContainText('refreshes every 60 min')
await expect(page.getByTestId('entries-list')).toContainText('employee-handbook.pdf')
await expect(page.getByTestId('entries-list')).toContainText('vpn-setup.md')
})
test('a standalone collection says it is not used yet and offers to try it', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections/release-notes')
await expect(page.getByTestId('collection-not-used')).toContainText('It stays in the library until you add it somewhere')
await expect(page.getByTestId('used-by-none')).toHaveText('Not used yet')
await expect(page.getByTestId('collection-try')).toBeVisible()
await expect(page.getByTestId('entries-none')).toBeVisible()
})
test('a question runs on the collection alone and shows ranked passages with scores', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections/handbook')
await page.locator('#search-query').fill('How many days can I work from home?')
await page.getByRole('button', { name: 'Search', exact: true }).click()
const results = page.getByTestId('ask-results')
await expect(results).toContainText('3 passages, ranked by similarity')
await expect(results.locator('.lib-passage').first()).toContainText('Remote work')
await expect(results.locator('.lib-passage').first()).toContainText('0.74')
await expect(results.locator('.lib-passage').nth(2)).toContainText('0.51')
expect(seen.searches).toEqual([{ name: 'handbook', query: 'How many days can I work from home?', max_results: 10 }])
})
test('the number of passages is tunable and nothing returned says so', async ({ page }) => {
const { seen } = await mockLibrary(page, { passages: [] })
await page.goto('/app/collections/handbook')
await page.getByText('Tune the search').click()
await page.locator('#search-max').fill('2')
await page.locator('#search-query').fill('anything')
await page.getByRole('button', { name: 'Search', exact: true }).click()
await expect(page.getByTestId('ask-none')).toBeVisible()
expect(seen.searches[0].max_results).toBe(2)
})
test('adding a web source posts the URL and the interval, and the list shows it', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections/handbook')
await page.getByTestId('add-source-toggle').click()
await page.locator('#source-url').fill('https://example.org/feed')
await page.locator('#source-interval').fill('30')
await page.getByRole('button', { name: 'Add URL' }).click()
await expect.poll(() => seen.sourcePosts.length).toBe(1)
expect(seen.sourcePosts[0]).toEqual({ name: 'handbook', url: 'https://example.org/feed', update_interval: 30 })
await expect(page.getByTestId('sources-list')).toContainText('https://example.org/feed')
await expect(page.getByTestId('sources-list')).toContainText('not fetched yet')
})
test('removing a source asks first', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections/handbook')
await page.getByRole('button', { name: 'Remove https://docs.example.org/guide' }).click()
await page.getByRole('button', { name: 'Remove', exact: true }).click()
await expect(page.getByTestId('sources-none')).toBeVisible()
})
test('uploading a file adds it to the files', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections/handbook')
await page.getByTestId('add-source-toggle').click()
await page.locator('#upload-file').setInputFiles({ name: 'notes.txt', mimeType: 'text/plain', buffer: Buffer.from('hello') })
await page.getByRole('button', { name: 'Upload' }).click()
await expect.poll(() => seen.uploads).toEqual(['handbook'])
await expect(page.getByTestId('entries-list')).toContainText('uploaded.txt')
})
test('a failed upload shows the server message and can be retried or dismissed', async ({ page }) => {
const { seen } = await mockLibrary(page, { uploadError: 'embedding dimension mismatch' })
await page.goto('/app/collections/handbook')
await page.getByTestId('add-source-toggle').click()
await page.locator('#upload-file').setInputFiles({ name: 'scan.pdf', mimeType: 'application/pdf', buffer: Buffer.from('x') })
await page.getByRole('button', { name: 'Upload' }).click()
await expect(page.getByTestId('upload-error')).toHaveText('embedding dimension mismatch')
await expect(page.getByTestId('collection-failed-badge')).toHaveText('1 failed')
await page.getByRole('button', { name: 'Retry' }).click()
await expect.poll(() => seen.uploads.length).toBe(2)
await expect(page.getByTestId('upload-error')).toBeVisible()
await page.getByRole('button', { name: 'Dismiss' }).click()
await expect(page.getByTestId('uploads-list')).toHaveCount(0)
})
test('an entry can be opened and closed with Escape', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections/handbook')
await page.getByRole('button', { name: 'View vpn-setup.md' }).click()
const dialog = page.getByTestId('entry-dialog')
await expect(dialog).toContainText('12 chunks')
await expect(dialog).toContainText('Full text of the entry.')
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
})
test('Add to... offers only the agent that carries the collection name', async ({ page }) => {
const { seen } = await mockLibrary(page, { collections: ['idle-agent', 'release-notes'] })
await page.goto('/app/collections/idle-agent')
await page.getByTestId('add-to-button').click()
const menu = page.getByTestId('add-to-menu')
await expect(menu).toContainText('An agent reads the collection that has its own name')
await expect(menu.getByTestId('add-to-idle-agent')).toContainText('Turns its knowledge base on')
await expect(menu.locator('[data-testid^="add-to-"][data-testid$="research-assistant"]')).toHaveCount(0)
await menu.getByRole('button', { name: 'Add to idle-agent' }).click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0].body).toMatchObject({ name: 'idle-agent', enable_kb: true })
await expect(page.getByTestId('collection-used-idle-agent')).toHaveText('Used by idle-agent')
})
test('with no agent of that name Add to... says how a collection gets used', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections/release-notes')
await page.getByTestId('add-to-button').click()
await expect(page.getByTestId('add-to-menu')).toContainText('No agent is named release-notes')
})
test('removing a collection from an agent turns its knowledge base off, and Undo restores it', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections/handbook')
await page.getByRole('button', { name: 'Remove from handbook' }).click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0].body).toMatchObject({ name: 'handbook', enable_kb: false })
await expect(page.getByTestId('collection-used-handbook')).toHaveText('Not used yet')
await page.getByTestId('library-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect.poll(() => seen.saves.length).toBe(2)
expect(seen.saves[1].body).toMatchObject({ name: 'handbook', enable_kb: true })
})
test('the disclosure names the endpoints and says where files stay', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/collections/handbook')
const api = page.getByTestId('collection-api')
await api.getByText('Index, API and privacy').click()
await expect(api).toContainText('/api/agents/collections/handbook/search')
await expect(api).toContainText('/api/agents/collections/handbook/upload')
await expect(api).toContainText('stay on this server')
})
test('a new collection is created and opened', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections')
await page.getByTestId('new-collection').click()
await page.getByLabel('New collection name').fill('field-notes')
await page.getByTestId('create-form').getByRole('button', { name: 'Create' }).click()
await expect.poll(() => seen.creates).toEqual(['field-notes'])
await expect(page).toHaveURL(/\/app\/collections\/field-notes$/)
})
test('an empty library teaches what memory is', async ({ page }) => {
await mockLibrary(page, { collections: [] })
await page.goto('/app/collections')
const empty = page.getByTestId('memory-empty')
await expect(empty.getByRole('heading', { name: 'Memory holds documents an agent can search' })).toBeVisible()
await empty.getByRole('button', { name: 'New collection' }).click()
await expect(page.getByLabel('New collection name')).toBeVisible()
})
test('Reset asks first and empties the collection', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/collections/handbook')
await page.getByRole('button', { name: 'Reset' }).click()
await page.getByRole('button', { name: 'Remove entries' }).click()
await expect.poll(() => seen.resets).toEqual(['handbook'])
})
test('on a phone the list and the collection take turns', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockLibrary(page)
await page.goto('/app/collections')
await expect(page.getByTestId('collection-row-handbook')).toBeVisible()
await expect(page.getByTestId('collection-pane')).toBeHidden()
await page.getByTestId('collection-row-handbook').click()
await expect(page.getByTestId('collection-title')).toBeVisible()
await page.getByRole('button', { name: 'All collections' }).click()
await expect(page.getByTestId('collection-row-handbook')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
})
+9 -14
View File
@@ -183,16 +183,16 @@ test.describe('Middleware page — admin in no-auth mode', () => {
await page.goto('/app/middleware')
// The Detector models card renders every token_classify filter model.
await expect(page.getByText('Detector models')).toBeVisible()
const nerRow = page.locator('tr').filter({ hasText: 'privacy-filter-multilingual' }).first()
await expect(page.getByRole('heading', { name: 'Detector models' })).toBeVisible()
const detectors = page.getByRole('table', { name: 'Detector models' })
const nerRow = detectors.locator('tr').filter({ hasText: 'privacy-filter-multilingual' }).first()
await expect(nerRow).toContainText(/NER/i)
const patternRow = page.locator('tr').filter({ hasText: 'secret-filter' }).first()
const patternRow = detectors.locator('tr').filter({ hasText: 'secret-filter' }).first()
await expect(patternRow).toContainText(/pattern/i)
// The NER detector is not (yet) a default — its toggle is unchecked.
// (The underlying checkbox is 0×0 by design, so we click the label wrapper.)
const nerToggle = nerRow.locator('label.toggle')
await expect(nerToggle.locator('input[type="checkbox"]')).not.toBeChecked()
const nerToggle = nerRow.getByRole('switch')
await expect(nerToggle).not.toBeChecked()
// Toggling it on persists the new default set via POST /api/settings.
const saved = page.waitForRequest(req =>
@@ -210,7 +210,7 @@ test.describe('Middleware page — admin in no-auth mode', () => {
// default set — it must still appear (toggled on) so admins can remove it.
const orphanRow = page.locator('tr').filter({ hasText: 'global-ner-default' }).first()
await expect(orphanRow).toContainText(/not loaded/i)
await expect(orphanRow.locator('label.toggle input[type="checkbox"]')).toBeChecked()
await expect(orphanRow.getByRole('switch')).toBeChecked()
})
test('Filtering tab flags an enabled model with no detector as a no-op', async ({ page }) => {
@@ -232,8 +232,8 @@ test.describe('Middleware page — admin in no-auth mode', () => {
// qwen-7b is OFF (enabled:false) — its PII toggle reads unchecked.
const row = page.locator('tr').filter({ hasText: 'qwen-7b' }).first()
const toggle = row.locator('label.toggle')
await expect(toggle.locator('input[type="checkbox"]')).not.toBeChecked()
const toggle = row.getByRole('switch')
await expect(toggle).not.toBeChecked()
// Toggling on PATCHes the model config with an explicit pii.enabled:true,
// scoped to that model (no other field is sent — the server deep-merges).
@@ -320,7 +320,6 @@ test.describe('Middleware page — admin in no-auth mode', () => {
test('Events tab renders rows but never the redacted content', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// Hash prefix is visible — that's how admins audit recurring leaks.
await expect(page.getByText('ff8d9819')).toBeVisible()
// The page only ever shows fields the EventStore stores. The matched
@@ -332,7 +331,6 @@ test.describe('Middleware page — admin in no-auth mode', () => {
test('Events tab renders proxy_connect rows with intercept decision', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// Both intercept and tunnel decisions visible.
const interceptRow = page.locator('tr').filter({ hasText: 'api.openai.com' }).first()
@@ -343,7 +341,6 @@ test.describe('Middleware page — admin in no-auth mode', () => {
test('Events tab renders proxy_traffic byte counts and status', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// The traffic row formats as "HTTP 200 · ↑412B ↓1.2KB · 240ms".
// We assert on the durable parts: status code, byte values, duration unit.
@@ -356,7 +353,6 @@ test.describe('Middleware page — admin in no-auth mode', () => {
test('Events kind filter narrows the table to the chosen kind', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// Default = All: pii row + 2 connect rows + 1 traffic row visible.
await expect(page.getByText('ff8d9819')).toBeVisible()
@@ -389,7 +385,6 @@ test.describe('Middleware page — admin in no-auth mode', () => {
test('Events tab shows the kind badge for each row', async ({ page }) => {
await page.goto('/app/middleware')
await page.getByRole('button', { name: /Events/i }).click()
// The Kind column header is present.
await expect(page.locator('th').filter({ hasText: /^Kind$/ })).toBeVisible()
+649
View File
@@ -0,0 +1,649 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger, mockPlacement, GALLERY, GB } from './ledger-fixtures.js'
// A model at its own address: /app/models/<id>. Opened from Explore and from
// Installed, with tabs, a walker over the list it came from, Back that finds
// the list as it was, and honest states for loading, not found, offline and an
// install in flight.
const rows = (page) => page.locator('[data-testid="discover-rail-item"]')
const installedRows = (page) => page.locator('[data-testid="installed-models-rail-item"]')
const name = (page) => page.getByTestId('model-page-name')
const tab = (page, id) => page.getByTestId(`model-page-tab-${id}`)
async function setup(page, options = {}, url = '/app/models') {
const state = await mockLedger(page, options)
const placement = await mockPlacement(page, options.placement || {})
await page.route('**/backend/load', route => { state.loads = [...(state.loads || []), route.request().postDataJSON().model]; return route.fulfill({ json: {} }) })
await page.route('**/backend/shutdown', route => { state.stops = [...(state.stops || []), route.request().postDataJSON().model]; return route.fulfill({ json: {} }) })
await page.goto(url)
return { state, placement }
}
async function openFromExplore(page, model, options) {
const handle = await setup(page, options)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator(`[data-entity="${model}"] [data-row-open]`).click()
await expect(name(page)).toHaveText(model)
return handle
}
test.describe('Model page - reaching it', () => {
test('the arrow at the end of an Explore row opens the model at its own address', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
await expect(page).toHaveURL(/\/app\/models\/qwen3-32b-instruct$/)
await expect(page.getByTestId('model-page')).toHaveAttribute('data-state', 'ready')
})
test('the arrow carries the name of its model for assistive technology', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await expect(page.locator('[data-entity="qwen3-32b-instruct"] [data-row-open]')).toHaveAccessibleName('Open details for qwen3-32b-instruct')
})
test('a double click on a row opens it', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator('[data-entity="qwen3-8b-instruct"] .ledger-name').dblclick()
await expect(name(page)).toHaveText('qwen3-8b-instruct')
})
test('"o" opens the selected row and the inspector has an Open details button', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator('[data-entity="gemma-3-27b-it"]').click()
await expect(page.getByTestId('inspector-open-page')).toBeVisible()
await page.keyboard.press('o')
await expect(name(page)).toHaveText('gemma-3-27b-it')
await page.getByTestId('model-page-back').click()
await page.locator('[data-entity="gemma-3-27b-it"]').click()
await page.getByTestId('inspector-open-page').click()
await expect(name(page)).toHaveText('gemma-3-27b-it')
})
test('an installed model opens from the Installed table too', async ({ page }) => {
await setup(page, {}, '/app/models?view=installed')
await expect(installedRows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator('[data-entity="qwen3-14b-instruct"] [data-row-open]').click()
await expect(name(page)).toHaveText('qwen3-14b-instruct')
await expect(page.getByTestId('model-page-load')).toBeVisible()
})
test('a pasted link opens the page with no list behind it', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-32b-instruct')
await expect(name(page)).toHaveText('qwen3-32b-instruct')
await expect(page.getByTestId('model-page-walk')).toHaveCount(0)
await page.getByTestId('model-page-back').click()
await expect(page).toHaveURL(/\/app\/models$/)
await expect(rows(page).first()).toBeVisible()
})
test('on a phone a tap on a row goes straight to the page', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator('[data-entity="qwen3-32b-instruct"] .ledger-name').click()
await expect(name(page)).toHaveText('qwen3-32b-instruct')
await expect(page).toHaveURL(/\/app\/models\/qwen3-32b-instruct$/)
})
})
test.describe('Model page - walking and going back', () => {
test('previous and next walk the list in the order it was shown', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
const order = await rows(page).evaluateAll(els => els.map(el => el.dataset.entity))
await page.locator(`[data-entity="${order[2]}"] [data-row-open]`).click()
await expect(name(page)).toHaveText(order[2])
await expect(page.getByTestId('model-page-position')).toHaveText(`3 of ${order.length}`)
await page.getByTestId('model-page-next').click()
await expect(name(page)).toHaveText(order[3])
await page.keyboard.press(']')
await expect(name(page)).toHaveText(order[4])
await page.keyboard.press('[')
await expect(name(page)).toHaveText(order[3])
await page.keyboard.press('j')
await expect(name(page)).toHaveText(order[4])
await page.keyboard.press('k')
await expect(name(page)).toHaveText(order[3])
await page.getByTestId('model-page-prev').click()
await expect(name(page)).toHaveText(order[2])
})
test('the walker stops at the ends of the list', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
const first = await rows(page).first().getAttribute('data-entity')
await page.locator(`[data-entity="${first}"] [data-row-open]`).click()
await expect(page.getByTestId('model-page-prev')).toBeDisabled()
await expect(page.getByTestId('model-page-next')).toBeEnabled()
})
test('the tab you are on is kept when you step to the next model', async ({ page }) => {
await openFromExplore(page, 'qwen3-8b-instruct')
await page.keyboard.press('2')
await expect(page).toHaveURL(/tab=fit/)
await page.keyboard.press(']')
await expect(page).toHaveURL(/tab=fit/)
await expect(page.getByTestId('model-page-panel-fit')).toBeVisible()
})
test('walking replaces the history entry, so Back leaves for the list in one step', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click()
await page.keyboard.press(']')
await page.keyboard.press(']')
await page.goBack()
await expect(page).toHaveURL(/\/app\/models$/)
await expect(rows(page).first()).toBeVisible()
})
test('Backspace goes back too, but not while typing', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click()
await expect(tab(page, 'config')).toBeVisible()
await page.keyboard.press('5')
await page.getByTestId('placement-context-input').fill('1234')
await page.keyboard.press('Backspace')
await expect(page.getByTestId('placement-context-input')).toHaveValue('123')
await expect(name(page)).toBeVisible()
await page.getByTestId('placement-context-input').blur()
await page.keyboard.press('Backspace')
await expect(page).toHaveURL(/\/app\/models$/)
})
test('Esc and the Back button return to the list with its search, filters and selection kept', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.getByTestId('models-search').fill('qwen3')
await expect(rows(page).first()).toBeVisible()
const facet = page.locator('.ledger-facets').getByRole('button', { name: /^Chat/ })
await facet.click()
await expect(facet).toHaveAttribute('aria-pressed', 'true')
// qwen3 as chat: the 4B, 8B, 14B (its Q4 build is folded into it), 30B, the
// coder and the 32B.
await expect(rows(page)).toHaveCount(6)
const before = 6
const target = await rows(page).nth(1).getAttribute('data-entity')
await page.locator(`[data-entity="${target}"]`).click()
await page.locator(`[data-entity="${target}"] [data-row-open]`).click()
await expect(name(page)).toHaveText(target)
await page.keyboard.press('Escape')
await expect(page.getByTestId('models-search')).toHaveValue('qwen3')
await expect(facet).toHaveAttribute('aria-pressed', 'true')
await expect(rows(page)).toHaveCount(before)
await expect(page.locator(`[data-entity="${target}"]`)).toHaveAttribute('data-selected', 'true')
await page.locator(`[data-entity="${target}"] [data-row-open]`).click()
await page.getByTestId('model-page-back').click()
await expect(page.getByTestId('models-search')).toHaveValue('qwen3')
await expect(rows(page)).toHaveCount(before)
})
test('the Installed view and its state filter come back with the page closed', async ({ page }) => {
await setup(page, {}, '/app/models?view=installed&state=running')
await expect(installedRows(page).first()).toBeVisible({ timeout: 15_000 })
await expect(installedRows(page)).toHaveCount(2)
await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click()
await expect(name(page)).toHaveText('qwen3-8b-instruct')
// The walker walks the filtered Installed list, not the gallery.
await expect(page.getByTestId('model-page-position')).toHaveText('1 of 2')
await page.keyboard.press('Escape')
await expect(page).toHaveURL(/view=installed&state=running/)
await expect(installedRows(page)).toHaveCount(2)
await expect(page.getByRole('tab', { name: /^Running/ })).toHaveAttribute('aria-selected', 'true')
})
test('coming back does not read the gallery again', async ({ page }) => {
const { state } = await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(500)
const before = state.listingRequests.length
await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click()
await expect(name(page)).toHaveText('qwen3-8b-instruct')
await page.keyboard.press('Escape')
await expect(rows(page).first()).toBeVisible()
await page.waitForTimeout(500)
// The page itself looks its own entry up once; the list asks for nothing.
expect(state.listingRequests.length - before).toBeLessThanOrEqual(1)
expect(state.listingRequests.slice(before).every(search => search.includes('term=qwen3-8b-instruct'))).toBe(true)
})
test('changing tabs keeps the walker, and Back still leaves for the list in one step', async ({ page }) => {
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await page.locator('[data-entity="qwen3-8b-instruct"] [data-row-open]').click()
await page.keyboard.press('2')
await page.keyboard.press('3')
await expect(page.getByTestId('model-page-walk')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page).toHaveURL(/\/app\/models$/)
})
test('the list keeps its scroll position and focus returns to the row arrow', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 520 })
await setup(page)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
const wrap = page.locator('[data-testid="discover-rail"]')
const scroller = await wrap.evaluate(el => {
let node = el
while (node && node.scrollHeight <= node.clientHeight + 4) node = node.parentElement
return node ? (node.classList.contains('ledger-wrap') ? 'wrap' : 'page') : 'none'
})
expect(scroller).not.toBe('none')
const target = await rows(page).nth(14).getAttribute('data-entity')
await page.locator(`[data-entity="${target}"]`).scrollIntoViewIfNeeded()
const position = () => page.evaluate(() => {
const els = [document.querySelector('.models-page'), document.querySelector('.ledger-wrap')]
return els.map(el => (el ? Math.round(el.scrollTop) : 0))
})
const before = await position()
expect(before[0] + before[1]).toBeGreaterThan(50)
await page.locator(`[data-entity="${target}"] [data-row-open]`).click()
await expect(name(page)).toHaveText(target)
await page.keyboard.press('Escape')
await expect(rows(page).first()).toBeVisible()
await expect.poll(position).toEqual(before)
await expect(page.locator(`[data-entity="${target}"] [data-row-open]`)).toBeFocused()
})
})
test.describe('Model page - an Explore model', () => {
test('the title block names the model, its type, backend and licence, and the install action', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
await expect(page.locator('.modelpage-chips')).toContainText('Text and reasoning')
await expect(page.locator('.modelpage-chips')).toContainText('llama-cpp')
await expect(page.locator('.modelpage-chips')).toContainText('apache-2.0')
await expect(page.locator('.modelpage-lede')).toContainText('Dense 32B chat model')
await expect(page.getByTestId('model-page-install')).toContainText('Install')
await expect(page.getByTestId('leaves-free')).toContainText('leaves')
await expect(page.getByTestId('model-page-state')).toHaveCount(0)
})
test('only the tabs that apply are there, without disabled ones', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
await expect(page.getByRole('tab')).toHaveText(['Overview', 'Fit and memory', 'Variants and files'])
await expect(page.getByRole('tab', { name: 'Logs' })).toHaveCount(0)
await expect(page.getByRole('tab', { selected: true })).toHaveText('Overview')
})
test('the answer strip gives fit, what it does and what installing leaves', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
await expect(page.getByTestId('answer-fit')).toContainText('Fits in GPU')
await expect(page.getByTestId('answer-fit')).toContainText('at 8K context')
await expect(page.getByTestId('answer-does')).toContainText('chat')
await expect(page.getByTestId('answer-state')).toContainText('Leaves')
})
test('the overview lists backend, licence, max context, tags and links, and a memory bar', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
const about = page.locator('.modelpage-about')
await expect(about).toContainText('llama-cpp')
await expect(about).toContainText('apache-2.0')
await expect(about).toContainText('Max context')
await expect(about).toContainText('128K tokens')
await expect(about.getByRole('link', { name: /huggingface\.co\/example\/qwen3-32b-instruct/ })).toHaveAttribute('target', '_blank')
await expect(page.getByTestId('glance-bar')).toBeVisible()
await expect(page.getByTestId('model-page-glance')).toContainText('free after')
})
test('the tab strip is a tab list: click, keys 1 to 3, arrows, and the address says which', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
await tab(page, 'fit').click()
await expect(page).toHaveURL(/tab=fit/)
await expect(page.getByTestId('model-page-fit')).toBeVisible()
await page.keyboard.press('3')
await expect(page).toHaveURL(/tab=variants/)
await expect(page.getByTestId('model-page-variants')).toBeVisible()
await page.keyboard.press('1')
await expect(page).not.toHaveURL(/tab=/)
await tab(page, 'overview').focus()
await page.keyboard.press('ArrowRight')
await expect(tab(page, 'fit')).toHaveAttribute('aria-selected', 'true')
await expect(tab(page, 'fit')).toBeFocused()
await page.keyboard.press('End')
await expect(tab(page, 'variants')).toHaveAttribute('aria-selected', 'true')
})
test('a tab named in the address opens, and an unknown one falls back to the overview', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-32b-instruct?tab=fit')
await expect(page.getByTestId('model-page-fit')).toBeVisible()
await page.goto('/app/models/qwen3-32b-instruct?tab=logs')
await expect(page.getByTestId('model-page-panel-overview')).toBeVisible()
})
test('Fit and memory says it in words, moves with the context size and draws the memory by context', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
await page.keyboard.press('2')
const banner = page.getByTestId('fit-banner')
await expect(banner).toContainText('Fits in GPU')
await expect(page.getByTestId('fit-bar')).toBeVisible()
await expect(page.getByTestId('vram-chart')).toBeVisible()
await expect(page.getByTestId('vram-bar-8192')).toHaveAttribute('data-selected', 'true')
await page.getByTestId('fit-context-262144').click()
await expect(banner).not.toContainText('Fits in GPU')
await expect(banner).toContainText('256K')
await expect(page.getByTestId('vram-bar-262144')).toHaveAttribute('data-selected', 'true')
await expect(page.getByTestId('vram-bar-262144')).toHaveAttribute('data-over', 'true')
await page.getByTestId('vram-bar-16384').scrollIntoViewIfNeeded()
await page.getByTestId('vram-bar-16384').locator('rect').first().click()
await expect(page.getByTestId('vram-bar-16384')).toHaveAttribute('data-selected', 'true')
await expect(page.getByTestId('vram-chart').locator('details')).toBeAttached()
})
test('a model that does not fit says how far over, never that it fits', async ({ page }) => {
await openFromExplore(page, 'llama-3.3-70b-instruct-iq2', { profile: 'laptop' })
await expect(page.getByTestId('answer-fit')).not.toContainText('Fits in GPU')
await expect(page.getByTestId('answer-fit')).toContainText(/more than|CPU/)
})
test('a host with no GPU measures against memory and says so', async ({ page }) => {
await openFromExplore(page, 'qwen3-8b-instruct', { profile: 'nogpu' })
await expect(page.getByTestId('answer-fit')).toContainText('Fits in memory')
await page.keyboard.press('2')
await expect(page.getByTestId('fit-pool')).toContainText('System memory')
})
test('Variants and files lists the builds with size and fit and installs the one you pick', async ({ page }) => {
const { state } = await openFromExplore(page, 'qwen3-14b-instruct', { installed: [] })
await page.keyboard.press('3')
const base = page.getByTestId('variant-row-qwen3-14b-instruct')
const q4 = page.getByTestId('variant-row-qwen3-14b-instruct-q4')
await expect(base).toBeVisible()
await expect(q4).toBeVisible()
await expect(base).toContainText('Auto-selected')
await expect(q4).toContainText('Q4_K_M')
await expect(q4).toContainText('Fits in GPU')
await page.getByTestId('variant-install-qwen3-14b-instruct-q4').click()
await expect.poll(() => state.installs.length).toBe(1)
expect(state.installs[0]).toEqual({ name: 'qwen3-14b-instruct', search: '?variant=qwen3-14b-instruct-q4' })
})
test('choosing a build changes what the Install button installs and the estimate it shows', async ({ page }) => {
const { state } = await openFromExplore(page, 'qwen3-14b-instruct', { installed: [] })
await expect(page.getByTestId('model-page-install')).toContainText('Install Q8_0')
await page.getByTestId('model-page-builds').click()
await page.getByTestId('model-page-build-qwen3-14b-instruct-q4').click()
await expect(page.getByTestId('model-page-install')).toContainText('Install Q4_K_M')
await page.getByTestId('model-page-install').click()
await expect.poll(() => state.installs.length).toBe(1)
expect(state.installs[0].search).toBe('?variant=qwen3-14b-instruct-q4')
await page.keyboard.press('2')
await expect(page.getByTestId('fit-build-qwen3-14b-instruct-q4')).toHaveAttribute('aria-pressed', 'true')
await page.getByTestId('fit-build-qwen3-14b-instruct').click()
await expect(page.getByTestId('fit-build-qwen3-14b-instruct')).toHaveAttribute('aria-pressed', 'true')
})
test('the build menu works from the keyboard: it opens on the chosen build and the arrows move', async ({ page }) => {
const { state } = await openFromExplore(page, 'qwen3-14b-instruct', { installed: [] })
await page.getByTestId('model-page-builds').focus()
await page.keyboard.press('Enter')
await expect(page.getByTestId('model-page-build-qwen3-14b-instruct')).toBeFocused()
await page.keyboard.press('ArrowDown')
await expect(page.getByTestId('model-page-build-qwen3-14b-instruct-q4')).toBeFocused()
await page.keyboard.press('Enter')
await expect(page.getByTestId('model-page-install')).toContainText('Install Q4_K_M')
await page.getByTestId('model-page-install').click()
await expect.poll(() => state.installs.length).toBe(1)
})
test('the files of a build are listed with their source and checksum', async ({ page }) => {
await openFromExplore(page, 'qwen3-32b-instruct')
await page.keyboard.press('3')
const file = page.getByTestId('file-row').first()
await expect(file).toContainText('qwen3-32b-instruct.gguf')
await expect(file).toContainText('huggingface.co/example/qwen3-32b-instruct')
await expect(file).toContainText('aaaaaaaaaaaaaaaa')
await expect(file).toContainText('Not downloaded')
await expect(page.getByTestId('variants-single')).toContainText('one build')
})
test('Install starts the install and the title block turns into progress with Cancel', async ({ page }) => {
const { state } = await openFromExplore(page, 'qwen3-32b-instruct')
await page.getByTestId('model-page-install').click()
await expect.poll(() => state.installs.length).toBe(1)
expect(state.installs[0].search).toBe('')
state.operations = [{ jobID: 'job-1', name: 'qwen3-32b-instruct', progress: 38, currentBytes: 7.5 * GB, totalBytes: 19.9 * GB, cancellable: true, isBackend: false }]
const installing = page.getByTestId('model-page-installing')
await expect(installing).toBeVisible({ timeout: 8_000 })
await expect(installing).toContainText('38%')
await expect(installing).toContainText('7.5 GB of 19.9 GB')
await expect(page.getByTestId('model-page-cancel')).toBeVisible()
await expect(page.getByTestId('answer-state')).toContainText('Installing 38%')
})
})
test.describe('Model page - an installed model', () => {
test('it has all six tabs, a state, Load and a menu, and the keys 1 to 6 reach them', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-14b-instruct')
await expect(name(page)).toHaveText('qwen3-14b-instruct')
await expect(page.getByRole('tab')).toHaveText([/^Overview$/, /^Fit and memory$/, /^Variants and files/, /^Usage and history$/, /^Configuration$/, /^Logs$/])
await expect(page.getByTestId('model-page-state')).toContainText('Idle')
await expect(page.getByTestId('model-page-load')).toBeVisible()
for (const [key, id] of [['4', 'usage'], ['5', 'config'], ['6', 'logs']]) {
await page.keyboard.press(key)
await expect(page.getByTestId(`model-page-panel-${id}`)).toBeVisible()
}
})
test('a running model shows Stop, asks before stopping and says Running', async ({ page }) => {
const { state } = await setup(page, {}, '/app/models/qwen3-8b-instruct')
await expect(page.getByTestId('model-page-state')).toContainText('Running')
await expect(page.getByTestId('model-page-load')).toHaveCount(0)
await page.getByTestId('model-page-stop').click()
await expect(page.getByRole('alertdialog')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop' }).click()
await expect.poll(() => state.stops).toEqual(['qwen3-8b-instruct'])
})
test('Load calls the same endpoint the table does', async ({ page }) => {
const { state } = await setup(page, {}, '/app/models/qwen3-14b-instruct')
await page.getByTestId('model-page-load').click()
await expect.poll(() => state.loads).toEqual(['qwen3-14b-instruct'])
})
test('the menu holds disable, pin, edit configuration, logs and delete', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-14b-instruct')
await page.getByRole('button', { name: 'Actions for qwen3-14b-instruct' }).click()
const menu = page.getByRole('menu')
await expect(menu.getByRole('menuitem')).toHaveText([/Disable model/, /Pin/, /Edit configuration/, /Backend logs/, /Delete model/])
await menu.getByRole('menuitem', { name: /Edit configuration/ }).click()
await expect(page.getByTestId('model-page-config')).toBeVisible()
await expect(page).toHaveURL(/tab=config/)
await page.getByRole('button', { name: 'Actions for qwen3-14b-instruct' }).click()
await page.getByRole('menu').getByRole('menuitem', { name: /Backend logs/ }).click()
await expect(page.getByTestId('model-page-logs')).toBeVisible()
})
test('delete asks first, removes the model and leaves for the installed list', async ({ page }) => {
const { state } = await setup(page, {}, '/app/models/qwen3-14b-instruct-q4')
await expect(name(page)).toHaveText('qwen3-14b-instruct-q4')
await page.getByRole('button', { name: 'Actions for qwen3-14b-instruct-q4' }).click()
await page.getByRole('menuitem', { name: /Delete model/ }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete model' }).click()
await expect.poll(() => state.deletes).toEqual(['qwen3-14b-instruct-q4'])
await expect(page).toHaveURL(/\/app\/models\?view=installed/)
})
test('Used by lists the agent, the alias and what else names this model, from the real lists', async ({ page }) => {
await setup(page, { aliases: [{ name: 'default-chat', target: 'qwen3-14b-instruct' }] }, '/app/models/qwen3-14b-instruct')
const used = page.getByTestId('model-page-usedby')
await expect(used).toContainText('research-helper')
await expect(used).toContainText('Agent')
await expect(used).toContainText('default-chat')
await expect(used).toContainText('Alias')
await expect(used.getByRole('link', { name: 'research-helper' })).toHaveAttribute('href', '/app/agents/research-helper/edit')
})
test('a model nothing uses says so', async ({ page }) => {
await setup(page, {}, '/app/models/kokoro-82m')
await expect(page.getByTestId('model-page-usedby')).toContainText('No agent, task, failover chain or alias')
})
test('Usage and history is honest: nothing is recorded, and it names what will appear', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-14b-instruct?tab=usage')
await expect(page.getByTestId('usage-empty')).toContainText('not recorded yet')
const missing = page.getByTestId('usage-missing')
for (const label of ['Requests per day', 'Time to first token', 'Loads and stops', 'Configuration changes']) {
await expect(missing).toContainText(label)
}
await expect(missing.getByText('Not recorded')).toHaveCount(4)
await expect(page.locator('.dk-chart')).toHaveCount(0)
const known = page.getByTestId('usage-known')
await expect(known).toContainText('Idle')
await expect(page.getByTestId('usage-usedby')).toContainText('1 agent')
})
test('Configuration holds the Placement section, the file it writes and a way into the full editor', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-14b-instruct?tab=config')
await expect(page.getByTestId('placement')).toBeVisible()
await expect(page.getByTestId('placement-file')).toContainText('context_size: 8192')
await expect(page.getByTestId('open-full-editor')).toHaveAttribute('href', '/app/model-editor/qwen3-14b-instruct')
await page.getByTestId('open-full-editor').click()
await expect(page.locator('h1', { hasText: 'Model Editor' })).toBeVisible()
await expect(page.getByRole('button', { name: /Back to this model/ })).toBeVisible()
})
test('Logs shows the backend output of the model in the log viewer', async ({ page }) => {
await page.routeWebSocket('**/ws/backend-logs/**', ws => {
ws.send(JSON.stringify({ type: 'initial', lines: [
{ timestamp: '2026-10-07T12:03:58Z', stream: 'stdout', text: 'Loading model qwen3-14b-instruct' },
{ timestamp: '2026-10-07T12:04:03Z', stream: 'stderr', text: 'rope scaling set to yarn' },
] }))
})
await setup(page, {}, '/app/models/qwen3-14b-instruct?tab=logs')
await expect(page.getByTestId('model-page-logs')).toContainText('Loading model qwen3-14b-instruct')
await expect(page.getByTestId('model-page-logs')).toContainText('rope scaling set to yarn')
await expect(page.getByTestId('model-page-logs').getByRole('button', { name: 'Export' })).toBeVisible()
await expect(page.getByTestId('model-page-logs').locator('.page-title')).toHaveCount(0)
})
test('a model the gallery does not list still has a page, from the installed list alone', async ({ page }) => {
await setup(page, {}, '/app/models/my-finetune-q4')
await expect(name(page)).toHaveText('my-finetune-q4')
await expect(page.getByRole('tab')).toHaveText(['Overview', 'Fit and memory', 'Usage and history', 'Configuration', 'Logs'])
await expect(page.getByTestId('model-page-load')).toBeVisible()
await page.keyboard.press('2')
await expect(page.getByTestId('fit-unavailable')).toBeVisible()
})
test('a disabled model offers Enable instead of Load', async ({ page }) => {
await setup(page, {}, '/app/models/whisper-medium')
await expect(page.getByTestId('model-page-state')).toContainText('Disabled')
await expect(page.getByTestId('model-page-enable')).toBeVisible()
await expect(page.getByTestId('model-page-load')).toHaveCount(0)
})
})
test.describe('Model page - states', () => {
test('loading shows a skeleton, not a half page', async ({ page }) => {
await setup(page, { listingDelay: 1500 }, '/app/models/qwen3-32b-instruct')
await expect(page.getByTestId('model-page')).toHaveAttribute('data-state', 'loading')
await expect(page.getByRole('status', { name: 'Loading the model' })).toBeVisible()
await expect(name(page)).toHaveText('qwen3-32b-instruct', { timeout: 10_000 })
})
test('an unknown id says so, offers the closest matches and a way back', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-32b')
const missing = page.getByTestId('model-page-missing')
await expect(missing).toContainText('No model called qwen3-32b')
await expect(missing.getByRole('link', { name: 'qwen3-32b-instruct' })).toBeVisible()
await missing.getByRole('link', { name: 'qwen3-32b-instruct' }).click()
await expect(name(page)).toHaveText('qwen3-32b-instruct')
})
test('an id nothing matches has no suggestions and still offers the way back', async ({ page }) => {
await setup(page, {}, '/app/models/nothing-here')
await expect(page.getByTestId('model-page-missing')).toContainText('No model called nothing-here')
await expect(page.getByTestId('model-page-missing').getByRole('list')).toHaveCount(0)
await page.getByTestId('model-page-missing').getByRole('button', { name: 'Back to models' }).click()
await expect(page).toHaveURL(/\/app\/models$/)
})
test('offline, a gallery model shows the reason, disables Install and offers a retry', async ({ page }) => {
const { state } = await setup(page, { listingStatus: 500 }, '/app/models/qwen3-32b-instruct')
await expect(page.getByTestId('model-page-offline')).toContainText('Cannot reach the gallery')
await expect(page.getByTestId('model-page-install')).toBeDisabled()
await expect(page.getByText('Needs the gallery')).toBeVisible()
state.listingStatus = 200
await page.getByTestId('model-page-offline').getByRole('button', { name: 'Retry' }).click()
await expect(page.getByTestId('model-page')).toHaveAttribute('data-state', 'ready')
await expect(page.getByTestId('model-page-install')).toBeEnabled()
})
test('offline, an installed model keeps working and the banner says it is the installed view', async ({ page }) => {
await setup(page, { listingStatus: 500 }, '/app/models/qwen3-14b-instruct')
await expect(page.getByTestId('model-page-offline')).toContainText('installed model')
await expect(page.getByTestId('model-page-load')).toBeEnabled()
await expect(page.getByRole('tab', { name: 'Logs' })).toBeVisible()
})
test('a failed install shows why and offers Retry, which dismisses the old failure first', async ({ page }) => {
const calls = []
await mockLedger(page, { operations: [{ jobID: 'job-9', name: 'qwen3-32b-instruct', error: 'checksum mismatch', isBackend: false }] })
await mockPlacement(page)
await page.route('**/api/operations/*/dismiss', route => { calls.push('dismiss'); return route.fulfill({ json: {} }) })
await page.route('**/api/models/install/*', route => { calls.push('install'); return route.fulfill({ json: { jobID: 'job-10' } }) })
await page.goto('/app/models/qwen3-32b-instruct')
await expect(page.getByTestId('model-page-failed')).toContainText('checksum mismatch')
await expect(page.getByTestId('answer-state')).toContainText('Install failed')
await page.getByTestId('model-page-install').click()
await expect.poll(() => calls).toEqual(['dismiss', 'install'])
})
test('with no estimate the Fit tab says so instead of drawing nothing', async ({ page }) => {
await mockLedger(page, { gallery: GALLERY.map(e => (e.name === 'whisper-large-v3' ? { ...e, estimate: { sizeBytes: 0, sizeDisplay: '', estimates: {} } } : e)) })
await mockPlacement(page)
await page.goto('/app/models/whisper-large-v3?tab=fit')
await expect(page.getByTestId('fit-unavailable')).toBeVisible()
})
test('a speech model says memory does not grow with the context size and draws no chart', async ({ page }) => {
await setup(page, {}, '/app/models/kokoro-82m?tab=fit')
await expect(page.getByTestId('fit-banner')).toBeVisible()
await expect(page.getByTestId('fit-flat').or(page.getByTestId('vram-chart'))).toBeVisible()
})
})
test.describe('Model page - phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('the page fits the width: title, action, tabs and strip stack without sideways scroll', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-14b-instruct')
await expect(name(page)).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
await expect(page.getByTestId('model-page-load')).toBeVisible()
const box = await page.getByTestId('model-page-load').boundingBox()
expect(box.x + box.width).toBeLessThanOrEqual(390)
await expect(page.getByTestId('model-page-answer')).toBeVisible()
await expect(page.getByTestId('model-page-tab-logs')).toBeAttached()
})
test('the variants and files tables stay inside the width', async ({ page }) => {
await setup(page, { installed: [] }, '/app/models/qwen3-14b-instruct?tab=variants')
await expect(page.getByTestId('variant-row-qwen3-14b-instruct-q4')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
for (const row of ['variant-install-qwen3-14b-instruct', 'variant-install-qwen3-14b-instruct-q4']) {
const box = await page.getByTestId(row).boundingBox()
expect(box.x + box.width).toBeLessThanOrEqual(390)
}
})
test('the memory chart and the context chips fit a phone', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-32b-instruct?tab=fit')
await expect(page.getByTestId('vram-chart')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
})
test('the Configuration tab and the Placement section fit a phone', async ({ page }) => {
await setup(page, {}, '/app/models/qwen3-14b-instruct?tab=config')
await expect(page.getByTestId('placement')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
})
})
@@ -0,0 +1,573 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger, mockPlacement, GALLERY, GB, PROFILES, vramFor } from './ledger-fixtures.js'
// The Placement section: where a model runs. On the model page's Configuration
// tab it saves on its own; in the model editor it feeds the editor's own Save.
// Both write gpu_layers, tensor_split, main_gpu and context_size and nothing
// else, and every figure comes from the device list and the estimate.
const page_ = (id, tab = 'config') => `/app/models/${id}?tab=${tab}`
const mode = (page, id) => page.getByTestId(`placement-mode-${id}`)
const file = (page) => page.getByTestId('placement-file')
const verdict = (page) => page.getByTestId('placement-verdict')
const save = (page) => page.getByTestId('config-save')
async function setup(page, { url = page_('qwen3-14b-instruct'), ledger = {}, placement = {} } = {}) {
const state = await mockLedger(page, ledger)
const handle = await mockPlacement(page, placement)
await page.goto(url)
await expect(page.getByTestId('placement')).toBeVisible({ timeout: 15_000 })
return Object.assign(handle, { ledger: state })
}
const entry = (name) => GALLERY.find(e => e.name === name)
// The laptop's GPU with plenty of system memory behind it, for the spill cases
// that are about the GPU and not about running out of memory.
const laptopBigRam = { ...PROFILES.laptop, ram: { total: 64 * GB, used: 8 * GB, free: 56 * GB, available: 56 * GB, usage_percent: 12 } }
test.describe('Placement - modes', () => {
test('Auto is the default for a model that sets nothing, writes nothing and says what the engine does', async ({ page }) => {
const { patches } = await setup(page)
await expect(mode(page, 'auto')).toHaveAttribute('aria-checked', 'true')
await expect(mode(page, 'auto')).toContainText('writes nothing')
await expect(mode(page, 'cpu')).toContainText('gpu_layers: 0')
await expect(mode(page, 'custom')).toContainText('gpu_layers: N')
const hint = page.getByTestId('placement-auto-hint')
await expect(hint).toContainText('every layer')
await expect(hint).toContainText('llama.cpp')
await expect(file(page)).not.toContainText('gpu_layers')
await expect(save(page)).toBeDisabled()
await expect(save(page)).toContainText('Saved')
expect(patches).toEqual([])
})
test('CPU only writes gpu_layers: 0, says the model runs on the CPU and saves exactly that', async ({ page }) => {
const { patches } = await setup(page)
await mode(page, 'cpu').click()
await expect(mode(page, 'cpu')).toHaveAttribute('aria-checked', 'true')
await expect(file(page).locator('[data-written="true"]', { hasText: 'gpu_layers: 0' })).toBeVisible()
await expect(verdict(page)).toHaveAttribute('data-state', 'cpu')
await expect(verdict(page)).toContainText('Runs on CPU only')
await expect(verdict(page)).toContainText('Slower than on a GPU')
await expect(save(page)).toBeEnabled()
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { gpu_layers: 0 } }])
})
test('CPU only has no GPU bars, only system memory', async ({ page }) => {
await setup(page)
await mode(page, 'cpu').click()
await expect(page.getByTestId('placement-bar-ram')).toBeVisible()
await expect(page.getByTestId('placement-bar-gpu-0')).toHaveCount(0)
})
test('Custom starts at all layers and the field takes a number that goes into the file', async ({ page }) => {
const { patches } = await setup(page)
await mode(page, 'custom').click()
await expect(page.getByTestId('placement-layers')).toHaveValue('All')
await expect(file(page)).toContainText('gpu_layers: 99999999')
await page.getByTestId('placement-layers').fill('12')
await expect(file(page).locator('[data-written="true"]', { hasText: 'gpu_layers: 12' })).toBeVisible()
await expect(mode(page, 'custom')).toContainText('gpu_layers: 12')
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { gpu_layers: 12 } }])
})
test('All layers writes the value LocalAI already uses by default', async ({ page }) => {
const { patches } = await setup(page)
await mode(page, 'custom').click()
await page.getByTestId('placement-layers').fill('7')
await expect(file(page)).toContainText('gpu_layers: 7')
await page.getByTestId('placement-all-layers').click()
await expect(file(page)).toContainText('gpu_layers: 99999999')
await expect(page.getByTestId('placement-layers')).toHaveValue('All')
await expect(page.getByTestId('placement-all-layers')).toHaveAttribute('aria-pressed', 'true')
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { gpu_layers: 99999999 } }])
})
test('typing the word all, in any case, means all layers', async ({ page }) => {
await setup(page, { url: page_('llama-3.3-70b-instruct-iq2') })
await expect(page.getByTestId('placement-layers')).toHaveValue('20')
await page.getByTestId('placement-layers').fill('ALL')
await expect(file(page)).toContainText('gpu_layers: 99999999')
})
test('a model already set to all layers opens in Custom with All selected', async ({ page }) => {
await setup(page, { url: page_('qwen3-8b-instruct') })
await expect(mode(page, 'custom')).toHaveAttribute('aria-checked', 'true')
await expect(page.getByTestId('placement-layers')).toHaveValue('All')
})
test('going back to Auto removes the key, and the save sends null for it', async ({ page }) => {
const { patches } = await setup(page, { url: page_('qwen3-8b-instruct') })
await mode(page, 'auto').click()
await expect(file(page)).not.toContainText('gpu_layers')
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-8b-instruct', patch: { gpu_layers: null } }])
})
test('a model set to zero opens as CPU only', async ({ page }) => {
await setup(page, { url: page_('gemma-3-12b-it') })
await expect(mode(page, 'cpu')).toHaveAttribute('aria-checked', 'true')
})
test('with a layer count from the estimate there is a slider, and it writes the count', async ({ page }) => {
const { patches } = await setup(page, { placement: { layerCount: 40 } })
await mode(page, 'custom').click()
const slider = page.getByTestId('placement-slider')
await expect(slider).toBeVisible()
await expect(slider).toHaveAttribute('max', '40')
await expect(page.getByTestId('placement-no-slider')).toHaveCount(0)
await slider.fill('25')
await expect(page.getByTestId('placement-layers')).toHaveValue('25')
await expect(file(page)).toContainText('gpu_layers: 25')
await expect(page.getByTestId('placement-custom')).toContainText('25 of 40 layers on the GPU')
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { gpu_layers: 25 } }])
})
test('without a layer count there is no slider and the page says why, never a made-up range', async ({ page }) => {
await setup(page)
await mode(page, 'custom').click()
await expect(page.getByTestId('placement-slider')).toHaveCount(0)
await expect(page.getByTestId('placement-no-slider')).toContainText('not reported')
await expect(page.getByTestId('placement-no-slider')).toContainText('99999999')
})
})
test.describe('Placement - context size', () => {
test('the presets write context_size and the estimate follows', async ({ page }) => {
const { patches, estimateCalls } = await setup(page)
await expect(page.getByTestId('placement-context-8192')).toHaveAttribute('aria-pressed', 'true')
await page.getByTestId('placement-context-32768').click()
await expect(page.getByTestId('placement-context-32768')).toHaveAttribute('aria-pressed', 'true')
await expect(file(page).locator('[data-written="true"]', { hasText: 'context_size: 32768' })).toBeVisible()
await expect.poll(() => estimateCalls.some(call => call.context_size === 32768)).toBe(true)
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { context_size: 32768 } }])
})
test('the number field takes any size', async ({ page }) => {
await setup(page)
await page.getByTestId('placement-context-input').fill('20000')
await expect(file(page)).toContainText('context_size: 20000')
await expect(page.locator('[data-testid^="placement-context-"][aria-pressed="true"]')).toHaveCount(0)
})
test('a bigger context grows the KV segment of the bar, from the estimate and nothing else', async ({ page }) => {
await setup(page)
const kv = (p) => p.getByTestId('placement-bar-gpu-0').locator('[data-segment="kv"]')
await expect(kv(page)).toBeVisible()
const width = async () => (await kv(page).boundingBox()).width
const small = await width()
await page.getByTestId('placement-context-65536').click()
await expect.poll(width).toBeGreaterThan(small * 2)
})
test('a model that sets no context size says which one the estimate used', async ({ page }) => {
await setup(page, { placement: { configs: { 'qwen3-14b-instruct': 'name: qwen3-14b-instruct\nbackend: llama-cpp\nparameters:\n model: a.gguf\n' } } })
await expect(page.getByTestId('placement')).toContainText('Not set. The estimate uses 8K.')
})
})
test.describe('Placement - the bars and the verdict', () => {
test('one GPU: a bar with other apps, the model and what is free after, and a verdict that fits', async ({ page }) => {
await setup(page)
const bar = page.getByTestId('placement-bar-gpu-0')
await expect(bar).toContainText('RTX 4090')
await expect(bar).toContainText('free after')
await expect(bar.locator('[data-segment="other"]')).toBeVisible()
await expect(bar.locator('[data-segment="model"]')).toBeVisible()
await expect(verdict(page)).toHaveAttribute('data-state', 'fits')
await expect(verdict(page)).toContainText('Fits in GPU')
await expect(page.getByTestId('placement-bar-ram')).toHaveCount(0)
})
test('the bar says how much is free after, from the estimate and the device', async ({ page }) => {
await setup(page)
const need = vramFor(entry('qwen3-14b-instruct'), 8192, null)
const free = 21.4 * GB - need
await expect(page.getByTestId('placement-bar-gpu-0')).toContainText(`${(free / GB).toFixed(1)} GB free after`)
})
test('a spill: some layers on the GPU and the rest in system memory, in words, without a made-up speed', async ({ page }) => {
await setup(page, { ledger: { resources: laptopBigRam }, url: page_('qwen3-14b-instruct') })
await mode(page, 'custom').click()
await page.getByTestId('placement-layers').fill('10')
await expect(verdict(page)).toHaveAttribute('data-state', 'spill')
await expect(verdict(page)).toContainText('Spills to CPU')
await expect(verdict(page)).toContainText('stays in system memory')
await expect(verdict(page)).toContainText('Slower than all layers on the GPU')
await expect(verdict(page)).not.toContainText(/\d+(\.\d+)?\s?x\b/)
await expect(verdict(page)).not.toContainText('tokens per second')
await expect(page.getByTestId('placement-bar-gpu-0')).toBeVisible()
await expect(page.getByTestId('placement-bar-ram')).toBeVisible()
})
test('a spill that does not fit in system memory either says so, with both figures', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' } })
await mode(page, 'custom').click()
await page.getByTestId('placement-layers').fill('10')
await expect(verdict(page)).toHaveAttribute('data-state', 'spill-over')
await expect(verdict(page)).toContainText('Not enough memory')
await expect(verdict(page)).toContainText('would stay in system memory, which has')
await expect(page.getByTestId('placement-bar-ram')).toContainText('over')
})
test('too many layers for the GPU is said plainly, with the engine behaviour that is true', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' } })
await mode(page, 'custom').click()
await page.getByTestId('placement-layers').fill('40')
await expect(verdict(page)).toHaveAttribute('data-state', 'toomany')
await expect(verdict(page)).toContainText('Too many layers for the GPU')
await expect(verdict(page)).toContainText('llama.cpp')
await expect(page.getByTestId('placement-bar-gpu-0')).toContainText('over')
})
test('Auto that does not all fit says the engine will lower the count, and what all layers need', async ({ page }) => {
await setup(page, { ledger: { resources: laptopBigRam } })
await expect(verdict(page)).toHaveAttribute('data-state', 'trimmed')
await expect(verdict(page)).toContainText('lowers the layer count at load time')
const need = vramFor(entry('qwen3-14b-instruct'), 8192, null)
await expect(verdict(page)).toContainText(`All layers need ${(need / GB).toFixed(1)} GB`)
})
test('Auto that does not fit in memory after the trim says so', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' } })
await expect(verdict(page)).toHaveAttribute('data-state', 'trimmed-over')
await expect(verdict(page)).toContainText('Not enough memory')
})
test('the bar grows past the tick when it does not fit, never clipped to look like it fits', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' } })
await mode(page, 'custom').click()
await page.getByTestId('placement-layers').fill('40')
await expect(page.getByTestId('placement-bar-gpu-0').locator('.memorybar')).toHaveAttribute('data-over', 'true')
})
})
test.describe('Placement - two GPUs', () => {
test('one bar per GPU with its name, and the split controls appear', async ({ page }) => {
await setup(page, { ledger: { profile: 'twogpu' } })
await mode(page, 'custom').click()
await expect(page.getByTestId('placement-bar-gpu-0')).toContainText('GPU 0, RTX 4090')
await expect(page.getByTestId('placement-bar-gpu-1')).toContainText('GPU 1, RTX 3060')
await expect(page.getByTestId('placement-split')).toBeVisible()
})
test('the split slider writes tensor_split as percentages that sum to 100', async ({ page }) => {
const { patches } = await setup(page, { ledger: { profile: 'twogpu' } })
await page.getByTestId('placement-split-slider').fill('70')
await expect(page.getByTestId('placement-split-0')).toHaveValue('70')
await expect(page.getByTestId('placement-split-1')).toHaveValue('30')
await expect(file(page).locator('[data-written="true"]', { hasText: 'tensor_split: 70,30' })).toBeVisible()
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { tensor_split: '70,30' } }])
})
test('Split by free memory writes the shares from what each card has free', async ({ page }) => {
await setup(page, { ledger: { profile: 'twogpu' } })
await page.getByTestId('placement-split-free').click()
// 21.4 and 11.6 GB free
await expect(file(page)).toContainText('tensor_split: 65,35')
await expect(page.getByTestId('placement-split-0')).toHaveValue('65')
})
test('the model is shared between the bars by the split', async ({ page }) => {
await setup(page, { ledger: { profile: 'twogpu' } })
// A bar is as wide as its own card, so a segment's width is its share of
// that card. Scale by the card (24 and 12 GB) to compare the bytes.
const bytes = (id, card) => page.getByTestId(`placement-bar-gpu-${id}`).locator('[data-segment="model"]')
.evaluate((el, size) => el.getBoundingClientRect().width * size, card)
const ratio = async () => (await bytes(0, 24)) / (await bytes(1, 12))
await page.getByTestId('placement-split-slider').fill('50')
await expect.poll(async () => Math.abs((await ratio()) - 1)).toBeLessThan(0.05)
await page.getByTestId('placement-split-slider').fill('90')
await expect.poll(ratio).toBeGreaterThan(5)
})
test('Main GPU writes main_gpu as the card index', async ({ page }) => {
const { patches } = await setup(page, { ledger: { profile: 'twogpu' } })
await page.getByTestId('placement-main-1').click()
await expect(page.getByTestId('placement-main-1')).toHaveAttribute('aria-pressed', 'true')
await expect(file(page)).toContainText('main_gpu: "1"')
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { main_gpu: '1' } }])
})
test('CPU only hides the split controls', async ({ page }) => {
await setup(page, { ledger: { profile: 'twogpu' } })
await mode(page, 'cpu').click()
await expect(page.getByTestId('placement-split')).toHaveCount(0)
})
test('with one GPU there is no split at all', async ({ page }) => {
await setup(page)
await expect(page.getByTestId('placement-split')).toHaveCount(0)
})
})
test.describe('Placement - machines without a usable GPU', () => {
test('no GPU: only CPU only can be chosen, and the page says why', async ({ page }) => {
await setup(page, { ledger: { profile: 'nogpu' } })
await expect(mode(page, 'auto')).toBeDisabled()
await expect(mode(page, 'custom')).toBeDisabled()
await expect(mode(page, 'cpu')).toBeEnabled()
await expect(page.getByTestId('placement-nogpu')).toContainText('No GPU was found')
await expect(verdict(page)).toHaveAttribute('data-state', 'nogpu')
await expect(verdict(page)).toContainText('No GPU found')
await expect(page.getByTestId('placement-bar-ram')).toBeVisible()
await expect(page.getByTestId('placement-bar-gpu-0')).toHaveCount(0)
await expect(page.getByTestId('placement-fit')).toHaveCount(0)
})
test('no GPU and a model bigger than free memory says there is not enough', async ({ page }) => {
await setup(page, { ledger: { profile: 'nogpu' }, url: page_('llama-3.3-70b-instruct-iq2') })
await expect(verdict(page)).toHaveAttribute('data-state', 'nogpu-over')
await expect(verdict(page)).toContainText('Not enough memory')
})
test('a CPU only choice that does not fit in memory says so', async ({ page }) => {
await setup(page, { ledger: { resources: { ...PROFILES.laptop, ram: { total: 8 * GB, used: 7 * GB, free: 1 * GB, available: 1 * GB, usage_percent: 87 } } } })
await mode(page, 'cpu').click()
await expect(verdict(page)).toHaveAttribute('data-state', 'cpu-over')
await expect(verdict(page)).toContainText('Not enough memory')
})
test('a cluster controller shows no device bars, because they would describe the wrong machine', async ({ page }) => {
await setup(page, { ledger: { resources: { ...PROFILES.gpu24, cluster: { enabled: true, total_memory: 80 * GB, node_name: 'worker-1', node_count: 2, is_gpu: true } } } })
await expect(page.getByTestId('placement-cluster')).toContainText('other machines')
await expect(page.getByTestId('placement-bar-gpu-0')).toHaveCount(0)
await expect(verdict(page)).toHaveCount(0)
await expect(page.getByTestId('placement-fit')).toHaveCount(0)
})
test('when the machine cannot be read the section says so and still edits the keys', async ({ page }) => {
const { patches } = await setup(page, { ledger: { failResources: true } })
await expect(page.getByTestId('placement')).toContainText('could not be read')
await expect(verdict(page)).toHaveCount(0)
await mode(page, 'cpu').click()
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { gpu_layers: 0 } }])
})
})
test.describe('Placement - the estimate', () => {
test('while it loads the bars are skeletons and Fit it for me waits', async ({ page }) => {
await setup(page, { placement: { estimate: 'slow', slowMs: 2500 } })
await expect(page.getByTestId('placement-loading')).toBeVisible()
await expect(page.getByTestId('placement-fit')).toBeDisabled()
await expect(page.getByTestId('placement-bar-gpu-0')).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('placement-fit')).toBeEnabled()
})
test('unavailable: a note with Retry replaces the bars, and Fit it for me is gone', async ({ page }) => {
const { estimateCalls } = await setup(page, { placement: { estimate: 'unavailable' } })
const note = page.getByTestId('placement-unavailable')
await expect(note).toContainText('Estimate unavailable')
await expect(note).toContainText('could not read the model file')
await expect(page.getByTestId('placement-fit')).toHaveCount(0)
await expect(page.getByTestId('placement-bar-gpu-0')).toHaveCount(0)
await expect(verdict(page)).toHaveCount(0)
// The keys can still be edited.
await mode(page, 'cpu').click()
await expect(file(page)).toContainText('gpu_layers: 0')
const calls = estimateCalls.length
await page.getByTestId('placement-retry').click()
await expect.poll(() => estimateCalls.length).toBeGreaterThan(calls)
})
test('a failing estimate endpoint is the same as unavailable', async ({ page }) => {
await setup(page, { placement: { estimate: 'error' } })
await expect(page.getByTestId('placement-unavailable')).toBeVisible()
})
test('Retry reads the estimate again and the bars come back', async ({ page }) => {
const handle = await setup(page, { placement: { estimate: 'unavailable' } })
await expect(page.getByTestId('placement-unavailable')).toBeVisible()
// The stub reads its mode on every call.
handle.estimate = 'ok'
await page.getByTestId('placement-retry').click()
await expect(page.getByTestId('placement-bar-gpu-0')).toBeVisible()
})
})
test.describe('Placement - Fit it for me', () => {
const laptopFree = 5.4 * GB
const largest = (name, ctx) => {
let best = 0
for (let layers = 1; layers <= 256; layers++) {
if (vramFor(entry(name), ctx, layers) <= laptopFree * 0.95) best = layers
}
return best
}
test('picks the largest layer count whose estimate fits, from real estimate calls', async ({ page }) => {
const { patches, estimateCalls } = await setup(page, { ledger: { resources: laptopBigRam } })
const expected = largest('qwen3-14b-instruct', 8192)
expect(expected).toBeGreaterThan(0)
expect(expected).toBeLessThan(40)
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-applied')).toContainText(`Set to ${expected} layers on the GPU`)
await expect(mode(page, 'custom')).toHaveAttribute('aria-checked', 'true')
await expect(page.getByTestId('placement-layers')).toHaveValue(String(expected))
await expect(file(page)).toContainText(`gpu_layers: ${expected}`)
await expect(verdict(page)).toHaveAttribute('data-state', 'spill')
// The search asked the server about more than one layer count.
const layerAsks = new Set(estimateCalls.map(call => call.gpu_layers))
expect(layerAsks.size).toBeGreaterThan(4)
await save(page).click()
await expect.poll(() => patches).toEqual([{ name: 'qwen3-14b-instruct', patch: { gpu_layers: expected } }])
})
test('Undo puts back what was there', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' } })
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-applied')).toBeVisible()
await page.getByTestId('placement-undo').click()
await expect(mode(page, 'auto')).toHaveAttribute('aria-checked', 'true')
await expect(file(page)).not.toContainText('gpu_layers')
await expect(page.getByTestId('placement-applied')).toHaveCount(0)
await expect(save(page)).toBeDisabled()
})
test('Undo restores a previous number, not just Auto', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' }, url: page_('llama-3.3-70b-instruct-iq2') })
await expect(page.getByTestId('placement-layers')).toHaveValue('20')
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-applied')).toBeVisible()
await page.getByTestId('placement-undo').click()
await expect(page.getByTestId('placement-layers')).toHaveValue('20')
await expect(file(page)).toContainText('gpu_layers: 20')
})
test('another edit clears the Undo, because it would no longer mean what it says', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' } })
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-applied')).toBeVisible()
await page.getByTestId('placement-context-4096').click()
await expect(page.getByTestId('placement-applied')).toHaveCount(0)
})
test('when every layer fits it sets all layers and says so', async ({ page }) => {
await setup(page)
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-applied')).toContainText('Set to all layers')
await expect(file(page)).toContainText('gpu_layers: 99999999')
})
test('when not even one layer fits it changes nothing and says what to lower', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' }, url: page_('llama-3.3-70b-instruct-iq2') })
await page.getByTestId('placement-context-131072').click()
await expect(page.getByTestId('placement-bar-gpu-0')).toBeVisible()
const before = await file(page).textContent()
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-fit-note')).toContainText('Not even one layer fits')
await expect(page.getByTestId('placement-applied')).toHaveCount(0)
expect(await file(page).textContent()).toBe(before)
})
test('when layers do not change the estimate it says so instead of guessing', async ({ page }) => {
await setup(page, { ledger: { profile: 'laptop' }, placement: { modelLayers: 0 } })
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-fit-note')).toContainText('does not change with the layer count')
})
test('it sits with the bars on the model page for a model that is running', async ({ page }) => {
await setup(page, { url: page_('qwen3-8b-instruct') })
await expect(page.getByTestId('placement-fit')).toBeVisible()
await expect(page.getByText('Changes apply the next time it loads.')).toBeVisible()
})
})
test.describe('Placement - in the model editor', () => {
async function editor(page, name = 'qwen3-14b-instruct', ledger = {}, placement = {}) {
await mockLedger(page, ledger)
const handle = await mockPlacement(page, placement)
await page.goto(`/app/model-editor/${name}`)
await expect(page.getByTestId('editor-placement')).toBeVisible({ timeout: 15_000 })
return handle
}
test('the section is in the editor with a link in the section rail and keeps the YAML tab and field search', async ({ page }) => {
await editor(page)
await expect(page.getByTestId('rail-placement')).toBeVisible()
await expect(page.locator('input[placeholder="Search fields to add..."]')).toBeVisible()
await expect(page.getByRole('button', { name: /YAML/ })).toBeVisible()
await expect(page.getByTestId('placement-mode-auto')).toHaveAttribute('aria-checked', 'true')
})
test('a choice dirties the editor and the editor save sends it with the other fields', async ({ page }) => {
const { patches } = await editor(page)
await expect(page.getByRole('button', { name: 'Saved' })).toBeDisabled()
await page.getByTestId('placement-mode-cpu').click()
const saveButton = page.getByRole('button', { name: 'Save Changes' })
await expect(saveButton).toBeEnabled()
await saveButton.click()
await expect.poll(() => patches.length).toBe(1)
expect(patches[0].name).toBe('qwen3-14b-instruct')
expect(patches[0].patch.gpu_layers).toBe(0)
expect(patches[0].patch.context_size).toBe(8192)
expect(patches[0].patch.name).toBe('qwen3-14b-instruct')
await expect(page.getByRole('button', { name: 'Saved' })).toBeDisabled()
})
test('Auto after a number sends null so the key leaves the file', async ({ page }) => {
const { patches } = await editor(page, 'llama-3.3-70b-instruct-iq2')
await expect(page.getByTestId('placement-layers')).toHaveValue('20')
await page.getByTestId('placement-mode-auto').click()
await page.getByRole('button', { name: 'Save Changes' }).click()
await expect.poll(() => patches.length).toBe(1)
expect(patches[0].patch.gpu_layers).toBeNull()
expect('gpu_layers' in patches[0].patch).toBe(true)
})
test('the generic fields and the section agree: editing one moves the other', async ({ page }) => {
await editor(page, 'llama-3.3-70b-instruct-iq2')
await page.getByTestId('placement-layers').fill('9')
await expect(page.getByText('GPU Layers').first()).toBeVisible()
await expect(page.locator('input[type="number"]').nth(1)).toHaveValue('9')
})
test('the context presets write context_size into the same form', async ({ page }) => {
const { patches } = await editor(page)
await page.getByTestId('placement-context-16384').click()
await page.getByRole('button', { name: 'Save Changes' }).click()
await expect.poll(() => patches.length).toBe(1)
expect(patches[0].patch.context_size).toBe(16384)
})
test('Fit it for me works in the editor and Undo returns to the saved state, which is clean again', async ({ page }) => {
await editor(page, 'qwen3-14b-instruct', { profile: 'laptop' })
await page.getByTestId('placement-fit').click()
await expect(page.getByTestId('placement-applied')).toBeVisible()
await expect(page.getByRole('button', { name: 'Save Changes' })).toBeEnabled()
await page.getByTestId('placement-undo').click()
await expect(page.getByRole('button', { name: 'Saved' })).toBeDisabled()
})
test('a key that was set back to Auto and saved as null opens as Auto, with no empty field', async ({ page }) => {
await editor(page, 'qwen3-14b-instruct', {}, { configs: { 'qwen3-14b-instruct': 'name: qwen3-14b-instruct\nbackend: llama-cpp\ncontext_size: 8192\ngpu_layers: null\n' } })
await expect(page.getByTestId('placement-mode-auto')).toHaveAttribute('aria-checked', 'true')
await expect(page.getByRole('button', { name: 'Saved' })).toBeDisabled()
await expect(page.locator('input[type="number"]')).toHaveCount(1)
})
test('Add Model has no Placement section, because there is no model to estimate yet', async ({ page }) => {
await mockLedger(page)
await mockPlacement(page)
await page.goto('/app/model-editor')
await expect(page.locator('h1', { hasText: 'Add Model' })).toBeVisible()
await expect(page.getByTestId('editor-placement')).toHaveCount(0)
})
test('the section fits a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await editor(page)
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
await expect(page.getByTestId('placement-mode-custom')).toBeVisible()
})
})
@@ -0,0 +1,398 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger, GALLERY, PROFILES, withDisk, GB } from './ledger-fixtures.js'
// The cleanup review: a sheet from the disk strip that ranks installed models
// by how safe they are to remove, from what the API really reports. The API
// records no last-used time or use count, so the sheet says so and ranks on
// structure only. Removal waits out an undo window in the browser; nothing is
// deleted before the window ends, and leaving the page deletes nothing.
const strip = (page) => page.getByTestId('disk-strip')
const sheet = (page) => page.getByTestId('cleanup-sheet')
const tier = (page, name) => page.getByTestId(`cleanup-tier-${name}`)
const sizeOf = (name) => GALLERY.find((e) => e.name === name).estimate.sizeBytes
const gb1 = (bytes) => (bytes / GB).toFixed(1)
async function openSheet(page, options = {}, { url = '/app/models', clock = false } = {}) {
if (clock) await page.clock.install()
const state = await mockLedger(page, options)
await page.goto(url)
await expect(strip(page)).toBeVisible({ timeout: 15_000 })
await strip(page).click()
await expect(sheet(page)).toBeVisible()
await expect(sheet(page).locator('.dk-sheet-body')).toHaveAttribute('aria-busy', 'false', { timeout: 15_000 })
return state
}
const selectSafe = (page) => tier(page, 'safe').getByRole('button', { name: 'Select all' }).click()
const removeButton = (page) => page.getByTestId('cleanup-remove')
// The one safe model in the fixture: the 4-bit build of a model whose 8-bit
// build is installed too, which the gallery would pick on this host.
const SAFE = 'qwen3-14b-instruct-q4'
test.describe('Cleanup sheet - dialog behaviour', () => {
test('it is a modal dialog with a name, focus on Close, and Escape gives focus back', async ({ page }) => {
await openSheet(page)
const dialog = page.getByRole('dialog', { name: 'Disk and cleanup' })
await expect(dialog).toHaveAttribute('aria-modal', 'true')
await expect(page.getByRole('button', { name: 'Close' })).toBeFocused()
await page.keyboard.press('Escape')
await expect(sheet(page)).toHaveCount(0)
await expect(strip(page)).toBeFocused()
})
test('Tab stays inside the sheet', async ({ page }) => {
await openSheet(page)
for (let i = 0; i < 25; i++) {
await page.keyboard.press('Tab')
const inside = await page.evaluate(() => !!document.activeElement?.closest('[data-testid="cleanup-sheet"]'))
expect(inside).toBe(true)
}
})
test('a click on the dimmed page closes it', async ({ page }) => {
await openSheet(page)
await page.mouse.click(40, 450)
await expect(sheet(page)).toHaveCount(0)
})
test('the header states what the disk holds', async ({ page }) => {
await openSheet(page)
await expect(sheet(page).locator('.dk-sheet-desc')).toContainText('171 GB free of 931 GB. Models take 108 GB.')
await expect(sheet(page).locator('.dk-meter')).toHaveAccessibleName(/652 GB other files, 108 GB models, 171 GB free/)
})
})
test.describe('Cleanup sheet - what it knows and what it does not', () => {
test('it says plainly that usage history is not recorded, and shows no usage numbers', async ({ page }) => {
await openSheet(page)
await expect(page.getByTestId('cleanup-honesty')).toContainText('Usage history is not recorded yet.')
const text = await sheet(page).innerText()
// Nothing the API cannot say: no last used, no times used, no days since.
expect(text).not.toMatch(/(last used|times used)\s*[:\d]|\d+ uses|days? ago|never loaded/i)
})
test('models land in Safe, Probably safe and Your call from real facts only', async ({ page }) => {
await openSheet(page)
// Safe: another build of the same model is installed, and nothing uses this one.
await expect(tier(page, 'safe').locator('li')).toHaveCount(1)
await expect(tier(page, 'safe')).toContainText(SAFE)
await expect(tier(page, 'safe')).toContainText('Another build is installed: qwen3-14b-instruct')
// Probably safe: turned off, unused, and the gallery can download it again.
await expect(tier(page, 'probably').locator('li')).toHaveCount(1)
await expect(tier(page, 'probably')).toContainText('whisper-medium')
await expect(tier(page, 'probably')).toContainText('Disabled and unused. Can be downloaded again.')
// Your call: nothing uses them, and nothing more is known.
const call = await tier(page, 'call').locator('.ledger-cr__name').evaluateAll((els) => els.map((e) => e.textContent.trim().split('\n')[0]))
expect(call.length).toBe(7)
await expect(tier(page, 'call')).toContainText('LocalAI cannot tell when you last used them')
await expect(tier(page, 'call')).toContainText('Not in the gallery, so it cannot be downloaded again.')
})
test('each tier is ranked by size and a model with no known size goes last', async ({ page }) => {
await openSheet(page)
const names = await tier(page, 'call').locator('.ledger-cr__name').evaluateAll(
(els) => els.map((e) => e.firstChild.textContent.trim()),
)
expect(names[0]).toBe('llama-3.3-70b-instruct-iq2')
expect(names[names.length - 1]).toBe('my-finetune-q4')
await expect(tier(page, 'call').locator('li').last()).toContainText('size unknown')
await expect(tier(page, 'call').locator('li').first()).toContainText(`${gb1(sizeOf('llama-3.3-70b-instruct-iq2'))} GB`)
})
test('protected models are in their own group, with the reason, and cannot be picked', async ({ page }) => {
await openSheet(page)
const group = page.getByTestId('cleanup-protected')
await expect(group).toContainText('Protected')
await expect(group).toContainText('4 models')
await expect(group).toContainText('never suggested')
await group.getByRole('button').click()
await expect(group).toContainText('Loaded now. Pinned. Used by alias "default-chat".')
await expect(group).toContainText('whisper-large-v3')
await expect(group).toContainText('Used by agent "research-helper".')
await expect(group).toContainText('Used by task "nightly-digest".')
await expect(group.getByRole('checkbox')).toHaveCount(0)
// And none of them is a row in a suggestion tier.
const suggested = []
for (const name of ['safe', 'probably', 'call']) {
suggested.push(...(await tier(page, name).locator('.ledger-cr__name').evaluateAll(
(els) => els.map((e) => e.firstChild.textContent.trim()),
)))
}
expect(suggested).toHaveLength(9)
for (const name of ['qwen3-8b-instruct', 'whisper-large-v3', 'qwen3-14b-instruct', 'gemma-3-12b-it']) {
expect(suggested).not.toContain(name)
}
})
test('a failover chain protects its targets', async ({ page }) => {
await openSheet(page, { chains: [{ name: 'chat-chain', targets: [{ model: 'mistral-small-3.2-24b' }] }] })
await page.getByTestId('cleanup-protected').getByRole('button').click()
await expect(page.getByTestId('cleanup-protected')).toContainText('failover chain "chat-chain"')
await expect(tier(page, 'call')).not.toContainText('mistral-small-3.2-24b')
})
test('when agents cannot be read nothing is called safe, and the sheet says why', async ({ page }) => {
await openSheet(page, { agentsStatus: 500 })
await expect(page.getByTestId('cleanup-unverified')).toContainText('could not be read')
await expect(tier(page, 'safe')).toHaveCount(0)
await expect(tier(page, 'probably')).toHaveCount(0)
await expect(tier(page, 'call')).toContainText(SAFE)
await expect(tier(page, 'call')).toContainText('Agents or tasks could not be checked.')
})
test('with nothing to suggest the sheet says so', async ({ page }) => {
await openSheet(page, { installed: [{ id: 'only-model', backend: 'llama-cpp', capabilities: [], pinned: true }] })
await expect(sheet(page)).toContainText('Nothing to free up')
await expect(removeButton(page)).toBeDisabled()
})
test('the first look shows skeleton rows while the facts are read', async ({ page }) => {
await mockLedger(page)
await page.route('**/api/agents*', async (route) => {
await new Promise((r) => setTimeout(r, 1500))
return route.fulfill({ json: { agents: [] } })
})
await page.goto('/app/models')
await strip(page).click()
await expect(page.getByTestId('cleanup-loading')).toBeVisible()
await expect(sheet(page).locator('.dk-sheet-body')).toHaveAttribute('aria-busy', 'true')
await expect(tier(page, 'safe')).toBeVisible({ timeout: 10_000 })
})
})
test.describe('Cleanup sheet - the effect of a choice', () => {
test('the bar says nothing is selected, and what could be freed', async ({ page }) => {
await openSheet(page)
const bar = page.getByTestId('cleanup-effect')
await expect(bar).toContainText('Nothing selected')
await expect(bar).toContainText('9 models could free up to')
await expect(removeButton(page)).toBeDisabled()
})
test('selecting rows updates the effect: what is freed and what is free after', async ({ page }) => {
await openSheet(page)
await selectSafe(page)
const bar = page.getByTestId('cleanup-effect')
const freed = sizeOf(SAFE)
await expect(bar).toContainText('1 model selected')
await expect(bar).toContainText(`Frees ${gb1(freed)} GB.`)
await expect(bar).toContainText(`${Math.round(171 + freed / GB)} GB free after, up from 171 GB`)
await expect(removeButton(page)).toBeEnabled()
await expect(removeButton(page)).toContainText('Remove 1 model')
// A second pick adds its size, and a model of unknown size is said, not guessed.
await tier(page, 'call').getByRole('checkbox', { name: 'Select my-finetune-q4' }).check()
await expect(bar).toContainText('2 models selected')
await expect(bar).toContainText('1 size is unknown and is not counted.')
await expect(bar).toContainText(`Frees ${gb1(freed)} GB.`)
await bar.getByRole('button', { name: 'Clear' }).click()
await expect(bar).toContainText('Nothing selected')
})
test('the disk meter shows the selection as its own segment', async ({ page }) => {
await openSheet(page)
await expect(sheet(page).locator('.ledger-space__freed')).toHaveCount(0)
await selectSafe(page)
await expect(sheet(page).locator('.ledger-space__freed')).toHaveCount(1)
await expect(sheet(page).locator('.dk-meter-legend')).toContainText(`Frees ${gb1(sizeOf(SAFE))} GB`)
})
test('"Select the safe one" picks the safe tier from the bar in one press', async ({ page }) => {
await openSheet(page)
await page.getByTestId('cleanup-pick-safe').click()
await expect(page.getByTestId('cleanup-effect')).toContainText('1 model selected')
await expect(tier(page, 'safe').getByRole('checkbox', { checked: true })).toHaveCount(1)
await expect(page.getByTestId('cleanup-pick-safe')).toHaveCount(0)
})
test('Select all picks a tier, and a second press clears it', async ({ page }) => {
await openSheet(page)
await tier(page, 'call').getByRole('button', { name: 'Select all' }).click()
await expect(tier(page, 'call').getByRole('checkbox', { checked: true })).toHaveCount(7)
await tier(page, 'call').getByRole('button', { name: 'Clear' }).click()
await expect(tier(page, 'call').getByRole('checkbox', { checked: true })).toHaveCount(0)
})
})
test.describe('Cleanup sheet - confirm and dry run', () => {
test('a dry run lists what goes, why, and what it frees, and cancelling deletes nothing', async ({ page }) => {
const state = await openSheet(page)
await selectSafe(page)
await removeButton(page).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Remove 1 model?')
await expect(dialog).toContainText(`This frees ${gb1(sizeOf(SAFE))} GB.`)
const dry = dialog.getByTestId('cleanup-dry-run')
await expect(dry).toContainText(SAFE)
await expect(dry).toContainText('Another build is installed: qwen3-14b-instruct')
await expect(dry).toContainText(`${gb1(sizeOf(SAFE))} GB`)
await expect(dialog).toContainText('Checked just now.')
await expect(dialog).toContainText('Nothing is deleted yet.')
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('alertdialog')).toHaveCount(0)
expect(state.deletes).toHaveLength(0)
await expect(page.getByTestId('removal-undo-toast')).toHaveCount(0)
// The sheet is still there, with the selection intact.
await expect(sheet(page)).toBeVisible()
await expect(page.getByTestId('cleanup-effect')).toContainText('1 model selected')
})
test('the dry run looks again: a model that became used since is left out', async ({ page }) => {
const state = await openSheet(page)
await selectSafe(page)
// Between opening the sheet and confirming, a task starts using the model.
state.tasks.push({ id: 't2', name: 'new-job', model: SAFE, enabled: true })
await removeButton(page).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Nothing left to remove')
await expect(dialog).toContainText('Left out, because they are now in use')
await expect(dialog).toContainText(SAFE)
// There is nothing to confirm, so the only button closes the dialog.
await expect(dialog.getByRole('button', { name: /^Remove/ })).toHaveCount(0)
await dialog.getByRole('button', { name: 'Close' }).click()
await expect(page.getByTestId('removal-undo-toast')).toHaveCount(0)
expect(state.deletes).toHaveLength(0)
})
})
test.describe('Cleanup sheet - removal with an undo window', () => {
async function removeSafe(page, options = {}) {
const state = await openSheet(page, options, { clock: true })
await selectSafe(page)
await removeButton(page).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Remove 1 model' }).click()
await expect(page.getByTestId('removal-undo-toast')).toBeVisible()
return state
}
test('removing starts a window: the toast offers Undo and nothing is sent yet', async ({ page }) => {
const state = await removeSafe(page)
const toast = page.getByTestId('removal-undo-toast')
await expect(toast).toContainText('Removed 1 model.')
await expect(toast).toContainText(`${gb1(sizeOf(SAFE))} GB is freed when the undo time ends in 30 s.`)
await expect(toast.getByRole('button', { name: 'Undo' })).toBeVisible()
// No close button: closing must not finish the removal early.
await expect(toast.getByRole('button', { name: /dismiss/i })).toHaveCount(0)
await page.clock.fastForward(25_000)
expect(state.deletes).toHaveLength(0)
// The model is out of the sheet meanwhile, and a second removal waits.
await expect(tier(page, 'safe')).toHaveCount(0)
await expect(page.getByTestId('cleanup-effect')).toContainText('A removal is waiting.')
})
test('Undo brings the model back and nothing is ever sent', async ({ page }) => {
const state = await removeSafe(page)
await page.clock.fastForward(10_000)
await page.getByTestId('removal-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(page.getByTestId('removal-undo-toast')).toHaveCount(0)
await expect(tier(page, 'safe')).toContainText(SAFE)
await page.clock.fastForward(120_000)
expect(state.deletes).toHaveLength(0)
await expect(removeButton(page)).toBeDisabled()
})
test('when the window ends each model goes through the existing delete call', async ({ page }) => {
const state = await removeSafe(page)
await page.clock.fastForward(31_000)
await expect.poll(() => state.deletes).toEqual([SAFE])
await expect(page.getByTestId('removal-undo-toast')).toHaveCount(0)
await expect(page.getByText('Deleted 1 model.')).toBeVisible()
await expect(tier(page, 'safe')).toHaveCount(0)
})
test('the hidden model is gone from the Installed table during the window and back after Undo', async ({ page }) => {
await removeSafe(page)
await page.keyboard.press('Escape')
await page.getByRole('link', { name: /^Installed/ }).click()
await expect(page.locator('[data-testid="installed-models-rail-item"]').first()).toBeVisible()
await expect(page.locator(`[data-entity="${SAFE}"]`)).toHaveCount(0)
await page.getByTestId('removal-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(page.locator(`[data-entity="${SAFE}"]`)).toBeVisible()
})
test('leaving the page during the window deletes nothing and says so', async ({ page }) => {
const state = await removeSafe(page)
await page.keyboard.press('Escape')
await page.getByRole('link', { name: 'Chat', exact: true }).first().click()
await expect(page).toHaveURL(/\/app\/chat/)
await expect(page.getByText('You left the page, so 1 model was not deleted.')).toBeVisible()
await page.clock.fastForward(120_000)
expect(state.deletes).toHaveLength(0)
})
test('closing the page during the window deletes nothing', async ({ page }) => {
const state = await removeSafe(page)
await page.close()
await new Promise((r) => setTimeout(r, 400))
expect(state.deletes).toHaveLength(0)
})
test('a delete that fails is reported and the model comes back', async ({ page }) => {
const state = await removeSafe(page, { deleteFails: [SAFE] })
await page.clock.fastForward(31_000)
await expect(page.getByText(`Could not delete ${SAFE}: model is busy`)).toBeVisible()
expect(state.deletes).toHaveLength(0)
await expect(tier(page, 'safe')).toContainText(SAFE)
})
test('a model that was loaded in the meantime is skipped, not deleted', async ({ page }) => {
const state = await removeSafe(page)
state.loaded.push(SAFE)
await page.clock.fastForward(31_000)
await expect(page.getByText(`Not deleted, because they were loaded in the meantime: ${SAFE}`)).toBeVisible()
expect(state.deletes).toHaveLength(0)
})
})
test.describe('Cleanup sheet - where the disk strip is not offered', () => {
test('without a disk reading there is no strip, and no Free up space button either', async ({ page }) => {
const { disk, ...noDisk } = PROFILES.gpu24
void disk
await mockLedger(page, { resources: noDisk })
await page.goto('/app/models?view=installed')
await expect(page.locator('[data-testid="installed-models-rail-item"]').first()).toBeVisible({ timeout: 15_000 })
await expect(strip(page)).toHaveCount(0)
await expect(page.getByTestId('installed-cleanup')).toHaveCount(0)
})
})
test.describe('Cleanup sheet - low disk', () => {
test('the strip is amber and the sheet opens on the same figures', async ({ page }) => {
await openSheet(page, { resources: withDisk(PROFILES.gpu24, { total: 931 * GB, used: 908 * GB, available: 23 * GB }) })
await expect(sheet(page).locator('.dk-sheet-desc')).toContainText('23.0 GB free of 931 GB')
})
})
test.describe('Cleanup sheet - phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('it is a bottom sheet with a grip, and the effect bar stays on screen', async ({ page }) => {
await openSheet(page)
// The sheet rises into place, so read it once it has settled.
await expect.poll(async () => {
const b = await sheet(page).boundingBox()
return Math.round(b.y + b.height)
}).toBeLessThanOrEqual(845)
const box = await sheet(page).boundingBox()
expect(Math.round(box.width)).toBe(390)
expect(box.y).toBeGreaterThan(0)
await expect(sheet(page).locator('.dk-sheet-grip')).toBeVisible()
const bar = await page.getByTestId('cleanup-effect').boundingBox()
expect(bar.y + bar.height).toBeLessThanOrEqual(844 + 1)
await selectSafe(page)
await expect(removeButton(page)).toBeVisible()
await expect(removeButton(page)).toBeInViewport()
})
})
test.describe('Cleanup sheet - reduced motion', () => {
test('it still opens, and the sheet does not animate in', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await openSheet(page)
const duration = await sheet(page).evaluate((el) => getComputedStyle(el).animationDuration)
expect(Math.max(...duration.split(',').map((d) => parseFloat(d)))).toBeLessThan(0.01)
})
})
@@ -78,8 +78,9 @@ test.describe("Models gallery - cluster-aware fit", () => {
// The whole defect in one assertion: 40GB against a 4-node cluster whose
// largest card holds 80GB.
await expect(railItem(page, "big-gpu-model")).toContainText("fits", { timeout: 20_000 });
await expect(railItem(page, "big-gpu-model")).not.toContainText("too large");
await expect(railItem(page, "big-gpu-model").locator("[data-fit]")).toHaveAttribute("data-fit", "fits", { timeout: 20_000 });
await expect(railItem(page, "big-gpu-model")).toContainText("free");
await expect(railItem(page, "big-gpu-model")).not.toContainText("over");
});
test("the fit verdict names the node it belongs to", async ({ page }) => {
@@ -90,7 +91,7 @@ test.describe("Models gallery - cluster-aware fit", () => {
await railItem(page, "big-gpu-model").click();
// Wait for the detail itself: until it renders, the pane still holds the
// zero-state hero, which names the node for its own reasons.
await expect(page.locator(PANE).getByText("40.0 GB")).toBeVisible({ timeout: 20_000 });
await expect(page.locator(PANE).locator(".stat-grid").getByText("40.0 GB")).toBeVisible({ timeout: 20_000 });
// The headroom this model has is headroom SOMEWHERE, and the stat says
// where rather than leaving it to read as this machine's.
@@ -115,6 +116,7 @@ test.describe("Models gallery - cluster-aware fit", () => {
await page.goto("/app/models");
await railReady(page);
await expect(railItem(page, "big-gpu-model")).toContainText("too large", { timeout: 20_000 });
await expect(railItem(page, "big-gpu-model").locator("[data-fit]")).toHaveAttribute("data-fit", "over", { timeout: 20_000 });
await expect(railItem(page, "big-gpu-model")).toContainText("over");
});
});
@@ -0,0 +1,64 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger } from './ledger-fixtures.js'
// The Explore inspector holds the model's actions in its header. Whatever the
// pane width, none of them may spill past the pane, and the text must sit
// inside the pane's padding rather than against its border.
const pane = (page) => page.getByTestId('discover-pane')
const SHOTS = process.env.EXPLORE_PANE_SHOTS
async function openModel(page, name) {
await mockLedger(page)
await page.goto('/app/models')
// Search first: on a phone the grouped list is long and the row may sit
// below a collapsed group.
await expect(page.getByTestId('models-search')).toBeVisible({ timeout: 15_000 })
await page.getByTestId('models-search').fill(name)
const row = page.locator(`[data-entity="${name}"]`)
await expect(row).toBeVisible({ timeout: 15_000 })
await row.click()
await expect(pane(page).locator('.detail-pane')).toBeVisible()
}
async function expectButtonsInside(page) {
const box = await pane(page).boundingBox()
const buttons = pane(page).locator('.detail-pane__actions button:visible')
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const b = await buttons.nth(i).boundingBox()
expect(b.x, `button ${i} left edge`).toBeGreaterThanOrEqual(box.x)
expect(b.x + b.width, `button ${i} right edge`).toBeLessThanOrEqual(box.x + box.width)
}
const scrolls = await pane(page).evaluate((el) => el.scrollWidth > el.clientWidth + 1)
expect(scrolls).toBe(false)
}
for (const theme of ['light', 'dark']) {
for (const [label, width] of [['wide', 1440], ['pane-1100', 1100], ['pane-920', 920]]) {
for (const [kind, name] of [['installed', 'qwen3-8b-instruct'], ['available', 'qwen3-32b-instruct']]) {
test(`${kind} model actions stay inside the pane (${theme}, ${label})`, async ({ page }) => {
await page.addInitScript((m) => localStorage.setItem('localai-theme', m), theme)
await page.setViewportSize({ width, height: 900 })
await openModel(page, name)
await expectButtonsInside(page)
// Padding: the pane's first text sits clear of its border.
const padding = await pane(page).evaluate((el) => parseFloat(getComputedStyle(el).paddingLeft))
expect(padding).toBeGreaterThanOrEqual(16)
const head = await pane(page).locator('.detail-pane__name').boundingBox()
const box = await pane(page).boundingBox()
expect(head.x - box.x).toBeGreaterThanOrEqual(16)
if (SHOTS) await pane(page).screenshot({ path: `${SHOTS}/${kind}-${theme}-${label}.png` })
})
}
}
}
test('a 320px pane keeps the actions inside it', async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 })
await openModel(page, 'qwen3-8b-instruct')
await pane(page).evaluate((el) => { el.style.width = '320px'; el.style.maxWidth = '320px'; el.style.justifySelf = 'start' })
await expectButtonsInside(page)
if (SHOTS) await pane(page).screenshot({ path: `${SHOTS}/installed-320.png` })
})
+155 -151
View File
@@ -99,21 +99,18 @@ const MOCK_ESTIMATES = {
},
};
// The gallery is a rail plus a pane, not a table. These three helpers are the
// whole of that migration for the specs below: an entry is addressed by the
// model it carries, and the detail lives in the pane rather than in a cell
// spanning the row.
// The gallery is a ledger table beside an inspector. These helpers address a
// row by the model it carries, and the detail by the inspector it opens in.
const PANE = '[data-testid="discover-pane"]';
const railItems = (page) => page.locator('[data-testid="discover-rail-item"]');
const railItem = (page, name) => page.locator(`[data-entity="${name}"]`);
// The use-case chips live in a popover now; opening it is idempotent so tests
// can call this without tracking whether it is already up.
const openUseCases = async (page) => {
const trigger = page.locator(".models-filters__usecase-trigger");
if ((await page.locator(".filter-btn").count()) === 0) await trigger.click();
await expect(page.locator(".filter-btn").first()).toBeVisible();
};
// Rendered means the rail has entries. The old gate waited on a column header.
// The capability facets are chips in one row, each addressed by its filter key.
const facet = (page, key) => page.getByTestId(`facet-${key}`);
const pressed = (locator) => expect(locator).toHaveAttribute("aria-pressed", "true");
const notPressed = (locator) => expect(locator).toHaveAttribute("aria-pressed", "false");
// The search field, which the "/" key also focuses.
const searchBox = (page) => page.getByTestId("models-search");
// Rendered means the table has rows.
const railReady = (page) =>
expect(railItems(page).first()).toBeVisible({ timeout: 10_000 });
@@ -133,12 +130,12 @@ test.describe("Models Gallery - Backend Features", () => {
test("selecting a model names its backend in the pane", async ({ page }) => {
await railItem(page, "llama-model").click();
await expect(
page.locator(PANE).locator(".badge", { hasText: "llama-cpp" }).first(),
page.locator(PANE).locator(".dk-badge", { hasText: "llama-cpp" }).first(),
).toBeVisible();
await railItem(page, "whisper-model").click();
await expect(
page.locator(PANE).locator(".badge", { hasText: /^whisper$/ }).first(),
page.locator(PANE).locator(".dk-badge", { hasText: /^whisper$/ }).first(),
).toBeVisible();
});
@@ -234,40 +231,37 @@ test.describe("Models Gallery - Multi-select Filters", () => {
test("multi-select toggle: click Chat, TTS, then Chat again", async ({
page,
}) => {
await openUseCases(page);
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" });
const chatBtn = facet(page, "chat");
const ttsBtn = facet(page, "tts");
await chatBtn.click();
await expect(chatBtn).toHaveClass(/active/);
await pressed(chatBtn);
await ttsBtn.click();
await expect(chatBtn).toHaveClass(/active/);
await expect(ttsBtn).toHaveClass(/active/);
await pressed(chatBtn);
await pressed(ttsBtn);
// Click Chat again to deselect it
await chatBtn.click();
await expect(chatBtn).not.toHaveClass(/active/);
await expect(ttsBtn).toHaveClass(/active/);
await notPressed(chatBtn);
await pressed(ttsBtn);
});
test('"All" clears selection', async ({ page }) => {
await openUseCases(page);
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
const allBtn = page.locator(".filter-btn", { hasText: "All" });
const chatBtn = facet(page, "chat");
const allBtn = facet(page, "all");
await chatBtn.click();
await expect(chatBtn).toHaveClass(/active/);
await pressed(chatBtn);
await allBtn.click();
await expect(allBtn).toHaveClass(/active/);
await expect(chatBtn).not.toHaveClass(/active/);
await pressed(allBtn);
await notPressed(chatBtn);
});
test("query param sent correctly with multiple filters", async ({ page }) => {
await openUseCases(page);
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" });
const chatBtn = facet(page, "chat");
const ttsBtn = facet(page, "tts");
// Click Chat and wait for its request to settle
await chatBtn.click();
@@ -290,7 +284,6 @@ test.describe("Models Gallery - Multi-select Filters", () => {
});
test("backend greys out unavailable filters", async ({ page }) => {
await openUseCases(page);
// Select llama-cpp backend via dropdown
await page.locator("button", { hasText: "All Backends" }).click();
const dropdown = page
@@ -300,9 +293,9 @@ test.describe("Models Gallery - Multi-select Filters", () => {
await dropdown.locator("text=llama-cpp").click();
// Wait for filter state to update
const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" });
const sttBtn = page.locator(".filter-btn", { hasText: "STT" });
const imageBtn = page.locator(".filter-btn", { hasText: "Image" });
const ttsBtn = facet(page, "tts");
const sttBtn = facet(page, "transcript");
const imageBtn = facet(page, "image");
// TTS, STT, Image should be disabled for llama-cpp
await expect(ttsBtn).toBeDisabled();
@@ -310,10 +303,10 @@ test.describe("Models Gallery - Multi-select Filters", () => {
await expect(imageBtn).toBeDisabled();
// Chat, Embeddings, Vision, NER should remain enabled
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
const embBtn = page.locator(".filter-btn", { hasText: "Embeddings" });
const visBtn = page.locator(".filter-btn", { hasText: "Vision" });
const nerBtn = page.locator(".filter-btn", { hasText: "NER" });
const chatBtn = facet(page, "chat");
const embBtn = facet(page, "embeddings");
const visBtn = facet(page, "vision");
const nerBtn = facet(page, "token_classify");
await expect(chatBtn).toBeEnabled();
await expect(embBtn).toBeEnabled();
await expect(visBtn).toBeEnabled();
@@ -321,11 +314,10 @@ test.describe("Models Gallery - Multi-select Filters", () => {
});
test("backend clears incompatible filters", async ({ page }) => {
await openUseCases(page);
// Select TTS filter first
const ttsBtn = page.locator(".filter-btn", { hasText: "TTS" });
const ttsBtn = facet(page, "tts");
await ttsBtn.click();
await expect(ttsBtn).toHaveClass(/active/);
await pressed(ttsBtn);
// Now select llama-cpp backend (which doesn't support TTS)
await page.locator("button", { hasText: "All Backends" }).click();
@@ -336,7 +328,7 @@ test.describe("Models Gallery - Multi-select Filters", () => {
await dropdown.locator("text=llama-cpp").click();
// TTS should be auto-removed from selection
await expect(ttsBtn).not.toHaveClass(/active/);
await notPressed(ttsBtn);
});
});
@@ -414,8 +406,12 @@ test.describe("Models Gallery - Empty State", () => {
await page.route("**/api/models*", (route) => {
const url = new URL(route.request().url());
const tag = url.searchParams.get("tag");
// Only the gallery's own listing comes back empty. The one-item requests
// that count each chip still say the facet matches something, so the chip
// stays on screen to be turned off again.
const isListing = url.searchParams.get("items") === "30";
const body =
tag === "chat" ? EMPTY_FILTERED_RESPONSE : MOCK_MODELS_RESPONSE;
isListing && tag === "chat" ? EMPTY_FILTERED_RESPONSE : MOCK_MODELS_RESPONSE;
route.fulfill({
contentType: "application/json",
@@ -430,16 +426,15 @@ test.describe("Models Gallery - Empty State", () => {
test("shows empty state for filtered-out results and clear filters restores the gallery", async ({
page,
}) => {
await openUseCases(page);
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
const allBtn = page.locator(".filter-btn", { hasText: "All" });
const chatBtn = facet(page, "chat");
const allBtn = facet(page, "all");
await chatBtn.click();
await expect(page.locator(".empty-state-title")).toHaveText(
await expect(page.locator(".dk-empty-title")).toHaveText(
"No models found",
);
await expect(page.locator(".empty-state-text")).toHaveText(
await expect(page.locator(".dk-empty-text")).toHaveText(
"No models match your current search or filters.",
);
@@ -449,9 +444,9 @@ test.describe("Models Gallery - Empty State", () => {
await clearBtn.click();
await expect(allBtn).toHaveClass(/active/);
await expect(chatBtn).not.toHaveClass(/active/);
await expect(page.locator(".empty-state")).toHaveCount(0);
await pressed(allBtn);
await notPressed(chatBtn);
await expect(page.getByTestId("gallery-empty")).toHaveCount(0);
await expect(railItem(page, "llama-model")).toBeVisible();
});
});
@@ -1171,13 +1166,13 @@ test.describe("Models Gallery - Collapsed Listing", () => {
// produce "no models found", which reads as "that model does not exist";
// returning the build itself would instead put a row in the listing that
// the view the user asked for has no place for.
await page.locator(".search-bar input").fill("whisper-model");
await searchBox(page).fill("whisper-model");
await expect(railItem(page, "llama-model")).toBeVisible();
await expect(railItem(page, "whisper-model")).toHaveCount(
0,
);
await expect(page.locator(".empty-state")).toHaveCount(0);
await expect(page.getByTestId("gallery-empty")).toHaveCount(0);
});
test("the same search returns the build itself with the toggle off", async ({
@@ -1187,7 +1182,7 @@ test.describe("Models Gallery - Collapsed Listing", () => {
// the individual build, exactly as it does for every client that never
// sends the parameter.
await flipCollapse(page);
await page.locator(".search-bar input").fill("whisper-model");
await searchBox(page).fill("whisper-model");
await expect(railItem(page, "whisper-model")).toBeVisible();
});
@@ -1198,7 +1193,7 @@ test.describe("Models Gallery - Collapsed Listing", () => {
// The server decides what an active search means. The page keeps asking
// for the collapsed listing so that decision lives in one place, and so
// clearing the box goes straight back to the browsing view.
await page.locator(".search-bar input").fill("whisper-model");
await searchBox(page).fill("whisper-model");
await expect.poll(
() => listingUrls[listingUrls.length - 1].searchParams.get("term"),
).toBe("whisper-model");
@@ -1210,14 +1205,14 @@ test.describe("Models Gallery - Collapsed Listing", () => {
test("clearing the search box returns to the collapsed listing", async ({
page,
}) => {
await page.locator(".search-bar input").fill("whisper-model");
await searchBox(page).fill("whisper-model");
// The search narrowed to the one surfaced row, so the other browsing rows
// are gone and their return is what proves the term was dropped.
await expect(
railItem(page, "stablediffusion-model"),
).toHaveCount(0);
await page.locator(".search-bar input").fill("");
await searchBox(page).fill("");
await expect(
railItem(page, "stablediffusion-model"),
@@ -1253,10 +1248,10 @@ test.describe("Models Gallery - Collapsed Listing", () => {
// the dead end coming back.
await expect(collapseToggle(page)).toBeChecked();
await page.locator(".search-bar input").fill("whisper-model");
await searchBox(page).fill("whisper-model");
await expect(railItem(page, "llama-model")).toBeVisible();
await expect(page.locator(".empty-state")).toHaveCount(0);
await expect(page.getByTestId("gallery-empty")).toHaveCount(0);
// Still asked for collapsed: the server decides what a term means.
await expect
.poll(() =>
@@ -1270,13 +1265,12 @@ test.describe("Models Gallery - Collapsed Listing", () => {
test("the empty state does not blame the collapse for a chip", async ({
page,
}) => {
await openUseCases(page);
await page.locator(".filter-btn", { hasText: "Chat" }).click();
await facet(page, "chat").click();
await expect(page.locator(".empty-state-title")).toHaveText(
await expect(page.locator(".dk-empty-title")).toHaveText(
"No models found",
);
await expect(page.locator(".empty-state-text")).toHaveText(
await expect(page.locator(".dk-empty-text")).toHaveText(
"No models match your current search or filters.",
);
// The chip is applied server-side over every build the gallery holds, and
@@ -1292,8 +1286,8 @@ test.describe("Models Gallery - Collapsed Listing", () => {
}) => {
// Same reasoning as the chip: the term is matched against every build, so
// with one typed the collapse cannot be what emptied the listing.
await page.locator(".search-bar input").fill("nothing-matches-this");
await expect(page.locator(".empty-state")).toBeVisible();
await searchBox(page).fill("nothing-matches-this");
await expect(page.getByTestId("gallery-empty")).toBeVisible();
await expect(page.locator(".empty-state-hint")).toHaveCount(0);
});
@@ -1301,9 +1295,8 @@ test.describe("Models Gallery - Collapsed Listing", () => {
test("clear filters returns to the collapsed browsing view", async ({
page,
}) => {
await openUseCases(page);
await page.locator(".filter-btn", { hasText: "Chat" }).click();
await expect(page.locator(".empty-state")).toBeVisible();
await facet(page, "chat").click();
await expect(page.getByTestId("gallery-empty")).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
@@ -1316,13 +1309,12 @@ test.describe("Models Gallery - Collapsed Listing", () => {
test("clear filters resets the collapse toggle to its default", async ({
page,
}) => {
await openUseCases(page);
// It is a filter like the others, so leaving it behind would make "clear
// filters" a half-truth.
await flipCollapse(page);
await expect(railItem(page, "whisper-model")).toBeVisible();
await page.locator(".filter-btn", { hasText: "Chat" }).click();
await expect(page.locator(".empty-state")).toBeVisible();
await facet(page, "chat").click();
await expect(page.getByTestId("gallery-empty")).toBeVisible();
await page.getByRole("button", { name: "Clear filters" }).click();
@@ -1333,11 +1325,10 @@ test.describe("Models Gallery - Collapsed Listing", () => {
});
test("the clear button appears for the toggle alone", async ({ page }) => {
await openUseCases(page);
// Turning the collapse off is a filter change with nothing else set, so
// the empty state must still offer a way back.
await flipCollapse(page);
await page.locator(".filter-btn", { hasText: "Chat" }).click();
await facet(page, "chat").click();
await expect(
page.getByRole("button", { name: "Clear filters" }),
@@ -1477,9 +1468,9 @@ test.describe("Models Gallery - Markdown descriptions", () => {
test("the description sits outside the label/value grid on a readable measure", async ({
page,
}) => {
// Wide enough that the ch-based cap is the thing deciding the width. In a
// narrow pane the cap simply does not bind, and the ratio below would pass
// or fail on the viewport rather than on the rule being tested.
// The inspector is a narrow column, so the cap cannot be seen binding by
// width alone. What matters is that the prose is a block of its own with a
// measure cap in the stylesheet, and that it never outgrows the inspector.
await page.setViewportSize({ width: 1800, height: 900 });
await railItem(page, "headings-model").click();
const detail = page.locator(PANE);
@@ -1487,18 +1478,21 @@ test.describe("Models Gallery - Markdown descriptions", () => {
await expect(detail.locator("table td", { hasText: "Description" })).toHaveCount(
0,
);
await expect(detail.locator("table .detail-prose")).toHaveCount(0);
await expect(detail.locator(".detail-prose__label")).toHaveText(
"Description",
);
const proseWidth = await page
const measure = await page
.locator(".detail-prose__body")
.evaluate((el) => el.getBoundingClientRect().width);
.evaluate((el) => ({
width: el.getBoundingClientRect().width,
cap: getComputedStyle(el).maxWidth,
}));
const paneWidth = await detail.evaluate(
(el) => el.getBoundingClientRect().width,
);
// A measure, not the full pane: the cap is a ch count, so the exact pixel
// value moves with the font, but it must stay well inside the pane.
expect(proseWidth).toBeLessThan(paneWidth * 0.85);
expect(measure.cap).not.toBe("none");
expect(measure.width).toBeLessThanOrEqual(paneWidth);
});
test("a model without a description renders no prose block", async ({
@@ -1542,82 +1536,86 @@ test.describe("Models Gallery - Filter layout structure", () => {
await railReady(page);
});
test("the chip rows contain only use-case chips", async ({ page }) => {
await openUseCases(page);
// One row per family now, plus the row holding "All" on its own. The
// contract is unchanged: a chip row carries chips and nothing else.
const chipRows = page.locator(".filter-bar");
const rowCount = await chipRows.count();
expect(rowCount).toBeGreaterThan(1);
const childClasses = await chipRows.evaluateAll((rows) =>
rows.flatMap((r) => Array.from(r.children).map((c) => c.className)),
test("the facet row contains only facet chips", async ({ page }) => {
// A chip row carries chips and nothing else: no slider, no toggle, no
// select. Each chip is a filter, so each says whether it is pressed.
const row = page.locator(".ledger-facets");
await expect(row).toBeVisible();
// The chips for the other facets arrive with the server's counts.
await expect(facet(page, "chat")).toBeVisible({ timeout: 10_000 });
const children = await row.evaluate((el) =>
Array.from(el.children).map((c) => ({
cls: c.className,
pressed: c.getAttribute("aria-pressed"),
tag: c.tagName,
})),
);
expect(childClasses.length).toBeGreaterThan(0);
for (const cls of childClasses) {
expect(cls).toContain("filter-btn");
}
await expect(chipRows.locator("input[type='range']")).toHaveCount(0);
await expect(chipRows.locator(".filter-bar-group__toggle")).toHaveCount(0);
await expect(chipRows.getByText("All Backends")).toHaveCount(0);
// Every family the rail speaks is represented, and none is empty.
for (const label of ["Text and reasoning", "Vision", "Speech and audio", "Image and video"]) {
await expect(
page.locator(".models-filters__usecase-label", { hasText: label }),
).toBeVisible();
expect(children.length).toBeGreaterThan(3);
for (const c of children) {
expect(c.tag).toBe("BUTTON");
expect(c.cls).toContain("dk-chip");
expect(["true", "false"]).toContain(c.pressed);
}
await expect(row.locator("input[type='range']")).toHaveCount(0);
await expect(row.locator(".filter-bar-group__toggle")).toHaveCount(0);
await expect(row.getByText("All Backends")).toHaveCount(0);
// "All" leads, and the capabilities the gallery really has follow it.
await expect(row.locator("button").first()).toHaveAttribute("data-testid", "facet-all");
await expect(facet(page, "chat")).toBeVisible();
await expect(facet(page, "transcript")).toBeVisible();
});
test("refinements live in their own band, outside the chip row", async ({
test("refinements live in their own band, outside the facet row", async ({
page,
}) => {
const refine = page.getByTestId("models-filters-refine");
await expect(refine).toBeVisible();
await expect(refine.locator(".filter-bar")).toHaveCount(0);
await expect(refine.locator(".dk-chip")).toHaveCount(0);
await expect(refine.getByText("Fits in GPU")).toBeVisible();
await expect(refine.locator("#models-context-size")).toBeVisible();
// The band is a sibling of the chip row, never a descendant.
await expect(refine.getByText("One row per model")).toBeVisible();
// The band is a sibling of the facet row, never a descendant.
const nested = await page
.locator(".filter-bar")
.locator(".ledger-facets")
.locator('[data-testid="models-filters-refine"]')
.count();
expect(nested).toBe(0);
// The context length is the length the fit column is computed at, so it
// sits with the table it changes.
await expect(page.locator(".ledger-bar #models-context-size")).toBeVisible();
});
test("the backend select sits above the use-case control it gates", async ({
test("the backend select sits above the facet row it gates", async ({
page,
}) => {
const selectBtn = page.locator("button", { hasText: "All Backends" });
await expect(selectBtn).toBeVisible();
const trigger = page.locator(".models-filters__usecase-trigger");
await expect(trigger).toBeVisible();
// Picking a backend disables the use cases it cannot serve, so it still
// reads first even though both are now stacked in the rail column.
const row = page.locator(".ledger-facets");
await expect(row).toBeVisible();
// Picking a backend disables the facets it cannot serve, so it reads first.
const selectBox = await selectBtn.boundingBox();
const triggerBox = await trigger.boundingBox();
expect(selectBox.y).toBeLessThan(triggerBox.y);
const rowBox = await row.boundingBox();
expect(selectBox.y).toBeLessThan(rowBox.y);
});
test("refinements stay grouped and on one band at a narrow width", async ({
test("the controls stay on screen at a narrow width", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 900 });
const refine = page.getByTestId("models-filters-refine");
await expect(refine).toBeVisible();
const triggerBox = await page.locator(".models-filters__usecase-trigger").boundingBox();
const refineBox = await refine.boundingBox();
// Below the use-case control, not interleaved with it.
expect(refineBox.y).toBeGreaterThanOrEqual(triggerBox.y + triggerBox.height - 1);
await expect(refine.getByText("Fits in GPU")).toBeVisible();
await expect(refine.locator("#models-context-size")).toBeVisible();
await expect(page.locator("#models-context-size")).toBeVisible();
// Nothing pushes the page sideways: the facet row scrolls inside itself.
const overflow = await page.evaluate(
() => document.documentElement.scrollWidth - window.innerWidth,
);
expect(overflow).toBeLessThanOrEqual(1);
});
test("chips expose pressed state and the context slider is labelled", async ({
page,
}) => {
await openUseCases(page);
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
const chatBtn = facet(page, "chat");
await expect(chatBtn).toHaveAttribute("aria-pressed", "false");
await chatBtn.click();
await expect(chatBtn).toHaveAttribute("aria-pressed", "true");
@@ -1629,23 +1627,26 @@ test.describe("Models Gallery - Filter layout structure", () => {
});
test("a keyboard-focused chip shows a focus ring", async ({ page }) => {
await openUseCases(page);
// The global :focus-visible rule is wrapped in :where(), so it ties with
// .filter-btn on specificity and loses on order. Without an explicit rule
// the chips render their resting shadow while focused, i.e. no indicator.
await page.locator(".filter-bar-group__search input").click();
await page.keyboard.press("Tab"); // backend select
await page.keyboard.press("Tab"); // use-case disclosure
await page.keyboard.press("Tab"); // first chip
const focused = page.locator(".filter-btn:focus-visible");
// The shared focus language: a 2px solid outline with a 2px offset. A chip
// is reached by Tab from the search field, so the ring must show for
// keyboard focus and must not depend on a hover or a click.
await searchBox(page).click();
let focused = page.locator(".ledger-facet:focus-visible");
for (let i = 0; i < 12 && (await focused.count()) === 0; i++) {
await page.keyboard.press("Tab");
}
await expect(focused).toHaveCount(1);
// The ring transitions in, so settle before reading the computed value.
// Settle any transition before reading the computed value.
await page.waitForTimeout(400);
const shadow = await focused.evaluate(
(el) => getComputedStyle(el).boxShadow,
);
// A 3px spread ring, not the 1px/2px resting drop shadow.
expect(shadow).toMatch(/0px 0px 0px 3px/);
const ring = await focused.evaluate((el) => {
const cs = getComputedStyle(el);
return {
style: cs.outlineStyle,
width: cs.outlineWidth,
offset: cs.outlineOffset,
};
});
expect(ring).toEqual({ style: "solid", width: "2px", offset: "2px" });
});
test("the context control is keyboard reachable and drives the value", async ({
@@ -1677,7 +1678,7 @@ const MOCK_MULTI_CONTEXT_ESTIMATES = {
},
};
test.describe("Models Gallery - Explore split view", () => {
test.describe("Models Gallery - Explore ledger", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/models*", (route) => {
route.fulfill({
@@ -1703,12 +1704,15 @@ test.describe("Models Gallery - Explore split view", () => {
await railReady(page);
});
test("the gallery renders no table", async ({ page }) => {
// The point of the change, asserted directly: the eight-column table and
// the row that expanded underneath it are both gone.
test("the gallery is one labelled table with a row per model", async ({ page }) => {
// The ledger is a real table: a caption for assistive technology, column
// headers, one row per model, and nothing that expands underneath a row.
await expect(page.locator('[data-testid="discover"]')).toBeVisible();
await expect(page.locator("table thead th")).toHaveCount(0);
await expect(page.locator('td[colspan="8"]')).toHaveCount(0);
const table = page.locator("table.ledger-table");
await expect(table.locator("caption")).toContainText("Gallery models");
await expect(table.locator("thead th").first()).toBeAttached();
await expect(railItems(page)).toHaveCount(4);
await expect(page.locator("tr.dk-table-detail")).toHaveCount(0);
});
test("with nothing selected the pane is the discovery page", async ({
@@ -1743,12 +1747,12 @@ test.describe("Models Gallery - Explore split view", () => {
await expect(page.locator('[data-testid="discover-back"]')).toBeVisible();
});
test("the rail groups while browsing", async ({ page }) => {
test("the table groups while browsing", async ({ page }) => {
await expect(railItems(page).first()).toBeVisible();
await expect(page.locator('[data-testid^="discover-rail-group-"]').first()).toBeVisible();
});
test("collapsing a group hides its entries and keeps the others", async ({ page }) => {
test("collapsing a group hides its rows and keeps the others", async ({ page }) => {
const group = page.locator('[data-testid^="discover-rail-group-"]').first();
const before = await railItems(page).count();
await group.click();
@@ -1756,11 +1760,11 @@ test.describe("Models Gallery - Explore split view", () => {
expect(await railItems(page).count()).toBeLessThan(before);
});
test("a query flattens the rail to results", async ({ page }) => {
test("a query flattens the table to results", async ({ page }) => {
// Once a term is typed the buckets stand between the reader and the answer,
// so they go. A rule rather than a toggle.
await expect(page.locator('[data-testid^="discover-rail-group-"]').first()).toBeVisible();
await page.locator(".filter-bar-group__search input").fill("llama");
await searchBox(page).fill("llama");
await expect(page.locator('[data-testid^="discover-rail-group-"]')).toHaveCount(0);
await expect(railItems(page).first()).toBeVisible();
});
@@ -1813,7 +1817,7 @@ test.describe("Models Gallery - Explore split view", () => {
await expect(page.locator(".discover__chart")).toHaveCount(0);
});
test("the rail moves the selection from the keyboard", async ({ page }) => {
test("the table moves the selection from the keyboard", async ({ page }) => {
await railItem(page, "llama-model").click();
await expect(page).toHaveURL(/[?&]model=llama-model(&|$)/);
// Wait for the pane to actually be the detail, not merely for the URL to
@@ -0,0 +1,227 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger, GALLERY, GB } from './ledger-fixtures.js'
// The Installed tab in the ledger's own vocabulary: the same table, state
// filters with counts, a state per row, Load or Stop on the row, the row menu,
// and sizes only where the gallery can state one. The fixture installs 13
// models, loads two, pins one and disables one.
const rows = (page) => page.locator('[data-testid="installed-models-rail-item"]')
const row = (page, name) => page.locator(`[data-entity="${name}"]`)
const pane = (page) => page.locator('[data-testid="installed-models-pane"]')
async function open(page, options = {}, url = '/app/models?view=installed') {
const state = await mockLedger(page, options)
await page.goto(url)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
return state
}
const sizeOf = (name) => GALLERY.find((e) => e.name === name).estimate.sizeBytes
test.describe('Installed ledger - table', () => {
test('every installed model is a row, with state filters that carry counts', async ({ page }) => {
await open(page)
await expect(rows(page)).toHaveCount(13)
const tab = (name) => page.getByRole('tab', { name: new RegExp(`^${name}`) })
await expect(tab('All')).toContainText('13')
await expect(tab('Running')).toContainText('2')
await expect(tab('Idle')).toContainText('10')
await expect(tab('Disabled')).toContainText('1')
await expect(tab('Pinned')).toContainText('1')
await expect(tab('Distributed')).toContainText('0')
await tab('Running').click()
await expect(page).toHaveURL(/[?&]state=running/)
await expect(rows(page)).toHaveCount(2)
await expect(tab('Running')).toHaveAttribute('aria-selected', 'true')
})
test('each row says what state the model is in', async ({ page }) => {
await open(page)
await expect(row(page, 'qwen3-8b-instruct').locator('.ledger-state')).toHaveAttribute('data-state', 'running')
await expect(row(page, 'qwen3-8b-instruct').locator('.ledger-state')).toContainText('Running')
await expect(row(page, 'kokoro-82m').locator('.ledger-state')).toContainText('Idle')
await expect(row(page, 'whisper-medium').locator('.ledger-state')).toHaveAttribute('data-state', 'disabled')
await expect(row(page, 'whisper-medium').locator('.ledger-state')).toContainText('Disabled')
})
test('the row shows its backend and marks a pinned model', async ({ page }) => {
await open(page)
await expect(row(page, 'flux.1-schnell')).toContainText('stablediffusion-ggml')
await expect(row(page, 'whisper-large-v3')).toContainText('whisper')
await expect(row(page, 'qwen3-8b-instruct').getByRole('img', { name: 'Pinned' })).toBeVisible()
await expect(row(page, 'kokoro-82m').getByRole('img')).toHaveCount(0)
})
test('Load, Stop and Enable sit on the row and call the same endpoints as the inspector', async ({ page }) => {
const calls = []
await mockLedger(page)
await page.route('**/backend/load', (route) => { calls.push(['load', route.request().postDataJSON().model]); return route.fulfill({ json: {} }) })
await page.route('**/backend/shutdown', (route) => { calls.push(['stop', route.request().postDataJSON().model]); return route.fulfill({ json: {} }) })
await page.route('**/models/toggle-state/**', (route) => { calls.push(['toggle', new URL(route.request().url()).pathname]); return route.fulfill({ json: {} }) })
await page.goto('/app/models?view=installed')
await expect(rows(page).first()).toBeVisible()
await row(page, 'kokoro-82m').getByRole('button', { name: 'Load', exact: true }).click()
await expect.poll(() => calls.length).toBe(1)
expect(calls[0]).toEqual(['load', 'kokoro-82m'])
await row(page, 'whisper-medium').getByRole('button', { name: 'Enable model' }).click()
await expect.poll(() => calls.length).toBe(2)
expect(calls[1][1]).toContain('/models/toggle-state/whisper-medium/enable')
// Stopping asks first, as it always has.
await row(page, 'qwen3-8b-instruct').getByRole('button', { name: 'Stop', exact: true }).click()
await expect(page.getByRole('alertdialog')).toContainText('Stop model qwen3-8b-instruct?')
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop', exact: true }).click()
await expect.poll(() => calls.length).toBe(3)
expect(calls[2]).toEqual(['stop', 'qwen3-8b-instruct'])
})
test('the row menu offers disable, pin, edit, logs and delete, and delete asks first', async ({ page }) => {
const state = await open(page)
await row(page, 'kokoro-82m').getByRole('button', { name: 'Actions for kokoro-82m' }).click()
const menu = page.getByRole('menu')
for (const name of ['Disable model', 'Pin (prevent idle unload)', 'Edit configuration', 'Backend logs', 'Delete model']) {
await expect(menu.getByRole('menuitem', { name })).toBeVisible()
}
await menu.getByRole('menuitem', { name: 'Delete model' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Delete model kokoro-82m?')
// Nothing is sent until the question is answered.
expect(state.deletes).toHaveLength(0)
await dialog.getByRole('button', { name: 'Delete model' }).click()
await expect.poll(() => state.deletes).toEqual(['kokoro-82m'])
await expect(row(page, 'kokoro-82m')).toHaveCount(0)
})
test('a menu on a row opens that row, not the one selected', async ({ page }) => {
await open(page)
await row(page, 'bge-m3').click()
await row(page, 'kokoro-82m').getByRole('button', { name: 'Actions for kokoro-82m' }).click()
await page.getByRole('menuitem', { name: 'Backend logs' }).click()
await expect(page).toHaveURL(/\/app\/backend-logs\/kokoro-82m/)
})
})
test.describe('Installed ledger - sizes', () => {
test('a size appears where the gallery lists the model, and a dash where it does not', async ({ page }) => {
await open(page)
await expect(row(page, 'qwen3-14b-instruct-q4')).toContainText(`${(sizeOf('qwen3-14b-instruct-q4') / GB).toFixed(1)} GB`)
const unknown = row(page, 'my-finetune-q4').locator('.ledger-size')
await expect(unknown).toContainText('—')
await expect(unknown.locator('span')).toHaveAttribute('title', /does not list this model/)
})
test('sorting by size puts models with no size last in both directions', async ({ page }) => {
await open(page)
// Sizes arrive in the background; wait for the last one to land.
await expect(row(page, 'sdxl-turbo')).toContainText('GB')
const header = page.getByRole('columnheader', { name: /Size/ })
await header.getByRole('button').click()
await expect(header).toHaveAttribute('aria-sort', 'ascending')
let names = await rows(page).evaluateAll((els) => els.map((e) => e.getAttribute('data-entity')))
expect(names[0]).toBe('kokoro-82m')
expect(names[names.length - 1]).toBe('my-finetune-q4')
await header.getByRole('button').click()
await expect(header).toHaveAttribute('aria-sort', 'descending')
names = await rows(page).evaluateAll((els) => els.map((e) => e.getAttribute('data-entity')))
expect(names[0]).toBe('llama-3.3-70b-instruct-iq2')
expect(names[names.length - 1]).toBe('my-finetune-q4')
})
})
test.describe('Installed ledger - inspector and keys', () => {
test('selecting a row opens the inspector with the same actions', async ({ page }) => {
await open(page)
await row(page, 'kokoro-82m').click()
await expect(row(page, 'kokoro-82m')).toHaveAttribute('data-selected', 'true')
await expect(pane(page).getByRole('heading', { name: 'kokoro-82m' })).toBeVisible()
await expect(pane(page).getByRole('button', { name: 'Load', exact: true })).toBeVisible()
await expect(pane(page).getByRole('button', { name: 'Actions for kokoro-82m' })).toBeVisible()
await expect(page).toHaveURL(/model=kokoro-82m/)
})
test('arrows move, Escape closes, "/" searches and "d" changes density', async ({ page }) => {
await open(page)
const names = await rows(page).evaluateAll((els) => els.map((e) => e.getAttribute('data-entity')))
await row(page, names[0]).click()
await page.keyboard.press('ArrowDown')
await expect(row(page, names[1])).toBeFocused()
await expect(row(page, names[1])).toHaveAttribute('data-selected', 'true')
await page.keyboard.press('Escape')
await expect(page).not.toHaveURL(/model=/)
await expect(row(page, names[1])).toBeFocused()
await page.keyboard.press('d')
await expect(page.locator('table.ledger-table')).toHaveClass(/dk-table--compact/)
await page.keyboard.press('/')
await expect(page.getByRole('textbox', { name: 'Search installed models' })).toBeFocused()
})
test('Free up space opens the cleanup review', async ({ page }) => {
await open(page)
await page.getByTestId('installed-cleanup').click()
await expect(page.getByRole('dialog', { name: 'Disk and cleanup' })).toBeVisible()
})
})
test.describe('Installed ledger - states', () => {
test('nothing installed: the next step is named', async ({ page }) => {
await mockLedger(page, { installed: [] })
await page.goto('/app/models?view=installed')
const empty = page.getByTestId('installed-empty')
await expect(empty).toBeVisible({ timeout: 10_000 })
await expect(empty).toContainText('No models installed yet')
await expect(empty.getByRole('button', { name: 'Explore models' })).toBeVisible()
await expect(empty.getByRole('button', { name: 'Import model' })).toBeVisible()
})
test('the first load draws skeleton rows', async ({ page }) => {
await mockLedger(page)
await page.route('**/api/models/capabilities', async (route) => {
await new Promise((r) => setTimeout(r, 1500))
return route.fulfill({ json: { data: [{ id: 'only', backend: 'llama-cpp', capabilities: [] }] } })
})
await page.goto('/app/models?view=installed')
await expect(page.getByTestId('gallery-loader')).toBeVisible({ timeout: 5_000 })
await expect(rows(page).first()).toBeVisible({ timeout: 10_000 })
})
test('a failed list is an inline alert with Retry', async ({ page }) => {
await mockLedger(page)
let fail = true
await page.route('**/api/models/capabilities', (route) => (fail
? route.fulfill({ status: 500, json: { error: 'down' } })
: route.fulfill({ json: { data: [{ id: 'back-again', backend: 'llama-cpp', capabilities: [] }] } })))
await page.route('**/v1/models', (route) => route.fulfill({ status: 500, json: { error: 'down' } }))
await page.goto('/app/models?view=installed')
const alert = page.getByRole('alert').filter({ hasText: 'Could not load installed models' })
await expect(alert).toBeVisible({ timeout: 10_000 })
fail = false
await alert.getByRole('button', { name: 'Retry' }).click()
await expect(row(page, 'back-again')).toBeVisible()
})
test('a filter that matches nothing offers to clear', async ({ page }) => {
await open(page)
await page.getByRole('textbox', { name: 'Search installed models' }).fill('zzz-nothing')
await expect(page.getByText('No installed models match these filters.')).toBeVisible()
await page.getByRole('button', { name: 'Clear filters' }).click()
await expect(rows(page)).toHaveCount(13)
await expect(page).not.toHaveURL(/[?&]q=/)
})
})
test.describe('Installed ledger - phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('rows are name, state and action, and the page stays in its width', async ({ page }) => {
await open(page)
const r = row(page, 'kokoro-82m')
expect(await r.evaluate((el) => getComputedStyle(el).display)).toBe('grid')
await expect(r.getByRole('button', { name: 'Load', exact: true })).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
expect(overflow).toBeLessThanOrEqual(1)
})
})
@@ -0,0 +1,491 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger, GALLERY, PROFILES, withDisk, GB } from './ledger-fixtures.js'
// The Explore ledger: one dense table where every row carries its own fit,
// facets with counts, search, selection, density, the disk strip and the
// states around them. The gallery is the shared 41-model fixture, so the
// numbers below are computed from it rather than typed in.
const rows = (page) => page.locator('[data-testid="discover-rail-item"]')
const row = (page, name) => page.locator(`[data-entity="${name}"]`)
const pane = (page) => page.locator('[data-testid="discover-pane"]')
const strip = (page) => page.getByTestId('disk-strip')
async function open(page, options = {}, url = '/app/models') {
const state = await mockLedger(page, options)
await page.goto(url)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
return state
}
const entry = (name) => GALLERY.find((e) => e.name === name)
const collapsed = GALLERY.filter((e) => !e.variantOf)
const need8k = (name) => entry(name).estimate.estimates[8192].vramBytes
const gb1 = (bytes) => (bytes / GB).toFixed(1)
test.describe('Models ledger - fit on this machine', () => {
test('every row carries its own fit: a bar, the memory needed and the headroom in words', async ({ page }) => {
await open(page)
const r = row(page, 'qwen3-32b-instruct')
const fit = r.locator('[data-fit]')
await expect(fit).toHaveAttribute('data-fit', 'fits')
// 24 GB card, 95 percent usable, minus what the model needs at 8K.
const headroom = gb1(24 * 0.95 * GB - need8k('qwen3-32b-instruct'))
await expect(fit).toContainText(`${headroom} free`)
await expect(fit).toContainText(`${gb1(need8k('qwen3-32b-instruct'))} GB`)
// The bar is drawn, and solid: no gradient on the fill.
const fill = fit.locator('.ledger-fit__fill')
await expect(fill).toBeVisible()
expect(await fill.evaluate((el) => getComputedStyle(el).backgroundImage)).toBe('none')
expect((await fill.boundingBox()).width).toBeGreaterThan(3)
})
test('a model too big for the card spills to the CPU and says how much', async ({ page }) => {
await open(page)
const fit = row(page, 'llama-3.3-70b-instruct-iq2').locator('[data-fit]')
await expect(fit).toHaveAttribute('data-fit', 'spill')
const spill = gb1(need8k('llama-3.3-70b-instruct-iq2') - 24 * 0.95 * GB)
await expect(fit).toContainText(`+${spill} on CPU`)
})
test('8 GB laptop: small models fit, mid ones spill, big ones are over', async ({ page }) => {
await open(page, { profile: 'laptop' })
// 6 GB of GPU memory, 10 GB of RAM to spill into.
const limit = 6 * 0.95 * GB
const small = row(page, 'qwen3-4b-instruct').locator('[data-fit]')
await expect(small).toHaveAttribute('data-fit', 'fits')
await expect(small).toContainText(`${gb1(limit - need8k('qwen3-4b-instruct'))} free`)
const mid = row(page, 'qwen3-8b-instruct').locator('[data-fit]')
await expect(mid).toHaveAttribute('data-fit', 'spill')
await expect(mid).toContainText(`+${gb1(need8k('qwen3-8b-instruct') - limit)} on CPU`)
const big = row(page, 'qwen3-14b-instruct').locator('[data-fit]')
await expect(big).toHaveAttribute('data-fit', 'over')
// Over GPU and RAM together: what is left after the RAM took its share.
await expect(big).toContainText(`${gb1(need8k('qwen3-14b-instruct') - limit - 10 * GB)} over`)
})
test('no GPU: the budget is memory, nothing spills and the page says so', async ({ page }) => {
await open(page, { profile: 'nogpu' })
await expect(page.getByTestId('fit-basis')).toContainText('No GPU found')
await expect(page.getByText('Fits in memory')).toBeVisible()
await expect(page.getByText('Fits in GPU')).toHaveCount(0)
await expect(row(page, 'qwen3-32b-instruct').locator('[data-fit]')).toHaveAttribute('data-fit', 'fits')
const big = row(page, 'gpt-oss-120b').locator('[data-fit]')
await expect(big).toHaveAttribute('data-fit', 'over')
await expect(big).toContainText(`${gb1(need8k('gpt-oss-120b') - 32 * 0.95 * GB)} over`)
await expect(page.locator('[data-fit="spill"]')).toHaveCount(0)
})
test('the context slider moves every fit and the column header with it', async ({ page }) => {
await open(page)
await expect(page.getByRole('columnheader', { name: /Fit at 8K/ })).toBeVisible()
await expect(row(page, 'qwen3-32b-instruct').locator('[data-fit]')).toHaveAttribute('data-fit', 'fits')
await page.locator('#models-context-size').fill('2')
await expect(page.getByRole('columnheader', { name: /Fit at 32K/ })).toBeVisible()
// At a 32K context the same model no longer fits in the card.
await expect(row(page, 'qwen3-32b-instruct').locator('[data-fit]')).toHaveAttribute('data-fit', 'spill')
})
test('Fits in GPU hides the rows that do not fit and keeps the ones with no estimate', async ({ page }) => {
await open(page)
await expect(row(page, 'llama-3.3-70b-instruct-iq2')).toBeVisible()
await page.locator('label.filter-bar-group__toggle', { hasText: 'Fits in GPU' }).locator('.toggle__track').click()
await expect(row(page, 'llama-3.3-70b-instruct-iq2')).toHaveCount(0)
await expect(row(page, 'qwen3-4b-instruct')).toBeVisible()
})
test('the inspector explains the fit and shows what an install leaves on disk', async ({ page }) => {
await open(page)
await row(page, 'qwen3-32b-instruct').click()
const summary = pane(page).getByTestId('fit-summary')
await expect(summary).toContainText('It fits')
await expect(summary).toContainText('GPU')
// 171 GB free, minus the 19.2 GB download.
await expect(pane(page).getByTestId('leaves-free')).toContainText('leaves 152 GB free')
await expect(pane(page).locator('.discover__chart')).toBeVisible()
})
test('a spilling model shows the GPU and the RAM bar in the inspector', async ({ page }) => {
await open(page, {}, '/app/models?model=llama-3.3-70b-instruct-iq2')
const summary = pane(page).getByTestId('fit-summary')
await expect(summary).toContainText('on the CPU')
await expect(summary.locator('.ledger-fitblock__row')).toHaveCount(2)
})
})
test.describe('Models ledger - facets and search', () => {
test('capability chips carry the server counts', async ({ page }) => {
await open(page)
const count = (key) => collapsed.filter((e) => e.facets.includes(key)).length
await expect(page.getByTestId('facet-all')).toContainText(String(collapsed.length))
await expect(page.getByTestId('facet-chat')).toContainText(String(count('chat')))
await expect(page.getByTestId('facet-tts')).toContainText(String(count('tts')))
// The count is read from the listing's own total, not tallied in the page.
await expect(page.getByTestId('facet-tts')).toHaveAccessibleName(new RegExp(`TTS ${count('tts')}`))
})
test('a facet filters on the server and the chip says it is pressed', async ({ page }) => {
const state = await open(page)
await page.getByTestId('facet-tts').click()
await expect(page.getByTestId('facet-tts')).toHaveAttribute('aria-pressed', 'true')
const tts = collapsed.filter((e) => e.facets.includes('tts'))
await expect(rows(page)).toHaveCount(tts.length)
await expect(page.getByTestId('facet-all')).toHaveAttribute('aria-pressed', 'false')
const listing = state.listingRequests.filter((q) => q.includes('items=30') && q.includes('tag=tts'))
expect(listing.length).toBeGreaterThan(0)
})
test('a search narrows the counts and drops the facets nothing matches', async ({ page }) => {
await open(page)
await expect(page.getByTestId('facet-chat')).toBeVisible()
await page.getByTestId('models-search').fill('whisper')
await expect(page.getByTestId('facet-transcript')).toContainText('2', { timeout: 10_000 })
await expect(page.getByTestId('facet-chat')).toHaveCount(0)
await expect(page.getByTestId('facet-all')).toContainText('2')
})
test('"/" jumps to the search field and Escape leaves it', async ({ page }) => {
await open(page)
const search = page.getByTestId('models-search')
await expect(search).not.toBeFocused()
await page.keyboard.press('/')
await expect(search).toBeFocused()
// A slash typed into the field is text, not a second jump.
await page.keyboard.type('a/b')
await expect(search).toHaveValue('a/b')
await page.keyboard.press('Escape')
await expect(search).not.toBeFocused()
})
test('search keeps its meaning: the term goes to the server as typed', async ({ page }) => {
const state = await open(page)
await page.getByTestId('models-search').fill('kokoro')
await expect(rows(page)).toHaveCount(1)
await expect(row(page, 'kokoro-82m')).toBeVisible()
await expect(page).toHaveURL(/[?&]q=kokoro/)
// A sentence is not parsed into filters. It is a term like any other, and
// the listing answers it (here with nothing).
await page.getByTestId('models-search').fill('models that fit my gpu')
await expect(page.getByTestId('gallery-empty')).toBeVisible()
await expect.poll(() => state.listingRequests.some((q) => q.includes('term=models+that+fit+my+gpu') || q.includes('term=models%20that%20fit%20my%20gpu'))).toBe(true)
// No tag was invented from the words.
expect(state.listingRequests.filter((q) => q.includes('items=30') && q.includes('models+that')).every((q) => !q.includes('tag='))).toBe(true)
})
})
test.describe('Models ledger - selection and keys', () => {
test('selection is a surface step and a check, with no rail on the edge', async ({ page }) => {
await open(page)
const r = row(page, 'qwen3-4b-instruct')
const cell = r.locator('td').nth(1)
const before = await cell.evaluate((el) => getComputedStyle(el).backgroundColor)
await r.click()
await expect(r).toHaveAttribute('data-selected', 'true')
await expect(r).toHaveAttribute('aria-current', 'true')
const after = await cell.evaluate((el) => {
const cs = getComputedStyle(el)
return { bg: cs.backgroundColor, shadow: cs.boxShadow, left: cs.borderLeftWidth }
})
expect(after.bg).not.toBe(before)
expect(after.shadow).not.toContain('inset')
expect(after.left).toBe('0px')
// The check mark fills in.
const mark = r.locator('.ledger-mark')
expect(await mark.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe('rgba(0, 0, 0, 0)')
// And there is no rail beside the table at all.
await expect(page.locator('.entity-rail, .split-view__rail-col')).toHaveCount(0)
await expect(pane(page).getByRole('heading', { name: 'qwen3-4b-instruct' })).toBeVisible()
})
test('arrow keys move the selection and the focus together', async ({ page }) => {
await open(page)
const names = await rows(page).evaluateAll((els) => els.map((e) => e.getAttribute('data-entity')))
await row(page, names[0]).click()
await page.keyboard.press('ArrowDown')
await expect(row(page, names[1])).toBeFocused()
await expect(row(page, names[1])).toHaveAttribute('data-selected', 'true')
await expect(page).toHaveURL(new RegExp(`model=${names[1]}`))
await page.keyboard.press('ArrowUp')
await expect(row(page, names[0])).toBeFocused()
// Moving by key replaces the history entry: Back leaves the page, it does
// not walk back through every row that was passed: one step out of the
// inspector, to the table as it was before the first click.
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
await page.goBack()
await expect(page).not.toHaveURL(/model=/)
})
test('Enter installs the focused row, and does nothing on one that is installed', async ({ page }) => {
const state = await open(page)
await row(page, 'qwen3-4b-instruct').click()
await page.keyboard.press('Enter')
await expect.poll(() => state.installs.length).toBe(1)
expect(state.installs[0].name).toBe('qwen3-4b-instruct')
// Plain Install asks for no variant: the server picks the build.
expect(state.installs[0].search).toBe('')
await row(page, 'qwen3-8b-instruct').click()
await expect(row(page, 'qwen3-8b-instruct')).toContainText('Installed')
await page.keyboard.press('Enter')
await page.waitForTimeout(300)
expect(state.installs).toHaveLength(1)
})
test('the Install button in a row installs without moving the selection', async ({ page }) => {
const state = await open(page)
await row(page, 'qwen3-4b-instruct').click()
await row(page, 'qwen3-30b-a3b').getByTestId('discover-row-install').click()
await expect.poll(() => state.installs.length).toBe(1)
expect(state.installs[0].name).toBe('qwen3-30b-a3b')
await expect(row(page, 'qwen3-4b-instruct')).toHaveAttribute('data-selected', 'true')
})
test('Escape closes the inspector and puts focus back on the row', async ({ page }) => {
await open(page)
await row(page, 'qwen3-4b-instruct').click()
await expect(pane(page).getByTestId('discover-back')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page).not.toHaveURL(/model=/)
await expect(pane(page).getByTestId('discover-back')).toHaveCount(0)
await expect(row(page, 'qwen3-4b-instruct')).toBeFocused()
})
test('there is no compare key: "c" does nothing', async ({ page }) => {
await open(page)
await row(page, 'qwen3-4b-instruct').click()
await page.keyboard.press('c')
await expect(page.getByRole('dialog')).toHaveCount(0)
await expect(page).toHaveURL(/model=qwen3-4b-instruct/)
})
})
test.describe('Models ledger - density', () => {
test('"d" flips between comfortable and compact rows, and the choice is kept', async ({ page }) => {
await open(page)
const table = page.locator('table.ledger-table')
await expect(table).not.toHaveClass(/dk-table--compact/)
const tall = (await row(page, 'qwen3-4b-instruct').boundingBox()).height
await page.keyboard.press('d')
await expect(table).toHaveClass(/dk-table--compact/)
await expect(page.getByTestId('density-compact')).toHaveAttribute('aria-pressed', 'true')
const short = (await row(page, 'qwen3-4b-instruct').boundingBox()).height
expect(short).toBeLessThan(tall)
await page.reload()
await expect(rows(page).first()).toBeVisible()
await expect(page.locator('table.ledger-table')).toHaveClass(/dk-table--compact/)
await page.getByTestId('density-comfortable').click()
await expect(page.locator('table.ledger-table')).not.toHaveClass(/dk-table--compact/)
})
test('"d" is text, not a switch, while typing in the search field', async ({ page }) => {
await open(page)
await page.getByTestId('models-search').click()
await page.keyboard.type('mod')
await expect(page.getByTestId('models-search')).toHaveValue('mod')
await expect(page.locator('table.ledger-table')).not.toHaveClass(/dk-table--compact/)
})
})
test.describe('Models ledger - disk strip', () => {
test('it shows what is free on the models disk and stays neutral', async ({ page }) => {
await open(page)
await expect(strip(page)).toContainText('171 GB free')
await expect(strip(page)).toHaveAttribute('data-low', 'false')
await expect(strip(page)).not.toContainText('Low disk')
await expect(strip(page)).toHaveAccessibleName(/171 GB free of 931 GB/)
})
test('it turns amber and says so when the disk is low', async ({ page }) => {
await open(page, { resources: withDisk(PROFILES.gpu24, { total: 931 * GB, used: 908 * GB, available: 23 * GB }) })
await expect(strip(page)).toHaveAttribute('data-low', 'true')
await expect(strip(page)).toContainText('Low disk')
const border = await strip(page).evaluate((el) => getComputedStyle(el).borderTopColor)
const warn = await page.evaluate(() => {
const probe = document.createElement('span')
probe.style.color = 'var(--dk-warn)'
document.body.appendChild(probe)
const color = getComputedStyle(probe).color
probe.remove()
return color
})
expect(border).toBe(warn)
await expect(strip(page)).toHaveAccessibleName(/Low disk/)
})
test('low means under 10 percent free, or under 20 GB free', async ({ page }) => {
const at = (total, free) => withDisk(PROFILES.gpu24, { total: total * GB, used: (total - free) * GB, available: free * GB })
await open(page, { resources: at(4000, 450) })
await expect(strip(page)).toHaveAttribute('data-low', 'false')
await page.unrouteAll({ behavior: 'ignoreErrors' })
await open(page, { resources: at(4000, 390) })
await expect(strip(page)).toHaveAttribute('data-low', 'true')
await page.unrouteAll({ behavior: 'ignoreErrors' })
await open(page, { resources: at(100, 19) })
await expect(strip(page)).toHaveAttribute('data-low', 'true')
})
test('it is hidden when the server reports no disk', async ({ page }) => {
const { disk, ...noDisk } = PROFILES.gpu24
void disk
await open(page, { resources: noDisk })
await expect(strip(page)).toHaveCount(0)
// The rest of the page does not care.
await expect(row(page, 'qwen3-4b-instruct')).toBeVisible()
})
test('it is hidden on a cluster controller, whose own disk says nothing about the workers', async ({ page }) => {
await open(page, {
resources: { ...PROFILES.gpu24, cluster: { enabled: true, node_name: 'w1', node_count: 2, total_memory: 80 * GB, is_gpu: true } },
})
await expect(strip(page)).toHaveCount(0)
})
test('an install that does not fit on the disk says so in words', async ({ page }) => {
await open(page, {
resources: withDisk(PROFILES.gpu24, { total: 931 * GB, used: 926 * GB, available: 5 * GB }),
}, '/app/models?model=qwen3-32b-instruct')
await expect(pane(page).getByTestId('leaves-free')).toContainText('less than that free')
})
test('it opens the cleanup review', async ({ page }) => {
await open(page)
await strip(page).click()
await expect(page.getByRole('dialog', { name: 'Disk and cleanup' })).toBeVisible()
})
})
test.describe('Models ledger - states', () => {
test('the first load draws skeleton rows in the table', async ({ page }) => {
await mockLedger(page, { listingDelay: 1500 })
await page.goto('/app/models')
await expect(page.getByTestId('gallery-loader')).toBeVisible({ timeout: 5_000 })
await expect(page.locator('table.ledger-table')).toHaveAttribute('aria-busy', 'true')
await expect(rows(page).first()).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('gallery-loader')).toHaveCount(0)
})
test('an empty gallery says so and offers no filter to clear', async ({ page }) => {
await mockLedger(page, { gallery: [] })
await page.goto('/app/models')
const empty = page.getByTestId('gallery-empty')
await expect(empty).toBeVisible({ timeout: 10_000 })
await expect(empty).toContainText('The model gallery is empty.')
await expect(empty.getByRole('button', { name: 'Clear filters' })).toHaveCount(0)
})
test('offline: a banner with Retry, and Retry brings the list back', async ({ page }) => {
const state = await mockLedger(page, { listingStatus: 500 })
await page.goto('/app/models')
const banner = page.getByTestId('gallery-error')
await expect(banner).toBeVisible({ timeout: 10_000 })
await expect(banner).toContainText('Cannot reach the gallery')
await expect(rows(page)).toHaveCount(0)
state.listingStatus = 200
await banner.getByRole('button', { name: 'Retry' }).click()
await expect(rows(page).first()).toBeVisible()
await expect(page.getByTestId('gallery-error')).toHaveCount(0)
})
test('install failed: the row names the error and offers Retry, which reinstalls', async ({ page }) => {
const state = await open(page, {
operations: [{ name: 'qwen3-32b-instruct', jobID: 'job-9', error: 'Connection reset at 62%', progress: 62 }],
})
const r = row(page, 'qwen3-32b-instruct')
await expect(r).toContainText('Connection reset at 62%')
const retry = r.getByTestId('discover-row-retry')
await expect(retry).toBeVisible()
await expect(r.getByTestId('discover-row-install')).toHaveCount(0)
await retry.click()
await expect.poll(() => state.installs.length).toBe(1)
expect(state.installs[0].name).toBe('qwen3-32b-instruct')
// The old failure is dismissed so it does not sit beside the new attempt.
expect(state.dismissed).toContain('job-9')
})
test('install failed: the inspector says what happened and the next step', async ({ page }) => {
await open(page, {
operations: [{ name: 'qwen3-32b-instruct', jobID: 'job-9', error: 'Connection reset at 62%' }],
}, '/app/models?model=qwen3-32b-instruct')
await expect(pane(page).getByRole('alert')).toContainText('Install failed: Connection reset at 62%')
await expect(pane(page).getByTestId('discover-install')).toContainText('Retry')
})
test('an install in flight shows its progress in the row', async ({ page }) => {
await open(page, { operations: [{ name: 'qwen3-32b-instruct', jobID: 'job-2', progress: 38 }] })
const r = row(page, 'qwen3-32b-instruct')
await expect(r).toContainText('Installing')
await expect(r).toContainText('38%')
await expect(r.getByTestId('discover-row-install')).toHaveCount(0)
})
test('the Installed tab carries the count of installed models', async ({ page }) => {
await open(page)
await expect(page.getByRole('link', { name: /^Installed/ })).toContainText('13')
})
test('reduced motion removes the transitions', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await open(page)
const duration = await row(page, 'qwen3-4b-instruct').locator('td').first().evaluate((el) => getComputedStyle(el).transitionDuration)
expect(Math.max(...duration.split(',').map((d) => parseFloat(d)))).toBeLessThan(0.01)
})
})
test.describe('Models ledger - phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('rows collapse to name, fit and action, and the page does not scroll sideways', async ({ page }) => {
await open(page)
const r = row(page, 'qwen3-4b-instruct')
expect(await r.evaluate((el) => getComputedStyle(el).display)).toBe('grid')
await expect(page.getByRole('columnheader', { name: 'Size' })).toBeHidden()
const name = await r.locator('.ledger-name').boundingBox()
const fit = await r.locator('[data-fit]').boundingBox()
expect(fit.y).toBeGreaterThan(name.y)
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - window.innerWidth)
expect(overflow).toBeLessThanOrEqual(1)
// The action stays reachable and large enough to tap.
const button = await r.getByTestId('discover-row-install').boundingBox()
expect(button.height).toBeGreaterThanOrEqual(30)
})
// On a phone there is no inspector beside the table, so a tap on a row opens
// the model's own page. Back gives the table back, with focus on the arrow of
// the row that was opened.
test('a tapped row is the page, and Back gives the table back', async ({ page }) => {
await open(page)
const r = row(page, 'qwen3-4b-instruct')
await r.locator('.ledger-name').click()
await expect(page.getByTestId('model-page')).toBeVisible()
await expect(page.getByTestId('model-page-name')).toHaveText('qwen3-4b-instruct')
await expect(r).toBeHidden()
await page.getByTestId('model-page-back').click()
await expect(r).toBeVisible()
await expect(r.locator('[data-row-open]')).toBeFocused()
})
// Between a phone and a desk the inspector still takes the page when a row is
// selected, and its close button gives the table back.
test('on a narrow window a selected row is the inspector page, and the close button gives the table back', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 900 })
await open(page)
const r = row(page, 'qwen3-4b-instruct')
await r.click()
await expect(pane(page)).toBeVisible()
await expect(r).toBeHidden()
await pane(page).getByTestId('discover-back').click()
await expect(r).toBeVisible()
await expect(r).toBeFocused()
})
test('the disk strip spans the width and is a tap target', async ({ page }) => {
await open(page)
const box = await strip(page).boundingBox()
expect(box.width).toBeGreaterThan(300)
expect(box.height).toBeGreaterThanOrEqual(40)
})
})
+29 -12
View File
@@ -111,7 +111,8 @@ test.describe('Models lifecycle', () => {
await page.goto('/app/models')
const explore = page.getByRole('link', { name: 'Explore', exact: true })
const installed = page.getByRole('link', { name: 'Installed', exact: true })
// The tab carries the installed count, so its name starts with the label.
const installed = page.getByRole('link', { name: /^Installed/ })
await expect(explore).toHaveAttribute('aria-current', 'page')
await expect(installed).not.toHaveAttribute('aria-current', 'page')
await expect(page.locator('[data-testid="discover"]')).toBeVisible()
@@ -137,15 +138,15 @@ test.describe('Models lifecycle', () => {
test('switches to Installed and restores URL state through history', async ({ page }) => {
await page.goto('/app/models')
await page.getByRole('link', { name: 'Installed', exact: true }).click()
await page.getByRole('link', { name: /^Installed/ }).click()
await expect(page).toHaveURL(/[?&]view=installed/)
await expect(page.getByRole('link', { name: 'Installed', exact: true })).toHaveAttribute('aria-current', 'page')
await expect(page.getByRole('link', { name: /^Installed/ })).toHaveAttribute('aria-current', 'page')
await expect(installedRail(page)).toBeVisible()
const search = page.getByRole('textbox', { name: 'Search installed models' })
await search.fill('beta')
await page.getByRole('tab', { name: /Idle$/ }).click()
await page.getByRole('tab', { name: /^Idle/ }).click()
await page.locator('[data-entity="beta"]').click()
await expect(page).toHaveURL(/[?&]q=beta/)
@@ -156,7 +157,7 @@ test.describe('Models lifecycle', () => {
await page.goBack()
await expect(page).not.toHaveURL(/[?&]model=beta/)
await expect(search).toHaveValue('beta')
await expect(page.getByRole('tab', { name: /Idle$/ })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('tab', { name: /^Idle/ })).toHaveAttribute('aria-selected', 'true')
await page.goForward()
await expect(page).toHaveURL(/[?&]model=beta/)
@@ -167,7 +168,7 @@ test.describe('Models lifecycle', () => {
await page.goto('/app/models?view=installed&q=remote&state=distributed&model=remote-model')
await expect(page.getByRole('textbox', { name: 'Search installed models' })).toHaveValue('remote')
await expect(page.getByRole('tab', { name: /Distributed$/ })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('tab', { name: /^Distributed/ })).toHaveAttribute('aria-selected', 'true')
await expect(installedPane(page)).toContainText('remote-model')
await expect(installedPane(page)).toContainText('Worker one')
})
@@ -188,7 +189,8 @@ test.describe('Models lifecycle', () => {
test('stops a running model with confirmation', async ({ page }) => {
await page.goto('/app/models?view=installed&model=alpha')
const stop = page.getByRole('button', { name: 'Stop', exact: true })
// The row and the inspector both carry Stop; this drives the inspector's.
const stop = installedPane(page).getByRole('button', { name: 'Stop', exact: true })
await expect(stop).toBeVisible()
await stop.click()
await expect(page.getByRole('alertdialog')).toContainText('Stop model alpha?')
@@ -197,7 +199,7 @@ test.describe('Models lifecycle', () => {
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop', exact: true }).click()
const request = await requestPromise
expect(request.postDataJSON()).toEqual({ model: 'alpha' })
await expect(page.getByRole('button', { name: 'Load', exact: true })).toBeVisible({ timeout: 3_000 })
await expect(installedPane(page).getByRole('button', { name: 'Load', exact: true })).toBeVisible({ timeout: 3_000 })
})
test('keeps a runtime failure inline with its model', async ({ page }) => {
@@ -208,7 +210,7 @@ test.describe('Models lifecycle', () => {
}))
await page.goto('/app/models?view=installed&model=beta')
await page.getByRole('button', { name: 'Load', exact: true }).click()
await installedPane(page).getByRole('button', { name: 'Load', exact: true }).click()
const alert = installedPane(page).getByRole('alert')
await expect(alert).toContainText('Could not load beta: engine unavailable')
@@ -222,7 +224,7 @@ test.describe('Models lifecycle', () => {
}))
await page.goto('/app/models?view=installed&model=beta')
await page.getByRole('button', { name: 'Actions for beta' }).click()
await installedPane(page).getByRole('button', { name: 'Actions for beta' }).click()
await page.getByRole('menuitem', { name: 'Delete model' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete model' }).click()
@@ -237,7 +239,7 @@ test.describe('Models lifecycle', () => {
}))
await page.goto('/app/models?view=installed&model=beta')
await page.getByRole('button', { name: 'Actions for beta' }).click()
await installedPane(page).getByRole('button', { name: 'Actions for beta' }).click()
await page.getByRole('menuitem', { name: 'Delete model' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Delete model' }).click()
@@ -271,7 +273,7 @@ test.describe('Models lifecycle', () => {
})
test('narrow detail Back restores focus to the originating model', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.setViewportSize({ width: 800, height: 800 })
await page.goto('/app/models?view=installed')
const model = page.locator('[data-entity="beta"]')
@@ -284,4 +286,19 @@ test.describe('Models lifecycle', () => {
await expect(model).toBeVisible()
await expect(model).toBeFocused()
})
test('on a phone a tapped model opens its page and Back restores focus to its arrow', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 800 })
await page.goto('/app/models?view=installed')
const model = page.locator('[data-entity="beta"]')
await model.click()
await expect(page.locator('[data-testid="model-page-name"]')).toHaveText('beta')
await expect(model).not.toBeVisible()
await page.locator('[data-testid="model-page-back"]').click()
await expect(model).toBeVisible()
await expect(model.locator('[data-row-open]')).toBeFocused()
})
})
@@ -1,11 +1,11 @@
import { test, expect } from "./coverage-fixtures.js";
import { mockLedger, stubRecommendations, PROFILES } from "./ledger-fixtures.js";
// The "Recommended for your hardware" strip defaults its own prominence off the
// installed-model count and remembers both the collapse choice and a dismissal,
// so every assertion here is about state that must survive a reload.
const DISMISS_KEY = "localai-models-recommended-dismissed";
const COLLAPSE_KEY = "localai-models-recommended-collapsed";
// The "Best for this machine" shelf sits in the empty inspector of Models
// Explore. There is no recommendation endpoint: it ranks the chat gallery
// against /api/resources and /api/models/estimate. With nothing installed it
// lists every fit; once something is installed it narrows to the best fit and
// offers the rest behind a toggle.
const REC_MODELS = [
{ name: "tiny-chat", description: "Tiny", backend: "llama-cpp", installed: false, tags: ["chat"] },
@@ -148,3 +148,79 @@ for (const view of ["models", "home"]) {
await expect(section).toHaveCount(0, { timeout: 15_000 });
});
}
// The same shelf against the shared ledger fixture, which has the shape of the
// real gallery: long model ids in a 400 px pane, and a machine profile.
test.describe("Best for this machine shelf (ledger fixture)", () => {
async function open(page, options = {}) {
await mockLedger(page, options);
await stubRecommendations(page);
await page.goto("/app/models");
await expect(panel(page)).toBeVisible({ timeout: 20_000 });
}
test("ranks what fits the machine, best fit first, with a size and an install button each", async ({ page }) => {
await open(page, { installed: [], loaded: [] });
await expect(panel(page).getByRole("heading", { name: "Best for this machine" })).toBeVisible();
const rows = grid(page).locator(".lane");
await expect(rows).toHaveCount(4);
await expect(rows.first().locator(".lane__tag--evidence")).toHaveText("Best fit");
await expect(rows.nth(1).locator(".lane__tag")).toHaveText("Also fits");
for (let i = 0; i < 4; i++) {
await expect(rows.nth(i).getByRole("button", { name: "Install" })).toBeVisible();
await expect(rows.nth(i)).toContainText(/\d+\.\d+ GB/);
await expect(rows.nth(i)).toContainText(/VRAM/);
}
// Every model that is listed fits the 24 GB card.
await expect(panel(page)).not.toContainText("gpt-oss-120b");
});
test("a long model id wraps inside its row and never runs under the install button", async ({ page }) => {
await open(page, { installed: [], loaded: [] });
const row = grid(page).locator(".lane").first();
const name = await row.locator(".lane__name").boundingBox();
const button = await row.getByRole("button", { name: "Install" }).boundingBox();
expect(name.x + name.width).toBeLessThanOrEqual(button.x);
const pane = await page.getByTestId("discover-pane").boundingBox();
expect(button.x + button.width).toBeLessThanOrEqual(pane.x + pane.width);
});
test("with a model installed it narrows to the best fit and the rest opens on request", async ({ page }) => {
await open(page);
const rows = grid(page).locator(".lane");
await expect(rows).toHaveCount(1);
await expect(rows.first().locator(".lane__tag--evidence")).toHaveText("Best fit");
const more = toggle(page);
await expect(more).toHaveAttribute("aria-expanded", "false");
await expect(more).toHaveText("3 more that fit");
await more.click();
await expect(rows).toHaveCount(4);
await expect(more).toHaveAttribute("aria-expanded", "true");
await more.click();
await expect(rows).toHaveCount(1);
});
test("renders nothing when the machine is too small for any candidate", async ({ page }) => {
await mockLedger(page, { installed: [], loaded: [], resources: { ...PROFILES.laptop, aggregate: { ...PROFILES.laptop.aggregate, total_memory: 0.5 * 1024 ** 3 }, gpus: [{ vendor: "nvidia", total_vram: 0.5 * 1024 ** 3 }] } });
await stubRecommendations(page);
await page.goto("/app/models");
await expect(page.getByTestId("discover-pane")).toContainText("Your host", { ignoreCase: true, timeout: 20_000 });
await expect(panel(page)).toHaveCount(0);
});
test("install posts the model that was named", async ({ page }) => {
await mockLedger(page, { installed: [], loaded: [] });
await stubRecommendations(page);
let named = null;
await page.route("**/api/models/install/*", (route) => {
named = decodeURIComponent(new URL(route.request().url()).pathname.split("/").pop());
return route.fulfill({ json: { jobID: "job-1" } });
});
await page.goto("/app/models");
await expect(panel(page)).toBeVisible({ timeout: 20_000 });
const first = grid(page).locator(".lane").first();
const id = (await first.locator(".lane__name").textContent()).trim();
await first.getByRole("button", { name: "Install" }).click();
await expect.poll(() => named).toBe(id);
});
});
@@ -0,0 +1,290 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLedger, storageSpec, GALLERY, GB } from './ledger-fixtures.js'
// What the models directory really holds, from GET /api/models/storage: a real
// Size column on the Installed table, the files of a model on its page, and
// the cleanup review counting only what removing a model really frees. A file
// two installed models use stays on disk when one of them goes.
//
// storageSpec() in the fixtures: qwen3-14b-instruct (15.7 GB + a 1.2 GB
// tokenizer) and qwen3-14b-instruct-q4 (9.0 GB + the same tokenizer) share the
// tokenizer; the two whisper models share a 0.2 GB filter file; my-finetune-q4
// names a projector file that is not on disk.
const rows = (page) => page.locator('[data-testid="installed-models-rail-item"]')
const row = (page, name) => page.locator(`[data-entity="${name}"]`)
const pane = (page) => page.getByTestId('installed-models-pane')
const strip = (page) => page.getByTestId('disk-strip')
const sheet = (page) => page.getByTestId('cleanup-sheet')
const item = (page, name) => sheet(page).locator('li', { has: page.locator('.ledger-cr__name', { hasText: name }) })
const gb1 = (bytes) => (bytes / GB).toFixed(1)
const sizeOf = (name) => GALLERY.find((e) => e.name === name).estimate.sizeBytes
async function openInstalled(page, options = {}, url = '/app/models?view=installed') {
const state = await mockLedger(page, { storage: storageSpec(), ...options })
await page.goto(url)
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
return state
}
async function openSheet(page, options = {}) {
const state = await mockLedger(page, { storage: storageSpec(), ...options })
await page.goto('/app/models')
await expect(strip(page)).toBeVisible({ timeout: 15_000 })
await strip(page).click()
await expect(sheet(page)).toBeVisible()
await expect(sheet(page).locator('.dk-sheet-body')).toHaveAttribute('aria-busy', 'false', { timeout: 15_000 })
return state
}
test.describe('Installed table - size on disk', () => {
test('the Size column holds what each model takes on disk, with the shared part under it', async ({ page }) => {
await openInstalled(page)
await expect(row(page, 'bge-m3').locator('.ledger-size')).toContainText('0.6 GB')
// 15.7 GB of weights and the 1.2 GB tokenizer another build also uses.
await expect(row(page, 'qwen3-14b-instruct').locator('.ledger-size')).toContainText('16.9 GB')
await expect(row(page, 'qwen3-14b-instruct').locator('.ledger-size')).toContainText('1.2 GB shared')
await expect(row(page, 'whisper-medium').locator('.ledger-size')).toContainText('0.2 GB shared')
// A model that shares nothing has no second line.
await expect(row(page, 'bge-m3').locator('.ledger-size__shared')).toHaveCount(0)
// The exact figure is on the tooltip.
await expect(row(page, 'bge-m3').locator('.ledger-size span').first()).toHaveAttribute('title', /on disk/)
})
test('a model that is not in the gallery still has a size, because the disk knows it', async ({ page }) => {
await openInstalled(page)
await expect(row(page, 'my-finetune-q4').locator('.ledger-size')).toContainText('4.6 GB')
})
test('a size read from disk is not marked as an estimate', async ({ page }) => {
await openInstalled(page)
const cell = row(page, 'sdxl-turbo').locator('.ledger-size')
await expect(cell).toContainText('6.9 GB')
await expect(cell.locator('span').first()).toHaveAttribute('title', /on disk/)
await expect(cell.locator('[title^="Estimated"]')).toHaveCount(0)
})
test('a model the report has no files for falls back to the gallery estimate', async ({ page }) => {
const spec = storageSpec()
delete spec.models['kokoro-82m']
await openInstalled(page, { storage: spec })
const cell = row(page, 'kokoro-82m').locator('.ledger-size')
await expect(cell).toContainText(`${gb1(sizeOf('kokoro-82m'))} GB`)
await expect(cell.locator('span').first()).toHaveAttribute('title', /Estimated from the gallery/)
})
test('sorting by size uses the size on disk', async ({ page }) => {
await openInstalled(page)
const header = page.getByRole('columnheader', { name: /Size/ })
await header.getByRole('button').click()
let names = await rows(page).evaluateAll((els) => els.map((e) => e.getAttribute('data-entity')))
expect(names[0]).toBe('kokoro-82m')
expect(names[names.length - 1]).toBe('llama-3.3-70b-instruct-iq2')
await header.getByRole('button').click()
names = await rows(page).evaluateAll((els) => els.map((e) => e.getAttribute('data-entity')))
expect(names[0]).toBe('llama-3.3-70b-instruct-iq2')
expect(names[names.length - 1]).toBe('kokoro-82m')
})
test('the footer gives the total on disk, the shared part and the missing references', async ({ page }) => {
await openInstalled(page)
const summary = page.getByTestId('installed-models-storage-summary')
await expect(summary).toContainText('on disk')
await expect(summary).toContainText('shared across models')
await expect(summary).toContainText('1 missing references')
})
test('an empty storage report gives no footer line instead of 0.0 GB on disk', async ({ page }) => {
await mockLedger(page)
await page.goto('/app/models?view=installed')
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('installed-models-storage-summary')).toHaveCount(0)
})
test('a model whose config names a missing file is marked on its row', async ({ page }) => {
await openInstalled(page)
await expect(row(page, 'my-finetune-q4').getByRole('img', { name: '1 referenced file missing' })).toBeVisible()
await expect(row(page, 'bge-m3').getByRole('img', { name: /missing/ })).toHaveCount(0)
})
for (const [label, status] of [['the call fails', 500], ['the user is not an admin', 403]]) {
test(`when ${label}, sizes fall back to the gallery and nothing else breaks`, async ({ page }) => {
await openInstalled(page, { storageStatus: status })
const q4 = row(page, 'qwen3-14b-instruct-q4').locator('.ledger-size')
await expect(q4).toContainText(`${gb1(sizeOf('qwen3-14b-instruct-q4'))} GB`)
await expect(q4.locator('span').first()).toHaveAttribute('title', /Estimated from the gallery/)
await expect(row(page, 'qwen3-14b-instruct-q4').locator('.ledger-size__shared')).toHaveCount(0)
// A model the gallery does not list says its size is not known.
const unknown = row(page, 'my-finetune-q4').locator('.ledger-size')
await expect(unknown).toContainText('—')
await expect(unknown.locator('span')).toHaveAttribute('title', /size on disk is not known/)
await expect(page.getByTestId('installed-models-storage-summary')).toHaveCount(0)
await expect(page.locator('[role="alert"]')).toHaveCount(0)
})
}
})
test.describe('Installed table - inspector', () => {
test('the inspector shows the size and names the models a model shares files with', async ({ page }) => {
await openInstalled(page)
await row(page, 'qwen3-14b-instruct').click()
await expect(pane(page)).toContainText('16.9 GB')
const shared = pane(page).getByRole('button', { name: 'qwen3-14b-instruct-q4' })
await expect(shared).toBeVisible()
await shared.click()
await expect(row(page, 'qwen3-14b-instruct-q4')).toHaveAttribute('data-selected', 'true')
})
test('a model with a missing file says so in the inspector', async ({ page }) => {
await openInstalled(page)
await row(page, 'my-finetune-q4').click()
await expect(pane(page)).toContainText('Missing files')
await expect(pane(page)).toContainText('1 referenced file missing')
})
})
test.describe('Model page - files on disk', () => {
const usage = '/app/models/qwen3-14b-instruct?tab=usage'
test('the Usage and history tab lists the files with their size and who shares them', async ({ page }) => {
await mockLedger(page, { storage: storageSpec() })
await page.goto(usage)
const files = page.getByTestId('model-page-files')
await expect(files.getByTestId('disk-file-row')).toHaveCount(2)
await expect(files).toContainText('16.9 GB in 2 files')
const weights = files.locator('[data-testid="disk-file-row"]', { hasText: 'qwen3-14b-instruct.gguf' })
await expect(weights).toContainText('15.7 GB')
const tokenizer = files.locator('[data-testid="disk-file-row"]', { hasText: 'qwen3-14b.tokenizer.json' })
await expect(tokenizer).toContainText('1.2 GB')
await expect(tokenizer).toContainText('Also used by qwen3-14b-instruct-q4')
await expect(tokenizer.getByRole('link', { name: 'qwen3-14b-instruct-q4' })).toHaveAttribute('href', /\/app\/models\/qwen3-14b-instruct-q4$/)
await expect(weights).not.toContainText('Also used by')
await expect(page.getByTestId('usage-size')).toContainText('16.9 GB')
await expect(page.getByTestId('usage-size')).toContainText('1.2 GB shared with other models')
// The usage history is still not recorded.
await expect(page.getByTestId('usage-empty')).toContainText('not recorded yet')
})
test('a file the config names that is not on disk is flagged', async ({ page }) => {
await mockLedger(page, { storage: storageSpec() })
await page.goto('/app/models/my-finetune-q4?tab=usage')
const missing = page.locator('[data-testid="disk-file-row"][data-missing="true"]')
await expect(missing).toHaveCount(1)
await expect(missing).toContainText('my-finetune-q4.mmproj.gguf')
await expect(missing).toContainText('missing')
await expect(page.getByTestId('files-missing-banner')).toContainText('not on disk')
// The files that exist come first.
await expect(page.getByTestId('disk-file-row').first()).toHaveAttribute('data-missing', 'false')
})
test('the Overview shows the size on disk and a missing-files badge', async ({ page }) => {
await mockLedger(page, { storage: storageSpec() })
await page.goto('/app/models/my-finetune-q4')
await expect(page.getByTestId('overview-size')).toContainText('4.6 GB')
await expect(page.getByTestId('overview-missing')).toContainText('1 referenced file missing')
await page.getByTestId('overview-size').getByRole('button', { name: 'See the files' }).click()
await expect(page.getByTestId('model-page-files')).toBeVisible()
})
for (const status of [403, 500]) {
test(`with the report unavailable (${status}), the tab says so instead of listing files`, async ({ page }) => {
await mockLedger(page, { storage: storageSpec(), storageStatus: status })
await page.goto(usage)
await expect(page.getByTestId('files-on-disk-none')).toContainText('admins only')
await expect(page.getByTestId('disk-file-row')).toHaveCount(0)
await expect(page.getByTestId('usage-empty')).toBeVisible()
})
}
})
test.describe('Cleanup sheet - what a removal really frees', () => {
test('a model is sized by the files it does not share', async ({ page }) => {
await openSheet(page)
// 9.0 GB of its own, plus the 1.2 GB tokenizer the other build keeps.
const q4 = item(page, 'qwen3-14b-instruct-q4')
await expect(q4.locator('.ledger-cr__size')).toHaveText('9.0 GB')
await expect(q4.getByTestId('cleanup-shared')).toContainText('Shares 1.2 GB with qwen3-14b-instruct.')
await expect(q4.getByTestId('cleanup-shared')).toContainText('stay on disk')
})
test('selecting a model frees its own bytes only, not the shared ones', async ({ page }) => {
await openSheet(page)
await item(page, 'qwen3-14b-instruct-q4').getByRole('checkbox').check()
const effect = page.getByTestId('cleanup-effect')
await expect(effect).toContainText('Frees 9.0 GB.')
await expect(effect).not.toContainText('10.2')
})
test('the review before removal lists the same bytes', async ({ page }) => {
await openSheet(page)
await item(page, 'qwen3-14b-instruct-q4').getByRole('checkbox').check()
await page.getByTestId('cleanup-remove').click()
const dry = page.getByTestId('cleanup-dry-run')
await expect(dry).toContainText('qwen3-14b-instruct-q4')
await expect(dry).toContainText('9.0 GB')
await expect(page.getByRole('alertdialog')).toContainText('This frees 9.0 GB.')
})
test('two models that share a file give it back together, and neither does alone', async ({ page }) => {
const spec = {
files: { 'flux.gguf': 12 * GB, 'sdxl.safetensors': 7 * GB, 'vae.safetensors': 1 * GB },
models: { 'flux.1-schnell': ['flux.gguf', 'vae.safetensors'], 'sdxl-turbo': ['sdxl.safetensors', 'vae.safetensors'] },
}
await openSheet(page, { storage: spec })
const effect = page.getByTestId('cleanup-effect')
await item(page, 'flux.1-schnell').getByRole('checkbox').check()
await expect(effect).toContainText('Frees 12.0 GB.')
await item(page, 'sdxl-turbo').getByRole('checkbox').check()
await expect(effect).toContainText('Frees 20.0 GB.')
await item(page, 'flux.1-schnell').getByRole('checkbox').uncheck()
await expect(effect).toContainText('Frees 7.0 GB.')
})
test('a model whose files are all shared frees nothing alone, and says why', async ({ page }) => {
const spec = {
files: { 'flux.gguf': 12 * GB, 'sdxl.bin': 2 * GB },
models: { 'flux.1-schnell': ['flux.gguf', 'sdxl.bin'], 'sdxl-turbo': ['sdxl.bin'] },
}
await openSheet(page, { storage: spec })
const sdxl = item(page, 'sdxl-turbo')
await expect(sdxl.locator('.ledger-cr__size')).toHaveText('0.0 GB')
await expect(sdxl.getByTestId('cleanup-shared')).toContainText('Shares 2.0 GB with flux.1-schnell')
await sdxl.getByRole('checkbox').check()
await expect(page.getByTestId('cleanup-effect')).toContainText('Frees 0.0 GB.')
await expect(page.getByTestId('cleanup-effect')).not.toContainText('size is unknown')
})
test('the sheet says sizes are read from the disk and leaves out shared files', async ({ page }) => {
await openSheet(page)
await expect(page.getByTestId('cleanup-sizes')).toContainText('what each model uses on disk')
await expect(page.getByTestId('cleanup-sizes')).toContainText('another installed model also uses is left out')
// The usage history is still not recorded, and the sheet still says so.
await expect(page.getByTestId('cleanup-honesty')).toContainText('Usage history is not recorded yet.')
})
test('a model that names files that are not on disk is a finding', async ({ page }) => {
await openSheet(page)
await expect(page.getByTestId('cleanup-missing')).toContainText('Config points at files that are not on disk: my-finetune-q4')
await expect(item(page, 'my-finetune-q4').getByTestId('cleanup-missing-item')).toContainText('Config points at 1 file that is not on disk.')
await expect(item(page, 'bge-m3').getByTestId('cleanup-missing-item')).toHaveCount(0)
})
test('a protected model shares its files too, and the sheet says which', async ({ page }) => {
await openSheet(page)
const group = page.getByTestId('cleanup-protected')
await group.getByRole('button').click()
await expect(group.locator('li', { hasText: 'whisper-large-v3' })).toContainText('Shares 0.2 GB with whisper-medium')
})
for (const status of [500, 403]) {
test(`when the report cannot be read (${status}), sizes are the gallery's and the sheet says so`, async ({ page }) => {
await openSheet(page, { storageStatus: status })
await expect(page.getByTestId('cleanup-sizes')).toContainText('could not be read')
await expect(page.getByTestId('cleanup-shared')).toHaveCount(0)
await expect(page.getByTestId('cleanup-missing')).toHaveCount(0)
await expect(item(page, 'qwen3-14b-instruct-q4').locator('.ledger-cr__size')).toHaveText(`${gb1(sizeOf('qwen3-14b-instruct-q4'))} GB`)
await expect(item(page, 'my-finetune-q4').locator('.ledger-cr__size')).toHaveText('size unknown')
await expect(page.getByTestId('cleanup-honesty')).toContainText('Usage history is not recorded yet.')
})
}
})
+31 -32
View File
@@ -28,53 +28,52 @@ test.describe('Navigation', () => {
await expect(page.locator('.sidebar-nav a.nav-item[href="/app/talk"]')).toBeVisible()
})
test('Build is a single entry that opens the Build console', async ({ page }) => {
test('Build and Operate sit under a Workspace label', async ({ page }) => {
await page.goto('/app')
await expect(page.locator('.sidebar-section-title', { hasText: 'Workspace' })).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toBeVisible()
await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toBeVisible()
})
test('Build is a single entry that opens the Build hub', async ({ page }) => {
await page.goto('/app')
const build = page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })
await expect(build).toBeVisible()
await build.click()
await expect(page.locator('.console-rail .console-rail-header', { hasText: 'Build' })).toBeVisible()
await expect(page).toHaveURL(/\/app\/build$/)
const bar = page.locator('.dk-hubtabs')
await expect(bar.locator('[data-hub-tab="overview"]')).toHaveAttribute('aria-current', 'page')
await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Build' })).toHaveClass(/active/)
})
test('Operate is a single entry that opens the admin console', async ({ page }) => {
test('Operate is a single entry that opens the Operate hub', async ({ page }) => {
await page.goto('/app')
const operate = page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })
await expect(operate).toBeVisible()
await operate.click()
await expect(page.locator('.console-rail .console-rail-header', { hasText: 'Operate' })).toBeVisible()
await expect(page).toHaveURL(/\/app\/operate$/)
await expect(page.locator('.dk-hubtabs [data-hub-tab="status"]')).toHaveAttribute('aria-current', 'page')
})
test('Build console groups Automation, Training and Recognition', async ({ page }) => {
test('Build hub lists its tools as tabs', async ({ page }) => {
await page.goto('/app/agents')
const rail = page.locator('.console-rail')
await expect(rail).toBeVisible()
for (const group of ['Automation', 'Training', 'Recognition']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
const bar = page.locator('.dk-hubtabs')
await expect(bar).toBeVisible()
for (const id of ['overview', 'agents', 'skills', 'memory', 'jobs', 'fine-tune', 'quantize', 'import', 'voices', 'faces']) {
await expect(bar.locator(`[data-hub-tab="${id}"]`)).toBeVisible()
}
// Recognition (Faces/Voices) and Training (Fine-tune/Quantize) live here now.
await expect(rail.locator('a.nav-item[href="/app/fine-tune"]')).toBeVisible()
await expect(rail.locator('a.nav-item[href="/app/face"]')).toBeVisible()
await expect(bar.locator('a[href="/app/fine-tune"]')).toBeVisible()
await expect(bar.locator('a[href="/app/face"]')).toBeVisible()
await expect(bar.locator('[data-hub-tab="agents"]')).toHaveAttribute('aria-current', 'page')
})
test('desktop console rail collapses to accessible icons and persists globally', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await page.goto('/app/backends')
const rail = page.locator('.console-rail')
const collapse = rail.getByRole('button', { name: 'Collapse Operate navigation' })
await expect(collapse).toBeVisible()
await collapse.click()
await expect(rail).toHaveClass(/console-rail--collapsed/)
await expect(rail).toHaveCSS('width', '60px')
await expect(rail.getByRole('link', { name: 'Backends', exact: true })).toHaveClass(/active/)
await expect(rail.getByRole('link', { name: 'Overview', exact: true })).toHaveAttribute('title', 'Overview')
await expect.poll(() => page.evaluate(() => localStorage.getItem('localai_console_rail_collapsed'))).toBe('true')
await page.goto('/app/agents')
const buildRail = page.locator('.console-rail')
await expect(buildRail).toHaveClass(/console-rail--collapsed/)
await expect(buildRail.getByRole('button', { name: 'Expand Build navigation' })).toBeVisible()
await page.reload()
await expect(page.locator('.console-rail')).toHaveClass(/console-rail--collapsed/)
test('the Build overview lists the tools with a line each', async ({ page }) => {
await page.goto('/app/build')
const list = page.getByRole('list', { name: 'Build tools' })
// Each tool is a row with its sentence and an Open link.
const row = (path) => list.locator('li.bt-tool', { has: page.locator(`a[href="${path}"]`) })
await expect(row('/app/agents')).toContainText('Create and run agents')
await expect(list.locator('a[href="/app/fine-tune"]')).toBeVisible()
})
})
@@ -0,0 +1,69 @@
import { test, expect } from './coverage-fixtures.js'
// No coloured strip on the left edge of a tile, card, row or message. A rail
// can be drawn four ways, and each is checked from computed style:
// 1. a left border thicker than 1px,
// 2. an inset box-shadow offset sideways with no blur (a "shadow" bar),
// 3. a ::before or ::after that is a narrow absolute strip on the left edge,
// 4. a narrow, tall child pinned to the left edge of its parent.
// Selection and status use a surface step, a check or a dot instead.
const ROUTES = [
'/app', '/app/chat', '/app/models', '/app/models?view=installed', '/app/studio',
'/app/talk', '/app/agents', '/app/skills', '/app/collections', '/app/agent-jobs',
'/app/backends', '/app/activity', '/app/operate', '/app/build', '/app/settings',
'/app/traces', '/app/usage', '/app/nodes', '/app/p2p', '/app/voice-library',
'/app/account',
]
test('no element on the main routes carries a left accent rail', async ({ page }) => {
test.setTimeout(ROUTES.length * 8_000)
const findings = []
for (const theme of ['light', 'dark']) {
await page.addInitScript((t) => localStorage.setItem('localai-theme', t), theme)
for (const route of ROUTES) {
await page.goto(route)
await page.waitForTimeout(400)
const found = await page.evaluate(() => {
const out = []
const name = (el) => `${el.tagName.toLowerCase()}.${String(el.className).split(' ').slice(0, 2).join('.')}`
const strip = (cs, parent, el, which) => {
if (cs.content === 'none' || cs.display === 'none' || cs.position !== 'absolute') return false
const w = parseFloat(cs.width)
const h = parseFloat(cs.height)
if (!(w >= 2 && w <= 4)) return false
const p = parent.getBoundingClientRect()
if (!(h >= p.height * 0.6 || cs.height === 'auto')) return false
return cs.left === '0px' || cs.insetInlineStart === '0px'
}
for (const el of document.querySelectorAll('body *')) {
const r = el.getBoundingClientRect()
if (r.width === 0 || r.height === 0) continue
const cs = getComputedStyle(el)
if (el.closest('.cm-editor')) continue
if (cs.borderLeftStyle !== 'none' && cs.borderLeftStyle !== 'dashed' && parseFloat(cs.borderLeftWidth) > 1 &&
cs.borderTopWidth !== cs.borderLeftWidth) { // a ring (spinner) has all four sides
out.push(`border-left ${cs.borderLeftWidth} on ${name(el)}`)
}
for (const m of cs.boxShadow.matchAll(/(-?\d+(?:\.\d+)?)px (-?\d+(?:\.\d+)?)px (\d+(?:\.\d+)?)px (-?\d+(?:\.\d+)?)px inset|inset (-?\d+(?:\.\d+)?)px (-?\d+(?:\.\d+)?)px (\d+(?:\.\d+)?)px (-?\d+(?:\.\d+)?)px/g)) {
const nums = m.slice(1).filter((v) => v !== undefined).map(Number)
const [x, y, blur] = nums
if (Math.abs(x) >= 2 && y === 0 && blur === 0) out.push(`inset side shadow ${m[0]} on ${name(el)}`)
}
for (const which of ['::before', '::after']) {
if (strip(getComputedStyle(el, which), el, el, which)) out.push(`${which} strip on ${name(el)}`)
}
// A narrow tall child pinned to the left edge of a card-like parent.
if (el.parentElement && cs.position === 'absolute' && el.children.length === 0 && !el.textContent.trim()) {
const p = el.parentElement.getBoundingClientRect()
if (r.width >= 2 && r.width <= 4 && r.height >= p.height * 0.6 && Math.abs(r.left - p.left) < 2) {
out.push(`left strip child ${name(el)}`)
}
}
}
return out
})
for (const f of found) findings.push(`${theme} ${route}: ${f}`)
}
}
expect([...new Set(findings)]).toEqual([])
})
+175 -22
View File
@@ -1,4 +1,5 @@
import { test, expect } from './coverage-fixtures.js'
import { mockSwarm, clusterNodes, clusterReplicas } from './swarm-fixtures.js'
const ID = 'n1'
async function mockNode(page, overrides = {}) {
@@ -11,47 +12,69 @@ async function mockNode(page, overrides = {}) {
}
test.describe('Node detail page', () => {
test('renders sections for a node', async ({ page }) => {
test('renders the header, the vitals and each tab for a node', async ({ page }) => {
await mockNode(page)
await page.goto(`/app/nodes/${ID}`)
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible()
await expect(page.getByText('llama-3.3')).toBeVisible()
await expect(page.getByText('llama-cpp')).toBeVisible()
await expect(page.getByText('env=prod')).toBeVisible()
await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('25.0% of 16 cores')).toBeVisible()
await expect(page.getByText('2.50 load (1m)')).toBeVisible()
await expect(page.getByText('37.3 GB / 93.1 GB')).toBeVisible()
await expect(page.getByRole('link', { name: 'Nodes' })).toHaveAttribute('href', '/app/nodes')
await expect(page.getByRole('link', { name: 'All nodes' })).toHaveAttribute('href', '/app/nodes')
await expect(page.getByRole('region', { name: 'Node resources' })).toBeVisible()
await expect(page.getByRole('region', { name: 'Running models' })).toHaveClass(/fleet-workbench/)
await expect(page.getByRole('region', { name: 'Installed backends' })).toHaveClass(/fleet-workbench/)
// Models is the first tab.
await expect(page.getByRole('tab', { name: /Models/ })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('region', { name: 'Running models' })).toContainText('llama-3.3')
await page.getByRole('tab', { name: /Backends/ }).click()
await expect(page.getByRole('region', { name: 'Installed backends' })).toContainText('llama-cpp')
await expect(page).toHaveURL(/tab=backends$/)
await page.getByRole('tab', { name: 'Logs' }).click()
await expect(page.getByRole('link', { name: 'llama-3.3' })).toHaveAttribute('href', /node-backend-logs\/n1\/llama-3\.3%230$/)
await page.getByRole('tab', { name: 'Capacity and labels' }).click()
await expect(page.getByText('env=prod')).toBeVisible()
})
test('opens straight onto a tab from the address, and moves between tabs with the arrow keys', async ({ page }) => {
await mockNode(page)
await page.goto(`/app/nodes/${ID}?tab=config`)
await expect(page.getByRole('tab', { name: 'Capacity and labels' })).toHaveAttribute('aria-selected', 'true', { timeout: 15_000 })
await page.getByRole('tab', { name: 'Capacity and labels' }).focus()
await page.keyboard.press('ArrowRight')
await expect(page.getByRole('tab', { name: /Models/ })).toHaveAttribute('aria-selected', 'true')
await expect(page.getByRole('tab', { name: /Models/ })).toBeFocused()
await expect(page).not.toHaveURL(/tab=/)
})
test('opens the logs of a replica from its row menu', async ({ page }) => {
await mockNode(page)
await page.goto(`/app/nodes/${ID}`)
await page.getByRole('button', { name: 'Actions for llama-3.3 replica 1' }).click()
await page.getByRole('menuitem', { name: 'View logs' }).click()
await expect(page).toHaveURL(/\/app\/node-backend-logs\/n1\/llama-3.3%230$/)
})
test('is reachable by clicking a roster panel', async ({ page }) => {
test('is reached by clicking a node in the roster', async ({ page }) => {
await page.route('**/api/nodes', r => r.fulfill({ status: 200, contentType: 'application/json',
body: JSON.stringify([{ id: ID, name: 'alpha', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy' }]) }))
await mockNode(page)
await page.goto('/app/nodes')
await page.getByRole('button', { name: 'Inspect alpha' }).click()
await page.getByRole('link', { name: 'Open full node details' }).click()
await page.getByRole('link', { name: 'alpha' }).click()
await expect(page).toHaveURL(new RegExp(`/app/nodes/${ID}$`))
await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible()
})
for (const [status, action] of [['healthy', 'Drain'], ['draining', 'Resume'], ['unhealthy', null], ['offline', null], ['unknown', null]]) {
test(`shows only the accepted lifecycle action for ${status} nodes`, async ({ page }) => {
await mockNode(page, { status })
await page.goto(`/app/nodes/${ID}`)
await expect(page.locator('.page-title').first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('button', { name: /Approve/ })).toHaveCount(0)
await expect(page.getByRole('button', { name: /Drain/ })).toHaveCount(action === 'Drain' ? 1 : 0)
await expect(page.getByRole('button', { name: /Resume/ })).toHaveCount(action === 'Resume' ? 1 : 0)
await page.getByRole('button', { name: 'Actions for alpha' }).click()
await expect(page.getByRole('menuitem', { name: 'Remove node…' })).toBeVisible()
await expect(page.getByRole('button', { name: /Remove/ })).toBeVisible()
})
}
@@ -69,12 +92,12 @@ test.describe('Node detail page', () => {
})
await page.goto(`/app/nodes/${ID}`)
await expect(page.getByTestId('pending-banner')).toContainText('It gets no requests until you approve it')
await page.getByRole('button', { name: 'Approve' }).click()
await expect.poll(() => approvalRequests).toBe(1)
await expect(page.getByText('Node approved')).toBeVisible()
await expect(page.getByRole('button', { name: 'Drain' })).toBeVisible()
await page.getByRole('button', { name: 'Actions for alpha' }).click()
await expect(page.getByRole('menuitem', { name: 'Remove node…' })).toBeVisible()
await expect(page.getByRole('button', { name: /Drain/ })).toBeVisible()
await expect(page.getByTestId('pending-banner')).toHaveCount(0)
})
test('renders valid totals with missing available capacity as No data', async ({ page }) => {
@@ -84,10 +107,11 @@ test.describe('Node detail page', () => {
total_disk: 100e9, available_disk: undefined,
})
await page.goto(`/app/nodes/${ID}`)
await expect(page.locator('.node-detail__metrics')).toContainText('VRAM')
await expect(page.locator('.node-detail__metrics')).toContainText('RAM')
await expect(page.locator('.node-detail__metrics')).toContainText('Models disk free')
await expect(page.locator('.node-detail__metrics').getByText('No data')).toHaveCount(3)
const vitals = page.getByRole('region', { name: 'Node resources' })
await expect(vitals).toContainText('VRAM')
await expect(vitals).toContainText('RAM')
await expect(vitals).toContainText('Models disk free')
await expect(vitals.getByText('No data')).toHaveCount(3)
})
test('keeps model operations visible on a narrow screen', async ({ page }) => {
@@ -99,6 +123,7 @@ test.describe('Node detail page', () => {
await expect(action).toBeVisible()
const box = await action.boundingBox()
expect(box.x + box.width).toBeLessThanOrEqual(390)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
})
test('distinguishes a load failure from a missing node and retries in place', async ({ page }) => {
@@ -119,4 +144,132 @@ test.describe('Node detail page', () => {
await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible()
expect(attempts).toBe(2)
})
test('says a missing node is missing', async ({ page }) => {
await page.route(`**/api/nodes/${ID}`, route => route.fulfill({ status: 404, contentType: 'application/json', body: '{"error":"node not found"}' }))
await page.goto(`/app/nodes/${ID}`)
await expect(page.getByRole('heading', { name: 'Node not found' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('link', { name: 'All nodes' })).toBeVisible()
})
test('still shows the node when its backends cannot be read', async ({ page }) => {
await mockNode(page)
await page.route(`**/api/nodes/${ID}/backends`, r => r.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nats unavailable"}' }))
await page.goto(`/app/nodes/${ID}?tab=backends`)
await expect(page.getByRole('heading', { name: 'alpha' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('alert')).toContainText('Could not read the backends on this node')
await page.getByRole('tab', { name: /Models/ }).click()
await expect(page.getByRole('region', { name: 'Running models' })).toContainText('llama-3.3')
})
})
test.describe('Node detail: drain, resume and remove', () => {
test('previews a drain from the loaded replicas and the rules, then drains', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/nodes/n-gpu1')
await expect(page.getByRole('heading', { name: 'gpu-box-1' })).toBeVisible({ timeout: 15_000 })
// The section is open on a healthy node and says it is a preview.
const section = page.getByRole('region', { name: 'What happens if I drain this node' })
await expect(section).toContainText('Preview')
await expect(section).toContainText('3 requests in flight finish first')
await expect(section.getByTestId('leave-list')).toContainText('is unavailable until this node returns')
await expect(section).toContainText('The server does not compute it')
await page.getByRole('button', { name: /Drain/ }).click()
const dialog = page.getByRole('dialog', { name: 'Drain gpu-box-1?' })
await expect(dialog).toBeVisible()
await expect(dialog).toContainText('Preview')
await expect(dialog.getByTestId('leave-list')).toContainText('qwen3-8b-instruct')
await dialog.getByRole('button', { name: 'Drain node' }).click()
await expect.poll(() => log.filter(entry => entry.path === '/api/nodes/n-gpu1/drain').length).toBe(1)
await expect(page.getByText('Node set to draining')).toBeVisible()
await expect(dialog).toHaveCount(0)
})
test('says a model stays up when another healthy node has it, and which node', async ({ page }) => {
const nodes = clusterNodes().map(node => (node.id === 'n-gpu2' ? { ...node, status: 'healthy' } : node))
await mockSwarm(page, { nodes })
await page.goto('/app/nodes/n-gpu1')
await expect(page.getByTestId('leave-list')).toContainText('qwen3-8b-instruct stays available on gpu-box-2', { timeout: 15_000 })
})
test('closing the drain dialog sends nothing', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/nodes/n-gpu1')
await page.getByRole('button', { name: /Drain/ }).click()
const dialog = page.getByRole('dialog', { name: 'Drain gpu-box-1?' })
await expect(dialog).toBeVisible()
await page.keyboard.press('Escape')
await expect(dialog).toHaveCount(0)
await expect(page.getByRole('button', { name: /Drain/ })).toBeFocused()
expect(log.filter(entry => entry.method === 'POST')).toEqual([])
})
test('says so plainly when the preview data cannot be read', async ({ page }) => {
await mockSwarm(page, { replicas: null })
await page.goto('/app/nodes/n-gpu1')
await expect(page.getByRole('region', { name: 'What happens if I drain this node' })).toContainText('cannot be worked out now', { timeout: 15_000 })
})
test('resumes a draining node', async ({ page }) => {
const nodes = clusterNodes().map(node => (node.id === 'n-gpu1' ? { ...node, status: 'draining' } : node))
const log = await mockSwarm(page, { nodes })
await page.goto('/app/nodes/n-gpu1')
await expect(page.getByTestId('draining-banner')).toContainText('It takes no new requests')
await page.getByRole('button', { name: /Resume/ }).click()
await expect.poll(() => log.filter(entry => entry.path === '/api/nodes/n-gpu1/resume').length).toBe(1)
await expect(page.getByText('Node resumed')).toBeVisible()
})
test('removes a node only after its name is typed', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/nodes/n-gpu1')
await page.getByRole('button', { name: /Remove/ }).click()
const dialog = page.getByRole('alertdialog', { name: 'Remove gpu-box-1?' })
await expect(dialog).toContainText('2 loaded replicas on this node stop serving')
const confirm = dialog.getByRole('button', { name: 'Remove node' })
await expect(confirm).toBeDisabled()
const field = dialog.getByLabel('Type gpu-box-1 to confirm')
await field.fill('gpu-box')
await expect(confirm).toBeDisabled()
await field.fill('gpu-box-1')
await expect(confirm).toBeEnabled()
await confirm.click()
await expect.poll(() => log.filter(entry => entry.method === 'DELETE' && entry.path === '/api/nodes/n-gpu1').length).toBe(1)
await expect(page).toHaveURL(/\/app\/nodes$/)
})
test('cancelling the remove dialog deletes nothing', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/nodes/n-gpu1')
await page.getByRole('button', { name: /Remove/ }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('alertdialog')).toHaveCount(0)
expect(log.filter(entry => entry.method === 'DELETE')).toEqual([])
})
test('a node that stopped answering says so, shows its last figures and offers no drain', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/nodes/n-gpu2')
const banner = page.getByTestId('down-banner')
await expect(banner).toContainText('Last heartbeat 4m ago', { timeout: 15_000 })
await expect(banner.getByRole('link', { name: 'See failover' })).toHaveAttribute('href', '/app/failover')
await expect(page.getByRole('button', { name: /Drain|Resume|Approve/ })).toHaveCount(0)
await expect(page.getByRole('region', { name: 'What the loss of this node means' })).toContainText('flux-dev')
await expect(page.getByText('Not answering').first()).toBeVisible()
})
test('unloads a model after asking, and warns when requests are running', async ({ page }) => {
const log = await mockSwarm(page, {
nodeModels: { 'n-gpu1': [{ node_id: 'n-gpu1', model_name: 'qwen3-8b-instruct', state: 'loaded', in_flight: 2, replica_index: 0 }] },
})
await page.goto('/app/nodes/n-gpu1')
await page.getByRole('button', { name: 'Actions for qwen3-8b-instruct replica 1' }).click()
await page.getByRole('menuitem', { name: 'Unload model…' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('2 request(s) in flight. Unloading interrupts them.')
await dialog.getByRole('button', { name: 'Unload' }).click()
await expect.poll(() => log.find(entry => entry.path === '/api/nodes/n-gpu1/models/unload')?.body).toEqual({ model_name: 'qwen3-8b-instruct' })
})
})
@@ -1,7 +1,11 @@
import { test, expect } from './coverage-fixtures.js'
// The Swarm Nodes page: a sortable table of the cluster, a map of the same
// nodes, and the models running across them. Every call is stubbed, so the
// suite needs no workers, no NATS and no database.
const baseNodes = [
{ id: 'n1', name: 'atlas', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', labels: { zone: 'east' }, total_vram: 100, available_vram: 40, total_ram: 200, available_ram: 100, total_disk: 1000, available_disk: 600, cpu_logical_cores: 8, cpu_usage_percent: 25, cpu_load_1: 1.5, model_count: 3, in_flight_count: 2, last_heartbeat: '2026-09-14T00:00:00Z' },
{ id: 'n1', name: 'atlas', node_type: 'backend', address: '10.0.0.1:50051', status: 'healthy', labels: { zone: 'east' }, total_vram: 100, available_vram: 40, total_ram: 200, available_ram: 100, total_disk: 1000, available_disk: 600, cpu_logical_cores: 8, cpu_usage_percent: 25, cpu_load_1: 1.5, model_count: 3, in_flight_count: 2, last_heartbeat: '2026-09-14T00:00:00Z', version: 'v3.9.0' },
{ id: 'n2', name: 'borealis', node_type: 'backend', address: '10.0.0.2:50051', status: 'pending', labels: { zone: 'west' }, total_vram: 100, available_vram: 10, total_ram: 200, available_ram: 10, total_disk: 1000, available_disk: 100, cpu_logical_cores: 16, cpu_usage_percent: 50, cpu_load_1: 4, model_count: 1, in_flight_count: 0 },
{ id: 'n3', name: 'legacy', node_type: 'agent', address: '10.0.0.3:50051', status: 'offline', labels: {} },
]
@@ -35,7 +39,9 @@ const baseModels = [
{ id: 'r4', node_id: 'missing', model_name: 'Whisper large v3', replica_index: 0, address: '10.0.0.9:50101', state: 'loaded', in_flight: 0, backend_type: 'whisper', last_used: '2026-09-14T09:00:00Z' },
]
test.describe('Nodes fleet dashboard', () => {
const rows = page => page.locator('tbody tr[data-row]')
test.describe('Swarm nodes: navigation', () => {
test('uses the standard Operate navigation at a desktop viewport', async ({ page }) => {
await mockFullOperateNavigation(page)
await mockNodes(page, [baseNodes[0]])
@@ -45,14 +51,29 @@ test.describe('Nodes fleet dashboard', () => {
await expect(primaryOperate).toBeVisible({ timeout: 15_000 })
await expect(primaryOperate).toHaveClass(/active/)
const rail = page.locator('.console-layout > .console-rail')
await expect(rail).toBeVisible()
await expect(rail.locator('a.nav-item')).toHaveCount(14)
await expect(rail.locator('a[href="/app/nodes"]')).toHaveClass(/active/)
await expect(rail.locator('a[href$="/swagger/index.html"]')).toHaveAttribute('target', '_blank')
// Distributed mode on: Swarm replaces This machine, and owns the Nodes route.
const bar = page.locator('.hub-layout > .hub-bar .dk-hubtabs')
await expect(bar).toBeVisible()
await expect(bar.locator('[data-hub-tab="swarm"]')).toHaveAttribute('aria-current', 'page')
await expect(bar.locator('[data-hub-tab="machine"]')).toHaveCount(0)
await expect(bar.locator('a[href$="/swagger/index.html"]')).toHaveAttribute('target', '_blank')
const sub = page.locator('.hub-subnav')
for (const name of ['Nodes', 'Placement rules', 'Failover', 'P2P']) {
await expect(sub.getByRole('link', { name })).toBeVisible()
}
await expect(sub.getByRole('link', { name: 'Nodes' })).toHaveAttribute('aria-current', 'page')
})
test('uses the standard collapsible Operate rail on mobile', async ({ page }) => {
test('keeps the Swarm tab on a node detail page and on the add page', async ({ page }) => {
await mockFullOperateNavigation(page)
await mockNodes(page, [baseNodes[0]])
for (const path of [`/app/nodes/${baseNodes[0].id}`, '/app/nodes/add']) {
await page.goto(path)
await expect(page.locator('.dk-hubtabs [data-hub-tab="swarm"]')).toHaveAttribute('aria-current', 'page')
}
})
test('uses the Operate tab bar on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockFullOperateNavigation(page)
await mockNodes(page, [baseNodes[0]])
@@ -62,41 +83,113 @@ test.describe('Nodes fleet dashboard', () => {
await expect(page.locator('.sidebar-nav a.nav-item', { hasText: 'Operate' })).toBeVisible()
await page.getByRole('button', { name: 'Close menu' }).click()
const rail = page.locator('.console-layout > .console-rail')
await expect(rail).toBeVisible()
await expect(rail.locator('.console-rail-groups')).toBeHidden()
await rail.getByRole('button', { name: 'Expand Operate navigation' }).click()
await expect(rail.locator('.console-rail-groups')).toBeVisible()
await expect(rail.locator('a.nav-item')).toHaveCount(14)
const bar = page.locator('.hub-layout > .hub-bar .dk-hubtabs')
await expect(bar).toBeVisible()
expect((await bar.boundingBox()).width).toBeLessThanOrEqual(390)
await bar.locator('[data-hub-tab="settings"]').scrollIntoViewIfNeeded()
await expect(bar.locator('[data-hub-tab="settings"]')).toBeInViewport()
})
})
test('shows aggregate health, capacity, attention filtering, search, sorting, and grouping', async ({ page }) => {
test.describe('Swarm nodes: the table', () => {
test('lists every node with its state in words and opens a node from its row', async ({ page }) => {
await mockNodes(page)
await page.goto('/app/nodes')
await expect(page.getByLabel('Fleet health summary')).toContainText('3 nodes', { timeout: 15_000 })
await expect(page.getByLabel('VRAM capacity')).toContainText('150 B / 200 B')
await expect(page.getByLabel('CPU capacity')).toContainText('10 busy / 24 cores')
await expect(page.getByLabel('Models disk capacity')).toContainText('1.3 KB / 2 KB')
await expect(page.getByRole('button', { name: /Needs attention.*2/ })).toBeVisible()
await page.getByRole('button', { name: /Low VRAM/ }).click()
const table = page.getByRole('table', { name: /Nodes with their state/ })
await expect(table).toBeVisible({ timeout: 15_000 })
await expect(rows(page)).toHaveCount(3)
await expect(page.getByRole('row', { name: /atlas/ })).toContainText('Healthy')
await expect(page.getByRole('row', { name: /borealis/ })).toContainText('Waiting for approval')
await expect(page.getByRole('row', { name: /legacy/ })).toContainText('Offline')
await expect(page.getByRole('row', { name: /atlas/ })).toContainText('v3.9.0')
await expect(page.getByRole('link', { name: 'atlas' })).toHaveAttribute('href', '/app/nodes/n1')
await page.getByRole('row', { name: /atlas/ }).locator('td').nth(2).click()
await expect(page).toHaveURL(/\/app\/nodes\/n1$/)
})
test('filters to what needs attention, then by reason, then back', async ({ page }) => {
await mockNodes(page)
await page.goto('/app/nodes')
const needs = page.getByRole('button', { name: /^Needs attention/ })
await expect(needs).toContainText('2', { timeout: 15_000 })
await expect(page.getByRole('button', { name: /^All/ })).toHaveAttribute('aria-pressed', 'true')
await needs.click()
await expect(needs).toHaveAttribute('aria-pressed', 'true')
await expect(page.getByRole('row', { name: /borealis/ })).toBeVisible()
await expect(page.getByRole('row', { name: /legacy/ })).toBeVisible()
await expect(page.getByRole('row', { name: /atlas/ })).toHaveCount(0)
await page.getByRole('button', { name: /Low VRAM/ }).click()
// Only the reasons that apply are offered.
await expect(page.getByRole('button', { name: /^Low models disk/ })).toBeVisible()
await expect(page.getByRole('button', { name: /^Not answering/ })).toBeVisible()
await page.getByRole('button', { name: /^Not answering/ }).click()
await expect(page.getByRole('row', { name: /legacy/ })).toBeVisible()
await expect(page.getByRole('row', { name: /borealis/ })).toHaveCount(0)
await page.getByRole('button', { name: /^All/ }).click()
await expect(rows(page)).toHaveCount(3)
})
test('says a low reading in the row and never flags a node that does not report', async ({ page }) => {
await mockNodes(page)
await page.goto('/app/nodes')
await expect(page.getByRole('row', { name: /borealis/ })).toContainText('Low GPU memory', { timeout: 15_000 })
await expect(page.getByRole('row', { name: /atlas/ })).not.toContainText('Low')
await expect(page.getByRole('row', { name: /legacy/ })).not.toContainText('Low')
})
test('searches, filters by state and type, sorts and groups', async ({ page }) => {
await mockNodes(page)
await page.goto('/app/nodes')
await expect(rows(page)).toHaveCount(3, { timeout: 15_000 })
await page.getByRole('searchbox', { name: 'Search nodes' }).fill('legacy')
await expect(rows(page)).toHaveCount(1)
await expect(page.getByRole('row', { name: /legacy/ })).toBeVisible()
await page.getByRole('searchbox', { name: 'Search nodes' }).fill('')
await page.getByRole('button', { name: /Sort by node/ }).click()
await expect(page.locator('tbody tr').first()).toContainText('legacy')
await page.getByLabel('Filter state').selectOption('pending')
await expect(rows(page)).toHaveCount(1)
await expect(page.getByRole('row', { name: /borealis/ })).toBeVisible()
await page.getByLabel('Filter state').selectOption('')
await page.getByLabel('Filter type').selectOption('agent')
await expect(rows(page)).toHaveCount(1)
await page.getByLabel('Filter type').selectOption('')
const nodeHeader = page.getByRole('columnheader', { name: 'Node', exact: true })
await expect(nodeHeader).toHaveAttribute('aria-sort', 'ascending')
await nodeHeader.getByRole('button').click()
await expect(nodeHeader).toHaveAttribute('aria-sort', 'descending')
await expect(rows(page).first()).toContainText('legacy')
await page.getByRole('columnheader', { name: 'State' }).getByRole('button').click()
await expect(page.getByRole('columnheader', { name: 'State' })).toHaveAttribute('aria-sort', 'ascending')
await expect(nodeHeader).not.toHaveAttribute('aria-sort', /./)
await page.getByLabel('Group nodes').selectOption('label:zone')
await expect(page.getByText('Unlabelled', { exact: true })).toBeVisible()
await expect(page.getByText('east', { exact: true })).toBeVisible()
})
test('switches between comfortable and compact rows and remembers the choice', async ({ page }) => {
await mockNodes(page)
await page.goto('/app/nodes')
const table = page.getByRole('table', { name: /Nodes with their state/ })
await expect(table).not.toHaveClass(/dk-table--compact/, { timeout: 15_000 })
await page.getByRole('radio', { name: 'Compact' }).click()
await expect(table).toHaveClass(/dk-table--compact/)
await expect(page.getByRole('radio', { name: 'Compact' })).toHaveAttribute('aria-checked', 'true')
await page.reload()
await expect(page.getByRole('table', { name: /Nodes with their state/ })).toHaveClass(/dk-table--compact/, { timeout: 15_000 })
})
test('mounts only 50 rows and clamps pagination for a 1,000-node fleet', async ({ page }) => {
const nodes = Array.from({ length: 1000 }, (_, index) => ({ id: `node-${index}`, name: `worker-${String(index).padStart(4, '0')}`, node_type: 'backend', address: `10.0.${Math.floor(index / 255)}.${index % 255}:50051`, status: 'healthy' }))
await mockNodes(page, nodes)
await page.goto('/app/nodes')
await expect(page.locator('tbody tr')).toHaveCount(50, { timeout: 15_000 })
await expect(rows(page)).toHaveCount(50, { timeout: 15_000 })
await expect(page.getByText('Page 1 of 20')).toBeVisible()
await page.getByRole('button', { name: 'Next page' }).click()
await expect(page.getByText('Page 2 of 20')).toBeVisible()
@@ -104,6 +197,47 @@ test.describe('Nodes fleet dashboard', () => {
await expect(page.getByText('Page 1 of 1')).toBeVisible()
})
test('keeps incomplete readings unknown', async ({ page }) => {
await mockNodes(page, [{
id: 'incomplete', name: 'incomplete-capacity', node_type: 'backend', status: 'healthy',
total_vram: 100, total_ram: 200, total_disk: 300,
}])
await page.goto('/app/nodes')
const row = page.getByRole('row', { name: /incomplete-capacity/ })
await expect(row.getByText('No reading')).toHaveCount(1, { timeout: 15_000 })
await expect(row).not.toContainText('Low')
})
test('keeps pending approval visible and approves from the row', async ({ page }) => {
await mockNodes(page, [baseNodes[1]])
let approvals = 0
await page.route('**/api/nodes/n2/approve', route => {
approvals += 1
return route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
})
await page.goto('/app/nodes')
await page.getByRole('button', { name: 'Approve borealis' }).click()
await expect.poll(() => approvals).toBe(1)
await expect(page.getByText('Node approved')).toBeVisible()
})
test('selects with the keyboard without opening the node, and shows the partial-page state', async ({ page }) => {
await mockNodes(page)
await page.goto('/app/nodes')
const checkbox = page.getByRole('checkbox', { name: 'Select atlas' })
await checkbox.focus()
await checkbox.press('Space')
await expect(checkbox).toBeChecked()
await expect(page).toHaveURL(/\/app\/nodes$/)
await expect(page.getByRole('row', { name: /atlas/ })).toHaveAttribute('data-selected', 'true')
const selectPage = page.getByRole('checkbox', { name: 'Select page' })
await expect.poll(() => selectPage.evaluate(input => input.indeterminate)).toBe(true)
})
})
test.describe('Swarm nodes: bulk actions', () => {
test('filters bulk actions by lifecycle state, reports skipped nodes, and prevents overlapping batches', async ({ page }) => {
const nodes = Array.from({ length: 12 }, (_, index) => ({
id: `n${index}`,
@@ -150,7 +284,7 @@ test.describe('Nodes fleet dashboard', () => {
expect(resumeRequests).not.toContain('n9')
})
test('disables bulk controls and remove confirmation while removal is running', async ({ page }) => {
test('disables bulk controls and the remove confirmation while removal is running', async ({ page }) => {
await mockNodes(page, [baseNodes[0]])
let finishRemove
await page.route('**/api/nodes/n1', async route => {
@@ -169,252 +303,122 @@ test.describe('Nodes fleet dashboard', () => {
await expect(page.getByText(/1 succeeded, 0 failed, 0 skipped/)).toBeVisible()
})
test('fetches backends only when an inspector opens and shows unknown legacy metrics', async ({ page }) => {
await mockNodes(page)
let backendRequests = 0
await page.route('**/api/nodes/n3/backends', route => {
backendRequests += 1
return route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"tool-runner"}]' })
test('updates a backend on the nodes that drifted, or on the selected ones', async ({ page }) => {
const nodes = [
{ ...baseNodes[0], id: 'a', name: 'alpha' },
{ ...baseNodes[0], id: 'b', name: 'beta' },
{ ...baseNodes[0], id: 'c', name: 'gamma', status: 'pending' },
{ ...baseNodes[2], id: 'd', name: 'agent' },
]
await mockNodes(page, nodes)
await page.route('**/api/backends/upgrades', route => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ 'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7', node_drift: [{ node_id: 'a' }, { node_id: 'b' }, { node_id: 'c' }] } }),
}))
const posts = []
await page.route('**/api/nodes/*/backends/upgrade', route => {
posts.push({ id: route.request().url().split('/').at(-3), body: route.request().postDataJSON() })
return route.fulfill({ status: 202, contentType: 'application/json', body: '{}' })
})
await page.goto('/app/nodes')
expect(backendRequests).toBe(0)
await page.getByRole('button', { name: 'Inspect legacy' }).click()
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector).toContainText('legacy')
await expect(inspector).toContainText('No data')
await expect(inspector).toContainText('1 backend')
expect(backendRequests).toBe(1)
await expect(page.getByRole('link', { name: 'Open full node details' })).toHaveAttribute('href', '/app/nodes/n3')
// The pending node and the agent worker take no backend update.
const button = page.getByRole('button', { name: 'Update llama-cpp on 2 nodes' })
await expect(button).toBeVisible({ timeout: 15_000 })
await page.getByRole('checkbox', { name: 'Select alpha' }).check()
await expect(page.getByRole('button', { name: 'Update llama-cpp on 1 node' })).toBeVisible()
await page.getByRole('button', { name: 'Clear selection' }).click()
await button.click()
await expect.poll(() => posts.length).toBe(2)
expect(posts.map(post => post.id).sort()).toEqual(['a', 'b'])
expect(posts.every(post => post.body.backend === 'llama-cpp')).toBe(true)
await expect(page.getByText('Updating backends on 2 nodes')).toBeVisible()
})
})
test('keeps pending approval visible', async ({ page }) => {
await mockNodes(page, [baseNodes[1]])
await page.route('**/api/nodes/n2/approve', route => route.fulfill({ status: 200, contentType: 'application/json', body: '{}' }))
await page.goto('/app/nodes')
await expect(page.getByRole('button', { name: 'Approve borealis' })).toBeVisible({ timeout: 15_000 })
})
test('shows inspector lifecycle controls only for server-accepted states', async ({ page }) => {
const statuses = ['healthy', 'draining', 'pending', 'unhealthy', 'offline', 'unknown']
await mockNodes(page, statuses.map((status, index) => ({
id: `state-${index}`,
name: `node-${status}`,
node_type: 'backend',
status,
})))
await page.route('**/api/nodes/*/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.goto('/app/nodes')
for (const status of statuses) {
await page.getByRole('button', { name: `Inspect node-${status}` }).click()
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector.getByRole('button', { name: 'Approve', exact: true })).toHaveCount(status === 'pending' ? 1 : 0)
await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toHaveCount(status === 'healthy' ? 1 : 0)
await expect(inspector.getByRole('button', { name: 'Resume', exact: true })).toHaveCount(status === 'draining' ? 1 : 0)
await inspector.getByRole('button', { name: 'Close node inspector' }).click()
}
})
test('approves a pending node from the model-to-node drilldown', async ({ page }) => {
let status = 'pending'
let approvalRequests = 0
await page.route('**/api/nodes', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
{ id: 'pending-node', name: 'pending-worker', node_type: 'backend', status },
]) }))
await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([
{ id: 'replica', node_id: 'pending-node', model_name: 'Pending model', replica_index: 0, state: 'loaded' },
]) }))
await page.route('**/api/nodes/pending-node/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.route('**/api/nodes/pending-node/approve', async route => {
approvalRequests += 1
status = 'healthy'
await route.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
test.describe('Swarm nodes: the map', () => {
test('draws the cluster once the replicas are read, and links every node', async ({ page }) => {
await mockNodes(page, baseNodes.map(node => (node.id === 'n2' ? { ...node, status: 'healthy' } : node)))
let modelRequests = 0
await page.route('**/api/nodes/models', route => {
modelRequests += 1
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) })
})
await page.goto('/app/nodes')
await page.getByRole('tab', { name: 'Running models' }).click()
await page.getByRole('button', { name: 'Inspect Pending model' }).click()
await page.getByRole('button', { name: 'Open node pending-worker' }).click()
await expect(rows(page)).toHaveCount(3, { timeout: 15_000 })
expect(modelRequests).toBe(0)
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await inspector.getByRole('button', { name: 'Approve', exact: true }).click()
await expect.poll(() => approvalRequests).toBe(1)
await expect(page.getByText('Node approved')).toBeVisible()
await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeVisible()
await page.getByRole('radio', { name: /Map/ }).click()
const map = page.getByTestId('nodes-map')
await expect(map).toBeVisible()
await expect(map).toContainText('This instance')
await expect(page.getByTestId('map-node')).toHaveCount(3)
await expect(page.getByTestId('map-node').first()).toHaveAttribute('href', '/app/nodes/n1')
await expect(page.getByTestId('map-node').first()).toContainText('Llama 3.2')
// An agent worker runs jobs, not models.
await expect(page.getByTestId('map-node').nth(2)).toContainText('runs agent jobs')
expect(modelRequests).toBe(1)
await page.getByRole('radio', { name: /List/ }).click()
await page.getByRole('radio', { name: /Map/ }).click()
expect(modelRequests).toBe(1)
await page.getByTestId('map-node').first().click()
await expect(page).toHaveURL(/\/app\/nodes\/n1$/)
})
test('keeps incomplete capacity unknown throughout the fleet view', async ({ page }) => {
await mockNodes(page, [{
id: 'incomplete', name: 'incomplete-capacity', node_type: 'backend', status: 'healthy',
total_vram: 100, total_ram: 200, total_disk: 300,
}])
await page.route('**/api/nodes/incomplete/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.goto('/app/nodes')
await expect(page.getByLabel('VRAM capacity')).toContainText('No data')
await expect(page.getByLabel('VRAM capacity')).toContainText('1 node unavailable')
await expect(page.getByRole('button', { name: /Low VRAM.*0/ })).toBeVisible()
const row = page.getByRole('row', { name: /incomplete-capacity/ })
await expect(row.getByText('No data')).toHaveCount(3)
await page.getByRole('button', { name: 'Inspect incomplete-capacity' }).click()
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector.getByText('No data')).toHaveCount(4)
})
test('announces complete and partial capacity coverage without adding visible clutter', async ({ page }) => {
const partialNode = {
...baseNodes[0],
id: 'n-partial',
name: 'partial-capacity',
total_vram: 100,
available_vram: 50,
total_ram: undefined,
available_ram: undefined,
}
await mockNodes(page, [baseNodes[0], partialNode])
await page.goto('/app/nodes')
const vram = page.getByLabel('VRAM capacity', { exact: true })
const ram = page.getByLabel('RAM capacity', { exact: true })
await expect(vram).toContainText('Capacity coverage: 2 of 2 nodes reporting; 0 unknown.', { timeout: 15_000 })
await expect(ram).toContainText('Capacity coverage: 1 of 2 nodes reporting; 1 unknown.')
await expect(vram.locator('.fleet-gauge__coverage')).toHaveCount(0)
await expect(ram.locator('.fleet-gauge__coverage')).toHaveText('1 node unavailable')
})
test('keeps checkbox keyboard activation from opening the inspector', async ({ page }) => {
await mockNodes(page, [baseNodes[0]])
await page.goto('/app/nodes')
const checkbox = page.getByRole('checkbox', { name: 'Select atlas' })
await checkbox.focus()
await checkbox.press('Space')
await expect(checkbox).toBeChecked()
await expect(page.getByRole('complementary', { name: 'Node inspector' })).toHaveCount(0)
})
test('styles fleet selection consistently and exposes the partial-page state', async ({ page }) => {
test('is not drawn on a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockNodes(page)
await page.goto('/app/nodes')
await expect(rows(page).first()).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('radio', { name: /Map/ })).toHaveCount(0)
await expect(page.getByRole('radio', { name: /List/ })).toHaveAttribute('aria-checked', 'true')
await expect(page.getByTestId('nodes-map')).toHaveCount(0)
})
})
const selectPage = page.getByRole('checkbox', { name: 'Select page' })
const selectNode = page.getByRole('checkbox', { name: 'Select atlas' })
await expect(selectPage).toHaveCSS('width', '18px')
await expect(selectNode).toHaveCSS('height', '18px')
await expect(selectNode).toHaveCSS('cursor', 'pointer')
await selectNode.check()
await expect.poll(() => selectPage.evaluate(input => input.indeterminate)).toBe(true)
test.describe('Swarm nodes: first run, errors and a phone', () => {
test('shows how to add the first worker when none has registered', async ({ page }) => {
await mockNodes(page, [])
await page.goto('/app/nodes')
await expect(page.getByRole('heading', { name: 'No workers registered yet' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('command-block')).toContainText('LOCALAI_REGISTER_TO')
await expect(page.getByRole('status').filter({ hasText: 'Listening for a worker' })).toBeVisible()
})
test('keeps the low-density composition while inspecting at a desktop viewport', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1050 })
test('keeps the last list and says so when a refresh fails', async ({ page }) => {
let calls = 0
await page.route('**/api/nodes', route => {
calls += 1
if (calls === 1) return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseNodes) })
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"database unavailable"}' })
})
await page.goto('/app/nodes')
await expect(rows(page)).toHaveCount(3, { timeout: 15_000 })
await expect(page.getByRole('alert')).toContainText('could not be refreshed', { timeout: 15_000 })
await expect(rows(page)).toHaveCount(3)
})
test('lays nodes out as cards on a phone without sideways scrolling', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockNodes(page)
await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"},{"name":"whisper"}]' }))
await page.goto('/app/nodes')
const overview = page.getByRole('region', { name: 'Fleet overview' })
const workbench = page.getByRole('region', { name: 'Fleet workbench' })
await expect(overview).toBeVisible({ timeout: 15_000 })
await expect(workbench).toBeVisible()
await expect(page.locator('.console-layout > .console-rail')).toBeVisible()
await expect(page.locator('.fleet-select-wrap')).toHaveCount(3)
await expect(page.getByLabel('Filter status')).toHaveCSS('appearance', 'none')
await expect(page.locator('.fleet-bulkbar')).toHaveCount(0)
const overviewBefore = await overview.boundingBox()
const fleetBefore = await page.locator('#fleet-nodes-panel').boundingBox()
const cells = overview.locator('.fleet-overview__cell')
await expect(cells).toHaveCount(5)
const cellTops = await cells.evaluateAll(items => items.map(cell => Math.round(cell.getBoundingClientRect().top)))
expect(new Set(cellTops).size).toBe(1)
const attention = page.getByRole('complementary', { name: 'Attention queue' })
await expect(attention).toBeVisible()
const overviewBox = await overview.boundingBox()
const attentionBox = await attention.boundingBox()
expect(attentionBox.y).toBeGreaterThanOrEqual(overviewBox.y + overviewBox.height)
const checkbox = page.getByRole('checkbox', { name: 'Select atlas' })
await checkbox.check()
await expect(page.getByRole('row', { name: /atlas/ })).toHaveClass(/is-selected/)
await expect(page.locator('.fleet-bulkbar')).toBeVisible()
await expect(page.getByRole('button', { name: 'Clear selection' })).toBeVisible()
const inspectNode = page.getByRole('button', { name: 'Inspect atlas' })
await inspectNode.focus()
await inspectNode.press('Enter')
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector).toBeVisible()
await expect(inspector.getByRole('heading', { name: 'Node' })).toBeVisible()
await expect(inspector.getByRole('heading', { name: 'Resources' })).toBeVisible()
await expect(inspector.getByRole('heading', { name: 'Workload' })).toBeVisible()
await expect(inspector.locator('.node-inspector__resource')).toHaveCount(2)
const overviewAfter = await overview.boundingBox()
const fleetAfter = await page.locator('#fleet-nodes-panel').boundingBox()
expect(Math.abs(overviewAfter.width - overviewBefore.width)).toBeLessThanOrEqual(1)
expect(Math.abs(fleetBefore.width - fleetAfter.width)).toBeLessThanOrEqual(1)
await expect(inspector).toHaveCSS('position', 'fixed')
await expect.poll(async () => {
const inspectorBox = await inspector.boundingBox()
return Math.max(
Math.abs(inspectorBox.x + inspectorBox.width - 1584),
Math.abs(inspectorBox.y - 16),
Math.abs(inspectorBox.height - 1018),
)
}).toBeLessThanOrEqual(1)
await expect(rows(page)).toHaveCount(3, { timeout: 15_000 })
await expect(page.locator('.sw-table--nodes thead')).toBeHidden()
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
const approve = page.getByRole('button', { name: 'Approve borealis' })
const box = await approve.boundingBox()
expect(box.x + box.width).toBeLessThanOrEqual(390)
await expect(page.getByRole('link', { name: 'Add a node' })).toBeVisible()
})
})
test('reflows the overview and presents a contained drawer at a narrow viewport', async ({ page }) => {
await page.setViewportSize({ width: 640, height: 900 })
await mockNodes(page, [baseNodes[0]])
await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.goto('/app/nodes')
const overview = page.getByRole('region', { name: 'Fleet overview' })
await expect(overview).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.console-layout > .console-rail')).toBeVisible()
const overviewBox = await overview.boundingBox()
expect(overviewBox.width).toBeGreaterThan(500)
const cells = overview.locator('.fleet-overview__cell')
const tops = await cells.evaluateAll(items => items.map(item => Math.round(item.getBoundingClientRect().top)))
expect(new Set(tops).size).toBeGreaterThan(1)
await expect(overview.getByLabel('Fleet health summary')).toHaveCSS('grid-column-start', '1')
await expect(overview.getByLabel('Fleet health summary')).toHaveCSS('grid-column-end', '-1')
const narrowInspectNode = page.getByRole('button', { name: 'Inspect atlas' })
await narrowInspectNode.focus()
await narrowInspectNode.press('Enter')
const inspector = page.getByRole('dialog', { name: 'Node inspector' })
await expect(inspector).toBeVisible()
await expect(inspector).toHaveAttribute('aria-modal', 'true')
await expect(inspector).toHaveCSS('position', 'fixed')
await expect(page.locator('.node-inspector__scrim')).toBeVisible()
await expect(page.locator('body')).toHaveCSS('overflow', 'hidden')
await expect(page.getByRole('region', { name: 'Fleet workbench', includeHidden: true })).toHaveAttribute('inert', '')
await expect(page.getByRole('region', { name: 'Fleet workbench', includeHidden: true })).toHaveAttribute('aria-hidden', 'true')
const workbenchBox = await page.locator('.fleet-workbench').boundingBox()
expect(workbenchBox.width).toBeLessThanOrEqual(600)
await expect.poll(async () => (await inspector.boundingBox()).y).toBeLessThanOrEqual(1)
const inspectorBox = await inspector.boundingBox()
expect(inspectorBox.height).toBe(900)
const close = inspector.getByRole('button', { name: 'Close node inspector' })
await expect(close).toBeFocused()
await close.press('Shift+Tab')
await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeFocused()
await page.keyboard.press('Tab')
await expect(close).toBeFocused()
await page.keyboard.press('Escape')
await expect(inspector).toHaveCount(0)
await expect(page.locator('body')).not.toHaveCSS('overflow', 'hidden')
await expect(page.getByRole('region', { name: 'Fleet workbench' })).not.toHaveAttribute('inert', '')
await expect(page.getByRole('region', { name: 'Fleet workbench' })).not.toHaveAttribute('aria-hidden', 'true')
await expect(narrowInspectNode).toBeFocused()
})
test('loads running models once on activation and drills model to node and back', async ({ page }) => {
test.describe('Swarm nodes: running models', () => {
test('reads the replicas once, on first use, and opens a model to its nodes', async ({ page }) => {
await page.setViewportSize({ width: 1600, height: 1050 })
await mockNodes(page, baseNodes.map(node => node.id === 'n2' ? { ...node, status: 'healthy' } : node))
await mockNodes(page, baseNodes.map(node => (node.id === 'n2' ? { ...node, status: 'healthy' } : node)))
let modelRequests = 0
let backendRequests = 0
await page.route('**/api/nodes/models', route => {
@@ -426,159 +430,56 @@ test.describe('Nodes fleet dashboard', () => {
return route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"}]' })
})
await page.goto('/app/nodes')
await expect(page.getByRole('table', { name: 'Fleet nodes' })).toBeVisible({ timeout: 15_000 })
await expect(rows(page)).toHaveCount(3, { timeout: 15_000 })
expect(modelRequests).toBe(0)
expect(backendRequests).toBe(0)
const nodesTab = page.getByRole('tab', { name: 'Nodes' })
const modelsTab = page.getByRole('tab', { name: 'Running models' })
const nodesPanel = page.locator('#fleet-nodes-panel')
const modelsPanel = page.locator('#fleet-models-panel')
await expect(nodesTab).toHaveAttribute('aria-controls', 'fleet-nodes-panel')
await expect(nodesTab).toHaveAttribute('tabindex', '0')
await expect(modelsTab).toHaveAttribute('aria-controls', 'fleet-models-panel')
await expect(modelsTab).toHaveAttribute('tabindex', '-1')
await expect(nodesPanel).toHaveAttribute('role', 'tabpanel')
await expect(nodesPanel).toHaveAttribute('aria-labelledby', 'fleet-nodes-tab')
await expect(nodesPanel).not.toHaveAttribute('hidden', '')
await expect(nodesPanel).toBeVisible()
await expect(modelsPanel).toHaveAttribute('role', 'tabpanel')
await expect(modelsPanel).toHaveAttribute('aria-labelledby', 'fleet-models-tab')
await expect(modelsPanel).toHaveAttribute('hidden', '')
await expect(modelsPanel).toBeHidden()
await expect(modelsPanel.getByRole('table', { name: 'Running models' })).toHaveCount(0)
await expect(nodesPanel.locator('tbody tr')).toHaveCount(3)
await nodesTab.focus()
await nodesTab.press('ArrowRight')
await expect(modelsTab).toBeFocused()
await expect(modelsTab).toHaveAttribute('aria-selected', 'true')
await expect(modelsTab).toHaveAttribute('tabindex', '0')
await expect(nodesTab).toHaveAttribute('tabindex', '-1')
await expect(nodesPanel).toHaveAttribute('hidden', '')
await expect(nodesPanel).toBeHidden()
await expect(modelsPanel).not.toHaveAttribute('hidden', '')
await expect(modelsPanel).toBeVisible()
await expect(page.getByText('Current loaded replicas on healthy nodes')).toBeVisible()
await expect(page.getByRole('table', { name: 'Running models' })).toBeVisible()
await page.getByRole('radio', { name: /Running models/ }).click()
const table = page.getByRole('table', { name: /Running models/ })
await expect(table).toBeVisible()
await expect(page.getByRole('row', { name: /Llama 3.2/ })).toContainText('3')
await expect(rows(page)).toHaveCount(2)
expect(modelRequests).toBe(1)
const toggle = page.getByRole('button', { name: 'Show replicas of Llama 3.2' })
await expect(toggle).toHaveAttribute('aria-expanded', 'false')
await toggle.click()
await expect(page.getByRole('button', { name: 'Hide replicas of Llama 3.2' })).toHaveAttribute('aria-expanded', 'true')
await expect(table).toContainText('2 replicas')
await expect(table).toContainText('borealis')
await expect(table.getByRole('link', { name: 'atlas' })).toHaveAttribute('href', '/app/nodes/n1')
// A replica on a node the roster no longer lists still shows, unlinked.
await page.getByRole('button', { name: 'Show replicas of Whisper large v3' }).click()
await expect(table).toContainText('missing')
await page.getByRole('radio', { name: /List/ }).click()
await page.getByRole('radio', { name: /Running models/ }).click()
expect(modelRequests).toBe(1)
expect(backendRequests).toBe(0)
const modelControl = page.getByRole('button', { name: 'Inspect Llama 3.2' })
await expect(modelControl).not.toHaveAttribute('aria-selected')
await expect(modelControl).toHaveAttribute('aria-pressed', 'false')
await expect(modelControl).toHaveAttribute('aria-expanded', 'false')
await expect(modelControl).not.toHaveAttribute('aria-controls')
await modelControl.focus()
await modelControl.press('Enter')
const modelInspector = page.getByRole('complementary', { name: 'Model inspector' })
const closeModel = page.getByRole('button', { name: 'Close model inspector' })
await expect(closeModel).toBeFocused()
await expect(modelControl).not.toHaveAttribute('aria-selected')
await expect(modelControl).toHaveAttribute('aria-pressed', 'true')
await expect(modelControl).toHaveAttribute('aria-expanded', 'true')
await expect(modelControl).toHaveAttribute('aria-current', 'true')
await expect(modelControl).toHaveAttribute('aria-controls', 'model-inspector')
await expect(modelInspector).toContainText('2 replicas')
await expect(modelInspector).toContainText('borealis')
const atlasControl = modelInspector.getByRole('button', { name: /Open node atlas/ })
await atlasControl.focus()
await atlasControl.press('Enter')
await expect(page.getByRole('complementary', { name: 'Node inspector' })).toBeVisible()
await expect(modelControl).toHaveAttribute('aria-pressed', 'true')
await expect(modelControl).toHaveAttribute('aria-expanded', 'false')
await expect(modelControl).not.toHaveAttribute('aria-controls')
const backToModel = page.getByRole('button', { name: 'Back to Llama 3.2' })
await expect(backToModel).toBeFocused()
await expect.poll(() => backendRequests).toBe(1)
expect(modelRequests).toBe(1)
await backToModel.press('Enter')
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toBeVisible()
await expect(page.getByRole('button', { name: /Open node atlas/ })).toBeFocused()
await closeModel.click()
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0)
await expect(modelControl).toBeFocused()
await expect(modelControl).not.toHaveAttribute('aria-selected')
await expect(modelControl).toHaveAttribute('aria-pressed', 'false')
await expect(modelControl).toHaveAttribute('aria-expanded', 'false')
await expect(modelControl).not.toHaveAttribute('aria-controls')
await page.getByRole('button', { name: 'Inspect Whisper large v3' }).click()
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('missing')
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toContainText('Unknown')
await page.getByRole('button', { name: 'Close model inspector' }).click()
await modelsTab.focus()
await modelsTab.press('ArrowLeft')
await expect(nodesTab).toBeFocused()
await expect(nodesTab).toHaveAttribute('aria-selected', 'true')
await expect(nodesPanel).not.toHaveAttribute('hidden', '')
await expect(modelsPanel).toHaveAttribute('hidden', '')
await expect(modelsPanel.locator('tbody tr')).toHaveCount(2)
await nodesTab.press('ArrowRight')
await expect(modelsPanel.locator('tbody tr')).toHaveCount(2)
expect(modelRequests).toBe(1)
})
test('opens logs directly for one replica and asks for placement when a model has several', async ({ page }) => {
await mockNodes(page, baseNodes.map(node => node.id === 'n2' ? { ...node, status: 'healthy' } : node))
test('opens logs directly for one replica and asks for the replica when a model has several', async ({ page }) => {
await mockNodes(page, baseNodes.map(node => (node.id === 'n2' ? { ...node, status: 'healthy' } : node)))
await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) }))
await page.goto('/app/nodes')
await page.getByRole('tab', { name: 'Running models' }).click()
await page.getByRole('radio', { name: /Running models/ }).click()
await page.getByRole('button', { name: 'Actions for Whisper large v3' }).click()
await page.getByRole('menuitem', { name: 'View logs…' }).click()
await expect(page).toHaveURL(/\/app\/node-backend-logs\/missing\/Whisper%20large%20v3%230$/)
await page.goto('/app/nodes')
await page.getByRole('tab', { name: 'Running models' }).click()
await page.getByRole('radio', { name: /Running models/ }).click()
await page.getByRole('button', { name: 'Actions for Llama 3.2' }).click()
await page.getByRole('menuitem', { name: 'View logs…' }).click()
const inspector = page.getByRole('complementary', { name: 'Model inspector' })
await expect(inspector).toBeVisible()
await expect(inspector.getByRole('button', { name: 'View all Llama 3.2 logs on atlas' })).toBeVisible()
await inspector.getByRole('button', { name: 'View logs for Llama 3.2 replica 1 on atlas' }).click()
await expect(page.getByRole('button', { name: 'Hide replicas of Llama 3.2' })).toBeVisible()
await page.getByRole('button', { name: 'View logs for Llama 3.2 replica 1 on atlas' }).click()
await expect(page).toHaveURL(/\/app\/node-backend-logs\/n1\/Llama%203.2%230$/)
})
test('treats the model inspector as a modal drawer on mobile', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockNodes(page)
await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(baseModels) }))
await page.goto('/app/nodes')
await page.getByRole('tab', { name: 'Running models' }).click()
await page.getByRole('button', { name: 'Inspect Llama 3.2' }).click()
const inspector = page.getByRole('dialog', { name: 'Model inspector' })
await expect(inspector).toHaveAttribute('aria-modal', 'true')
await expect(inspector.getByRole('button', { name: 'Close model inspector' })).toBeFocused()
await expect(page.locator('.fleet-workbench')).toHaveAttribute('inert', '')
await inspector.getByRole('button', { name: 'Close model inspector' }).press('Shift+Tab')
await expect(inspector.getByRole('button', { name: 'Close', exact: true })).toBeFocused()
await page.keyboard.press('Tab')
await expect(inspector.getByRole('button', { name: 'Close model inspector' })).toBeFocused()
})
test('keeps the node inspector open when Escape dismisses its confirmation dialog', async ({ page }) => {
await mockNodes(page)
await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.goto('/app/nodes')
await page.getByRole('checkbox', { name: 'Select atlas' }).check()
await page.getByRole('button', { name: 'Inspect atlas' }).click()
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector).toBeVisible()
await page.getByRole('button', { name: 'Remove selected' }).click()
await expect(page.getByRole('alertdialog')).toBeVisible()
await page.keyboard.press('Escape')
await expect(page.getByRole('alertdialog')).toHaveCount(0)
await expect(inspector).toBeVisible()
})
test('stops a running model once from an accessible row menu and refreshes inventory', async ({ page }) => {
test('stops a running model once from a row menu and refreshes the inventory', async ({ page }) => {
await mockNodes(page)
let modelRequests = 0
let stopRequests = 0
@@ -586,8 +487,8 @@ test.describe('Nodes fleet dashboard', () => {
let finishStop
await page.route('**/api/nodes/models', route => {
modelRequests += 1
const rows = modelRequests === 1 ? baseModels : baseModels.filter(row => row.model_name !== 'Llama 3.2')
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(rows) })
const list = modelRequests === 1 ? baseModels : baseModels.filter(row => row.model_name !== 'Llama 3.2')
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(list) })
})
await page.route('**/backend/shutdown', async route => {
stopRequests += 1
@@ -596,38 +497,23 @@ test.describe('Nodes fleet dashboard', () => {
await route.fulfill({ status: 200, contentType: 'application/json', body: '{"message":"ok"}' })
})
await page.goto('/app/nodes')
const modelsTab = page.getByRole('tab', { name: 'Running models' })
await modelsTab.click()
await page.getByRole('radio', { name: /Running models/ }).click()
const trigger = page.getByRole('button', { name: 'Actions for Llama 3.2' })
await expect(trigger).toBeVisible()
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve))))
await expect(modelsTab).toBeFocused()
await trigger.focus()
await trigger.press('Enter')
const menu = page.getByRole('menu', { name: 'Llama 3.2 actions' })
await expect(menu).toBeVisible()
await expect(menu).toBeFocused()
await menu.press('Escape')
await expect(menu).toHaveCount(0)
await expect(trigger).toBeFocused()
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0)
await trigger.click()
await page.locator('.model-workbench__scope').click()
await expect(menu).toHaveCount(0)
await expect(page.getByRole('complementary', { name: 'Model inspector' })).toHaveCount(0)
await trigger.click()
await menu.getByRole('menuitem', { name: 'Stop model…' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Stop Llama 3.2?')
await expect(dialog).toContainText('Llama 3.2 has 3 loaded replicas across 2 unique nodes. This will stop all loaded placements on those nodes.')
await expect(dialog.getByRole('button', { name: 'Stop model' })).toBeFocused()
await page.keyboard.press('Tab')
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeFocused()
await page.keyboard.press('Shift+Tab')
await expect(dialog).toContainText('Llama 3.2 has 3 replicas loaded across 2 nodes. This stops all loaded placements on those nodes.')
await expect(dialog.getByRole('button', { name: 'Stop model' })).toBeFocused()
await dialog.getByRole('button', { name: 'Cancel' }).click()
await expect(dialog).toHaveCount(0)
@@ -639,7 +525,6 @@ test.describe('Nodes fleet dashboard', () => {
button.click()
button.click()
})
await expect(dialog.getByRole('button', { name: 'Stopping…' })).toBeDisabled()
await expect(dialog.getByRole('button', { name: 'Cancel' })).toBeDisabled()
await expect.poll(() => stopRequests).toBe(1)
@@ -647,10 +532,10 @@ test.describe('Nodes fleet dashboard', () => {
finishStop()
await expect.poll(() => modelRequests).toBe(2)
await expect(page.getByText('Stopped Llama 3.2: 3 replicas across 2 nodes.')).toBeVisible()
await expect(page.getByRole('button', { name: 'Inspect Llama 3.2' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Actions for Llama 3.2' })).toHaveCount(0)
})
test('refreshes model inventory and warns about partial shutdown after a stop failure', async ({ page }) => {
test('refreshes the inventory and warns about a partial shutdown after a stop failure', async ({ page }) => {
await mockNodes(page)
let modelRequests = 0
let stopRequests = 0
@@ -663,7 +548,7 @@ test.describe('Nodes fleet dashboard', () => {
return route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"controller timed out"}' })
})
await page.goto('/app/nodes')
await page.getByRole('tab', { name: 'Running models' }).click()
await page.getByRole('radio', { name: /Running models/ }).click()
await page.getByRole('button', { name: 'Actions for Whisper large v3' }).click()
await page.getByRole('menuitem', { name: 'Stop model…' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Stop model' }).click()
@@ -671,67 +556,10 @@ test.describe('Nodes fleet dashboard', () => {
await expect.poll(() => stopRequests).toBe(1)
await expect.poll(() => modelRequests).toBe(2)
await expect(page.getByText(/Could not stop Whisper large v3:.*Some replicas may already have stopped\./)).toBeVisible()
await expect(page.getByRole('button', { name: 'Inspect Whisper large v3' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Actions for Whisper large v3' })).toBeVisible()
})
test('moves focus into and restores it from the node inspector', async ({ page }) => {
await mockNodes(page, [baseNodes[0]])
await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.goto('/app/nodes')
const nodeControl = page.getByRole('button', { name: 'Inspect atlas' })
await nodeControl.click()
const closeNode = page.getByRole('button', { name: 'Close node inspector' })
await expect(closeNode).toBeFocused()
await closeNode.click()
await expect(nodeControl).toBeFocused()
})
test('keeps drawer actions visible for a one-row filtered fleet', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 })
await mockNodes(page, baseNodes)
await page.route('**/api/nodes/n1/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[{"name":"llama-cpp"}]' }))
await page.goto('/app/nodes')
await page.getByRole('searchbox', { name: 'Search nodes' }).fill('atlas')
await expect(page.getByRole('row', { name: /atlas/ })).toBeVisible()
await page.getByRole('button', { name: 'Inspect atlas' }).click()
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector.getByRole('heading', { name: 'Resources' })).toBeVisible()
await expect(inspector.getByRole('heading', { name: 'Workload' })).toBeVisible()
await expect(inspector.getByRole('link', { name: 'Open full node details' })).toBeVisible()
await expect(inspector.getByRole('button', { name: 'Drain', exact: true })).toBeVisible()
const inspectorBox = await inspector.boundingBox()
expect(Math.abs(inspectorBox.y - 16)).toBeLessThanOrEqual(1)
expect(Math.abs(inspectorBox.height - 868)).toBeLessThanOrEqual(1)
await expect(inspector.locator('.node-inspector__actions')).toHaveCSS('display', 'grid')
})
test('keeps a desktop drawer in the visible viewport after opening from a long roster', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 })
const nodes = Array.from({ length: 50 }, (_, index) => ({
id: `long-${index}`,
name: `long-worker-${String(index).padStart(2, '0')}`,
node_type: 'backend',
status: 'healthy',
}))
await mockNodes(page, nodes)
await page.route('**/api/nodes/long-49/backends', route => route.fulfill({ status: 200, contentType: 'application/json', body: '[]' }))
await page.goto('/app/nodes')
await page.getByRole('button', { name: 'Inspect long-worker-49' }).click()
expect(await page.evaluate(() => window.scrollY)).toBeGreaterThan(1000)
const inspector = page.getByRole('complementary', { name: 'Node inspector' })
await expect(inspector).toHaveCSS('position', 'fixed')
await expect(inspector.getByRole('heading', { name: 'long-worker-49' })).toBeVisible()
await expect(inspector.getByRole('link', { name: 'Open full node details' })).toBeVisible()
const box = await inspector.boundingBox()
expect(Math.abs(box.y - 16)).toBeLessThanOrEqual(1)
expect(Math.abs(box.height - 768)).toBeLessThanOrEqual(1)
})
test('shows model loading, error, retry, and empty states', async ({ page }) => {
test('shows loading, error, retry and empty states', async ({ page }) => {
await mockNodes(page)
let finishFirst
let attempts = 0
@@ -744,7 +572,7 @@ test.describe('Nodes fleet dashboard', () => {
return route.fulfill({ status: 200, contentType: 'application/json', body: '[]' })
})
await page.goto('/app/nodes')
await page.getByRole('tab', { name: 'Running models' }).click()
await page.getByRole('radio', { name: /Running models/ }).click()
await expect(page.getByText('Loading running models…')).toBeVisible()
finishFirst()
await expect(page.getByText('Unable to load running models')).toBeVisible()
@@ -768,16 +596,15 @@ test.describe('Nodes fleet dashboard', () => {
}))
await page.route('**/api/nodes/models', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(models) }))
await page.goto('/app/nodes')
await page.getByRole('tab', { name: 'Running models' }).click()
await expect(page.getByRole('table', { name: 'Running models' }).locator('tbody tr')).toHaveCount(50)
await page.getByRole('radio', { name: /Running models/ }).click()
await expect(rows(page)).toHaveCount(50)
await expect(page.getByText('Page 1 of 20')).toBeVisible()
await page.getByRole('button', { name: 'Next model page' }).click()
await expect(page.getByText('Page 2 of 20')).toBeVisible()
await page.getByRole('searchbox', { name: 'Search running models' }).fill('model-0000')
await expect(page.locator('#fleet-models-panel').getByText('Page 1 of 1')).toBeVisible()
await page.getByRole('searchbox', { name: 'Search running models' }).fill('')
await page.getByRole('button', { name: /Sort by model/ }).click()
await expect(page.getByRole('table', { name: 'Running models' }).locator('tbody tr').first()).toContainText('model-0999')
await page.getByRole('searchbox', { name: 'Search model or backend…' }).fill('model-0000')
await expect(page.getByText('Page 1 of 1')).toBeVisible()
await page.getByRole('searchbox', { name: 'Search model or backend…' }).fill('')
await page.getByRole('columnheader', { name: 'Model' }).getByRole('button').click()
await expect(rows(page).first()).toContainText('model-0999')
})
})
@@ -1,6 +1,6 @@
import { test, expect } from './coverage-fixtures.js'
// These specs cover the per-node backend row in the Nodes page:
// These specs cover the per-node backend row on the node detail page:
// - the upgrade affordance is self-explanatory (icon + tooltip)
// - a delete affordance is present and goes through ConfirmDialog
//
@@ -90,10 +90,9 @@ async function mockDistributedNodes(page, { onDelete } = {}) {
}
async function openNodeDetail(page) {
// The per-node backend table now lives on the deep-linkable detail page
// at /app/nodes/:id (the old expand-row + "Manage" disclosure was removed
// when the roster was restructured). Navigate straight there.
await page.goto(`/app/nodes/${NODE_ID}`)
// The per-node backend table is the Backends tab of the deep-linkable detail
// page at /app/nodes/:id. Navigate straight there.
await page.goto(`/app/nodes/${NODE_ID}?tab=backends`)
await expect(page.getByRole('cell', { name: BACKEND_NAME, exact: true })).toBeVisible({ timeout: 10_000 })
}
@@ -107,13 +106,13 @@ async function openBackendActions(page) {
return menu
}
test.describe('Nodes page — per-node backend actions', () => {
test.describe('Node detail — per-node backend actions', () => {
test('upgrade affordance is self-explanatory (not "Reinstall backend" with a sync icon)', async ({ page }) => {
await mockDistributedNodes(page)
await openNodeDetail(page)
await expect(page.locator('.node-detail__metrics')).toContainText('RAM')
await expect(page.locator('.node-detail__metrics')).toContainText('3.7 GB / 7.5 GB')
await expect(page.getByRole('region', { name: 'Node resources' })).toContainText('RAM')
await expect(page.getByRole('region', { name: 'Node resources' })).toContainText('3.7 GB / 7.5 GB')
const menu = await openBackendActions(page)
@@ -123,7 +122,7 @@ test.describe('Nodes page — per-node backend actions', () => {
// Positive: the action menu names the operation and uses an upgrade icon.
const upgradeItem = menu.getByRole('menuitem', { name: 'Upgrade backend' })
await expect(upgradeItem).toBeVisible()
await expect(upgradeItem.locator('i.fa-arrow-up')).toBeVisible()
await expect(upgradeItem.locator('svg[data-icon="arrow-up"]')).toBeVisible()
})
test('per-node backend row shows a delete (trash) button next to upgrade', async ({ page }) => {
@@ -133,7 +132,7 @@ test.describe('Nodes page — per-node backend actions', () => {
const menu = await openBackendActions(page)
const deleteItem = menu.getByRole('menuitem', { name: 'Delete backend…' })
await expect(deleteItem).toBeVisible()
await expect(deleteItem.locator('i.fa-trash')).toBeVisible()
await expect(deleteItem.locator('svg[data-icon="trash"]')).toBeVisible()
})
test('clicking delete opens the confirm dialog and POSTs to the per-node delete endpoint', async ({ page }) => {
+6 -4
View File
@@ -13,10 +13,11 @@ test.describe('Nodes fleet roster', () => {
{ id: 'a1', name: 'agent-1', node_type: 'agent', address: '10.0.0.9:50051', status: 'draining', model_count: 0 },
])
await page.goto('/app/nodes')
await expect(page.getByRole('table', { name: 'Fleet nodes' })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('tab', { name: 'Nodes' })).toHaveAttribute('aria-selected', 'true')
await page.getByRole('tab', { name: 'Nodes' }).click()
await expect(page.getByRole('table', { name: /Nodes with their state/ })).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('radio', { name: /List/ })).toHaveAttribute('aria-checked', 'true')
await expect(page.getByRole('row', { name: /alpha/ })).toContainText('3')
// An agent worker runs jobs, not models, so its Models cell stays empty.
await expect(page.getByRole('row', { name: /agent-1/ })).toContainText('—')
expect(requests.some(url => url.includes('/api/nodes/models'))).toBe(false)
expect(requests.some(url => /\/api\/nodes\/[^/]+\/backends/.test(url))).toBe(false)
})
@@ -25,6 +26,7 @@ test.describe('Nodes fleet roster', () => {
await mockCluster(page, [])
await page.goto('/app/nodes')
await expect(page.getByText('No workers registered yet')).toBeVisible({ timeout: 15_000 })
await expect(page.getByTestId('add-node')).toBeVisible()
})
// A single-node server does not register the cluster routes at all, so the
@@ -38,7 +40,7 @@ test.describe('Nodes fleet roster', () => {
await expect(page.getByTestId('local-machine')).toBeVisible({ timeout: 15_000 })
await expect(page.getByText('No workers registered yet')).toHaveCount(0)
await expect(page.getByTestId('scale-out')).toHaveCount(0)
await page.getByRole('button', { name: 'Add machines' }).click()
await page.getByRole('button', { name: 'Add a machine' }).click()
await expect(page.getByTestId('scale-out')).toContainText('Distributed mode is not enabled')
})
}
+62
View File
@@ -0,0 +1,62 @@
import { test, expect } from './coverage-fixtures.js'
import { mockAccess } from './access-fixtures.js'
test.describe('404', () => {
test('names the address, offers a way home and lists the places the viewer can go', async ({ page }) => {
await page.goto('/app/no-such-page')
const nf = page.getByTestId('not-found')
await expect(nf.getByRole('heading', { level: 1 })).toHaveText('This page does not exist')
await expect(nf).toContainText('/app/no-such-page')
await expect(nf.getByRole('link', { name: 'Go home' })).toHaveAttribute('href', '/app')
const places = nf.getByRole('navigation', { name: 'Where to go' })
for (const name of ['Home', 'Models', 'Chat', 'Studio', 'Talk', 'Build', 'Operate']) {
await expect(places.getByRole('link', { name })).toBeVisible()
}
})
test('has no search, because the app has none to offer', async ({ page }) => {
await page.goto('/app/no-such-page')
await expect(page.getByTestId('not-found').getByRole('searchbox')).toHaveCount(0)
await expect(page.getByTestId('not-found')).not.toContainText('Press /')
})
test('the links go where they say', async ({ page }) => {
await page.goto('/app/no-such-page')
await page.getByTestId('not-found').getByRole('link', { name: 'Chat' }).click()
await expect(page).toHaveURL(/\/app\/chat$/)
await page.goto('/app/no-such-page')
await page.getByTestId('not-found').getByRole('link', { name: 'Go home' }).click()
await expect(page).toHaveURL(/\/app\/?$/)
})
test('a person who is not an admin is not offered admin places', async ({ page }) => {
await mockAccess(page, { status: 'member' })
await page.goto('/app/no-such-page')
const places = page.getByTestId('not-found').getByRole('navigation', { name: 'Where to go' })
await expect(places.getByRole('link', { name: 'Chat' })).toBeVisible()
await expect(places.getByRole('link', { name: 'Models' })).toHaveCount(0)
await expect(places.getByRole('link', { name: 'Operate' })).toHaveCount(0)
})
test('an address outside the app gets the same page', async ({ page }) => {
await page.goto('/nowhere/at/all')
await expect(page.getByTestId('not-found')).toContainText('/nowhere/at/all')
await expect(page.getByTestId('not-found').getByRole('link', { name: 'Go home' })).toBeVisible()
})
test('fits a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await page.goto('/app/no-such-page')
await expect(page.getByTestId('not-found')).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
})
})
test.describe('404: reduced motion', () => {
test('hover transitions are switched off', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await page.goto('/app/no-such-page')
const seconds = await page.locator('.nf-place').first().evaluate(el => parseFloat(getComputedStyle(el).transitionDuration))
expect(seconds).toBeLessThan(0.001)
})
})
+173
View File
@@ -0,0 +1,173 @@
// Fixtures for the Operate pages: Status, This machine, Runtime (Backends,
// Activity, Logs). Plain data and one function that routes every call those
// pages make, so a spec says only what is different about its case.
//
// Pure of Playwright imports so a script that is not a test can use it too.
export const GB = 1024 ** 3
// GET /api/resources on a host with one GPU.
export function gpuHost({ usedGB = 18, totalGB = 24, diskFreeGB = 588, diskTotalGB = 1000 } = {}) {
const total = totalGB * GB
const used = usedGB * GB
return {
type: 'gpu',
available: true,
gpus: [{
index: 0, name: 'NVIDIA RTX 4090', vendor: 'NVIDIA',
total_vram: total, used_vram: used, free_vram: total - used, usage_percent: (used / total) * 100,
}],
aggregate: { total_memory: total, used_memory: used, free_memory: total - used, usage_percent: (used / total) * 100, gpu_count: 1 },
ram: { total: 64 * GB, used: 31 * GB, free: 8 * GB, available: 33 * GB },
cpu: { logical_cores: 16, usage_percent: 41, load_1: 5.2 },
disk: { total: diskTotalGB * GB, used: (diskTotalGB - diskFreeGB) * GB, available: diskFreeGB * GB },
storage_size: 38 * GB,
reclaimer_enabled: false,
}
}
// A loaded model as GET /system reports it. `rss` is in GB.
export function loadedModel(id, backend, rss, cpu) {
return {
id,
backend,
process: {
pid: 4100 + Math.round(rss * 10),
rss_bytes: rss * GB,
memory_percent: (rss / 64) * 100,
...(cpu == null ? {} : { cpu_percent: cpu }),
started_at: new Date(Date.now() - 2 * 3600_000).toISOString(),
},
}
}
export const LOADED = [
loadedModel('qwen3-8b-instruct', 'llama-cpp', 9.2, 12.5),
loadedModel('gemma-3-12b-it', 'llama-cpp', 3.5, 0.4),
loadedModel('whisper-large-v3', 'whisper', 3.4, 0.1),
]
export const TRACES_QUIET = { total: 1204, errors: 0, p95_ms: 1200, window_hours: 24, buckets: [] }
export const TRACES_ERRORS = { total: 18402, errors: 37, p95_ms: 842, window_hours: 24, buckets: [] }
export const UPGRADE_LLAMA = {
'llama-cpp': { backend_name: 'llama-cpp', installed_version: '0.9.4', available_version: '0.9.7' },
}
export const FAILED_OP = {
id: 'gemma-3-27b', name: 'gemma-3-27b', fullName: 'localai@gemma-3-27b', jobID: 'job-gemma27',
progress: 61, taskType: 'installation', isDeletion: false, isBackend: false, isQueued: false,
cancellable: false, error: 'Connection reset by the registry',
}
export const RUNNING_OP = {
id: 'sglang', name: 'sglang', fullName: 'localai@sglang', jobID: 'job-sglang',
progress: 38, taskType: 'installation', isDeletion: false, isBackend: true, isQueued: false,
cancellable: true, message: 'downloading', phase: 'downloading',
currentBytes: 1.4 * GB, totalBytes: 3.8 * GB,
}
export const NODES = [
{ id: 'n1', name: 'gpu-box-1', status: 'healthy', healthy: true, node_type: 'backend', total_vram: 24 * GB, available_vram: 6 * GB, total_ram: 64 * GB, available_ram: 30 * GB },
{ id: 'n2', name: 'gpu-box-2', status: 'healthy', healthy: true, node_type: 'backend', total_vram: 24 * GB, available_vram: 12 * GB, total_ram: 64 * GB, available_ram: 40 * GB },
{ id: 'n3', name: 'gpu-box-3', status: 'healthy', healthy: true, node_type: 'backend', total_vram: 24 * GB, available_vram: 2 * GB, total_ram: 64 * GB, available_ram: 20 * GB },
{ id: 'n4', name: 'gpu-box-4', status: 'healthy', healthy: true, node_type: 'backend', total_vram: 24 * GB, available_vram: 18 * GB, total_ram: 64 * GB, available_ram: 44 * GB },
]
export function catalogBackend(name, extra = {}) {
return {
id: name, name, description: `${name} backend`, installed: false, version: '', processing: false,
isMeta: false, isAlias: false, isDevelopment: false, license: 'MIT', gallery: 'localai', tags: [], urls: [], nodes: [],
...extra,
}
}
export const CATALOG = [
catalogBackend('llama-cpp', { description: 'llama.cpp inference: chat, embeddings, vision', installed: true, version: '0.9.4', isMeta: true, tags: ['chat', 'vision'], urls: ['https://github.com/ggml-org/llama.cpp'] }),
catalogBackend('whisper', { description: 'Speech to text', installed: true, version: '1.7.2', tags: ['transcript'] }),
catalogBackend('kokoro', { description: 'Text to speech', installed: true, version: '0.3.1', tags: ['tts'] }),
catalogBackend('vllm', { description: 'GPU serving with high throughput', isMeta: true, tags: ['chat'] }),
catalogBackend('parakeet-cpp', { description: 'Fast speech recognition with speaker ids', tags: ['transcript'] }),
catalogBackend('diffusers', { description: 'Image and video with PyTorch', tags: ['image', 'video'] }),
]
export function installedBackend(name, version, extra = {}) {
return { Name: name, IsSystem: false, IsMeta: false, Metadata: { version, installed_at: '2026-09-02T09:12:00Z', uri: `oci://quay.io/go-skynet/local-ai-backends:${version}-${name}`, digest: 'sha256:9c1e04b7d2' }, ...extra }
}
export const INSTALLED = [
installedBackend('llama-cpp', '0.9.4'),
installedBackend('whisper', '1.7.2'),
installedBackend('kokoro', '0.3.1'),
]
export const MODEL_CAPS = {
data: [
{ id: 'qwen3-8b-instruct', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'gemma-3-12b-it', backend: 'llama-cpp', capabilities: ['FLAG_CHAT'] },
{ id: 'whisper-large-v3', backend: 'whisper', capabilities: ['FLAG_TRANSCRIPT'] },
],
}
export const LOG_LINES = [
{ timestamp: '2026-10-08T14:28:01Z', stream: 'stdout', text: 'llama-cpp gRPC server listening on 127.0.0.1:41871' },
{ timestamp: '2026-10-08T14:28:01Z', stream: 'stdout', text: 'loading model qwen3-8b-instruct' },
{ timestamp: '2026-10-08T14:28:02Z', stream: 'stdout', text: 'backend ready, pid 4118' },
{ timestamp: '2026-10-08T14:31:52Z', stream: 'stderr', text: 'load model: out of memory (needs 12.4 GB, 5.6 GB free)' },
{ timestamp: '2026-10-08T14:31:53Z', stream: 'stdout', text: 'backend is not running. The next request tries the load again.' },
]
// Route every call the Operate pages make. Later routes win, so a spec can call
// this first and override one endpoint after.
//
// distributed features.distributed and the node list
// nodes the cluster API's list
// resources GET /api/resources (null answers 503)
// loaded GET /system loaded_models
// upgrades GET /api/backends/upgrades
// operations GET /api/operations
// history GET /api/operations/history
// traces GET /api/traces/summary
// catalog GET /api/backends
// installed GET /backends
// models GET /api/models/capabilities
// delayMs holds every answer, for a loading state
export async function mockOperate(page, {
distributed = false,
nodes = NODES,
resources = gpuHost(),
loaded = LOADED,
upgrades = {},
operations = [],
history = [],
traces = TRACES_QUIET,
catalog = CATALOG,
installed = INSTALLED,
models = MODEL_CAPS,
delayMs = 0,
} = {}) {
const answer = async (route, body, status = 200) => {
if (delayMs) await new Promise(resolve => setTimeout(resolve, delayMs))
return route.fulfill({ status, json: body })
}
// First, so the more specific /api/backends/... routes below win over it.
await page.route('**/api/backends?*', route => answer(route, { backends: catalog, preferDevelopmentBackends: false }))
await page.route('**/api/features', route => route.fulfill({ json: { distributed, agents: true, mcp: true } }))
await page.route('**/api/nodes', route => (distributed
? answer(route, nodes)
: route.fulfill({ status: 404, json: { message: 'Not Found' } })))
await page.route('**/api/resources', route => (resources
? answer(route, resources)
: answer(route, { error: 'resource monitor disabled' }, 503)))
await page.route('**/system', route => answer(route, { backends: [], loaded_models: loaded }))
await page.route('**/api/backends/upgrades', route => answer(route, upgrades))
await page.route('**/api/operations', route => answer(route, { operations }))
await page.route('**/api/operations/history', route => answer(route, { operations: history }))
await page.route('**/api/traces/summary', route => answer(route, traces))
await page.route('**/backends', route => {
if (new URL(route.request().url()).pathname === '/backends') return answer(route, installed)
return route.continue()
})
await page.route('**/api/models/capabilities', route => answer(route, models))
await page.route('**/api/backend-logs', route => answer(route, ['qwen3-8b-instruct', 'whisper-large-v3']))
}
+58 -46
View File
@@ -67,24 +67,34 @@ test.describe('Operate overview', () => {
await expect(page.locator(ITEM, { hasText: 'qwen3-8b' })).toBeVisible()
})
test('the rail reports backend updates alongside the label', async ({ page }) => {
test('the Runtime tab reports backend updates beside the label', async ({ page }) => {
await mockQuiet(page, { upgrades: ONE_UPGRADE })
await page.goto('/app/operate')
const backends = page.locator('.console-rail a.nav-item[href="/app/backends"]')
await expect(backends).toBeVisible()
await expect(backends.locator('.nav-signal')).toContainText('1')
const runtime = page.locator('.dk-hubtabs [data-hub-tab="runtime"]')
await expect(runtime).toBeVisible()
await expect(runtime.locator('.dk-hubtab-attn')).toContainText('1')
})
test('the rail groups Runtime, Cluster, Observability and Administration', async ({ page }) => {
test('the Status tab carries the attention count and nothing else claims one', async ({ page }) => {
await mockQuiet(page, { upgrades: ONE_UPGRADE })
await page.goto('/app/operate')
await expect(page.locator('.dk-hubtabs [data-hub-tab="status"] .dk-hubtab-attn')).toContainText('1')
await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"] .dk-hubtab-attn')).toHaveCount(0)
await expect(page.locator('.dk-hubtabs [data-hub-tab="settings"] .dk-hubtab-count')).toHaveCount(0)
})
test('the tab bar offers Status, Runtime, Traffic and Settings', async ({ page }) => {
await mockQuiet(page)
await page.goto('/app/operate')
const rail = page.locator('.console-rail')
for (const group of ['Runtime', 'Cluster', 'Observability', 'Administration']) {
await expect(rail.locator('.console-group-title', { hasText: group })).toBeVisible()
const bar = page.locator('.dk-hubtabs')
for (const id of ['status', 'machine', 'runtime', 'traffic', 'settings']) {
await expect(bar.locator(`[data-hub-tab="${id}"]`)).toBeVisible()
}
// Six headings for thirteen items was the defect; the old pairs are gone.
for (const gone of ['Inference', 'Access']) {
await expect(rail.locator('.console-group-title', { hasText: new RegExp(`^${gone}$`) })).toHaveCount(0)
// The second row names the routes inside a tab.
await page.goto('/app/backends')
const sub = page.locator('.hub-subnav')
for (const name of ['Backends', 'Activity', 'Failover']) {
await expect(sub.getByRole('link', { name })).toBeVisible()
}
})
@@ -93,15 +103,15 @@ test.describe('Operate overview', () => {
route.fulfill({ json: { distributed: false, agents: true, mcp: true } }))
await mockQuiet(page)
await page.goto('/app/operate')
const rail = page.locator('.console-rail')
await expect(rail.locator('a.nav-item[href="/app/backends"]')).toBeVisible()
// Gating is the thing most likely to break silently when items move group.
// The Nodes route stays reachable, but as "This machine" in Runtime: a
// single host is not a cluster.
await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toHaveCount(1)
await expect(rail.locator('a.nav-item[href="/app/nodes"]')).toContainText('This machine')
await expect(rail.locator('a.nav-item', { hasText: /^Nodes/ })).toHaveCount(0)
await expect(rail.locator('a.nav-item[href="/app/scheduling"]')).toHaveCount(0)
const bar = page.locator('.dk-hubtabs')
await expect(bar.locator('a[href="/app/backends"]')).toBeVisible()
// Gating is the thing most likely to break silently when items move.
// The Nodes route stays reachable, but as "This machine": a single host is
// not a cluster. Swarm only exists in distributed mode.
await expect(bar.locator('a[href="/app/nodes"]')).toHaveCount(1)
await expect(bar.locator('a[href="/app/nodes"]')).toContainText('This machine')
await expect(bar.locator('[data-hub-tab="swarm"]')).toHaveCount(0)
await expect(bar.locator('a[href="/app/scheduling"]')).toHaveCount(0)
})
test('the sidebar keeps its operations badge', async ({ page }) => {
@@ -131,7 +141,7 @@ test.describe('Operate overview', () => {
})
})
test.describe('Operate overview headline', () => {
test.describe('Operate overview ledger', () => {
const SUMMARY = {
total: 18402, errors: 37, p95_ms: 842, window_hours: 24,
buckets: Array.from({ length: 12 }, (_, i) => ({ count: 100 + i * 10, errors: i })),
@@ -143,35 +153,39 @@ test.describe('Operate overview headline', () => {
await page.route('**/api/traces/summary', route => route.fulfill({ json: SUMMARY }))
await mockQuiet(page)
await page.goto('/app/operate')
const headline = page.locator('.operate-headline')
await expect(headline).toBeVisible()
await expect(headline).toContainText('18,402')
await expect(headline).toContainText('37')
await expect(headline).toContainText('842')
// Failed requests are a problem, so the row opens by itself.
const row = page.getByTestId('operate-row-failures')
await expect(row).toContainText('37 failed of 18,402 requests')
await expect(row.getByTestId('operate-traffic')).toContainText('842 ms')
// The whole point of the endpoint: three numbers, not the buffer.
expect(listCalls).toBe(0)
})
test('a quiet installation keeps the grid and says why it is empty', async ({ page }) => {
// Hiding the grid removed the page's structure exactly when someone was
test('a quiet installation keeps the row and says why it is empty', async ({ page }) => {
// Hiding the row removed the page's structure exactly when someone was
// most likely to be looking at it, and "0 failed" is information.
await page.route('**/api/traces/summary', route =>
route.fulfill({ json: { total: 0, errors: 0, p95_ms: 0, window_hours: 24, buckets: [] } }))
await mockQuiet(page)
await page.goto('/app/operate')
await expect(page.locator('.operate-headline')).toBeVisible()
await expect(page.locator('.operate-headline__cell')).toHaveCount(4)
await expect(page.locator('.operate-headline__note')).toBeVisible()
const row = page.getByTestId('operate-row-failures')
await expect(row).toBeVisible()
await expect(row).toContainText('No requests recorded in the last 24 hours.')
await expect(row.locator('.op-row__head')).toHaveAttribute('aria-expanded', 'false')
})
test('the sections state counts rather than listing their destinations', async ({ page }) => {
await page.route('**/api/traces/summary', route =>
route.fulfill({ json: { total: 18402, errors: 37, p95_ms: 842, window_hours: 24, buckets: [] } }))
await mockQuiet(page)
test('the Running now row states counts rather than listing destinations', async ({ page }) => {
await mockQuiet(page, {
operations: [{ id: 'op-1', name: 'qwen3-8b', type: 'install', progress: 40 }],
})
// A single node: the cluster API is not mounted.
await page.route('**/api/features', route => route.fulfill({ json: { distributed: false, agents: true, mcp: true } }))
await page.route('**/api/nodes', route => route.fulfill({ status: 404, json: { message: 'Not Found' } }))
await page.route('**/system', route => route.fulfill({ json: { backends: [], loaded_models: [{ id: 'm1', backend: 'llama-cpp' }] } }))
await page.goto('/app/operate')
const runtime = page.locator('.lanes--sections .lane').first()
await expect(runtime).toContainText('backends')
await expect(runtime).toContainText('running')
const row = page.getByTestId('operate-row-running')
await expect(row).toContainText('1 operation in progress')
await expect(row).toContainText('1 model loaded')
})
test('shows host capacity from the shared Operate summary', async ({ page }) => {
@@ -187,11 +201,10 @@ test.describe('Operate overview headline', () => {
await page.goto('/app/operate')
const capacity = page.locator('[data-testid="operate-capacity"]')
await expect(capacity).toBeVisible()
await expect(capacity).toContainText('Host capacity')
await expect(capacity).toContainText('NVIDIA L40S')
const capacity = page.getByTestId('operate-row-capacity')
await expect(capacity).toContainText('GPU memory')
await expect(capacity).toContainText('42%')
await expect(page.getByTestId('operate-capacity')).toContainText('GPU memory in use')
})
test('states when host capacity is unavailable', async ({ page }) => {
@@ -203,8 +216,8 @@ test.describe('Operate overview headline', () => {
await page.goto('/app/operate')
const capacity = page.locator('[data-testid="operate-capacity"]')
await expect(capacity).toContainText('Host capacity unavailable')
await expect(page.getByTestId('operate-row-capacity')).toContainText('Capacity is not reported by this host.')
await expect(page.getByTestId('operate-capacity-unavailable')).toBeVisible()
})
test('states when the host reports no capacity fields', async ({ page }) => {
@@ -213,7 +226,6 @@ test.describe('Operate overview headline', () => {
await page.goto('/app/operate')
const capacity = page.locator('[data-testid="operate-capacity"]')
await expect(capacity).toContainText('No capacity data reported')
await expect(page.getByTestId('operate-row-capacity')).toContainText('Capacity is not reported by this host.')
})
})
@@ -0,0 +1,271 @@
import { test, expect } from './coverage-fixtures.js'
import {
FAILED_OP, NODES, RUNNING_OP, TRACES_ERRORS, UPGRADE_LLAMA, CATALOG, gpuHost, mockOperate,
} from './operate-fixtures.js'
// The Status page: one sentence, then four rows (Needs you, Capacity, Running
// now, Recent failures). Only a row with a problem opens by itself, and the
// button in it does the thing it says.
const row = (page, id) => page.getByTestId(`operate-row-${id}`)
const head = (page, id) => row(page, id).locator('.op-row__head')
const headline = page => page.getByTestId('operate-headline')
test.describe('Status headline and ledger', () => {
test('a healthy installation says so and keeps every row closed', async ({ page }) => {
await mockOperate(page)
await page.goto('/app/operate')
await expect(headline(page)).toHaveText('Everything is running.')
await expect(headline(page)).toHaveAttribute('data-kind', 'ok')
for (const id of ['needs', 'capacity', 'running', 'failures']) {
await expect(head(page, id)).toHaveAttribute('aria-expanded', 'false')
}
await expect(page.getByTestId('operate-attention-clear')).toHaveText('Nothing needs you')
await expect(row(page, 'capacity')).toContainText('GPU memory 18.0 GB of 24.0 GB in use (75%)')
await expect(row(page, 'running')).toContainText('3 models loaded')
await expect(row(page, 'failures')).toContainText('1,204 requests in the last 24 hours, none failed')
})
test('counts what needs a person, and opens only the rows with a problem', async ({ page }) => {
await mockOperate(page, {
resources: gpuHost({ usedGB: 22.5 }),
upgrades: UPGRADE_LLAMA,
operations: [FAILED_OP],
traces: TRACES_ERRORS,
})
await page.goto('/app/operate')
await expect(headline(page)).toHaveText('2 things need you.')
await expect(page.getByTestId('operate-attention-item')).toHaveCount(2)
await expect(head(page, 'needs')).toHaveAttribute('aria-expanded', 'true')
await expect(head(page, 'capacity')).toHaveAttribute('aria-expanded', 'true')
await expect(head(page, 'failures')).toHaveAttribute('aria-expanded', 'true')
// Work in flight is not a problem.
await expect(head(page, 'running')).toHaveAttribute('aria-expanded', 'false')
await expect(row(page, 'failures')).toContainText('37 failed of 18,402 requests')
})
test('says "thing" for one', async ({ page }) => {
await mockOperate(page, { upgrades: UPGRADE_LLAMA })
await page.goto('/app/operate')
await expect(headline(page)).toHaveText('1 thing needs you.')
})
test('a full memory pool alone is something to look at, not something to decide', async ({ page }) => {
await mockOperate(page, { resources: gpuHost({ usedGB: 23 }) })
await page.goto('/app/operate')
await expect(headline(page)).toHaveAttribute('data-kind', 'looking')
await expect(headline(page)).toHaveText('Running, with 1 thing to look at.')
await expect(head(page, 'capacity')).toHaveAttribute('aria-expanded', 'true')
await expect(row(page, 'capacity')).toContainText('Memory is 96% full')
})
test('says it is reading until every answer is in', async ({ page }) => {
await mockOperate(page, { delayMs: 1500 })
await page.goto('/app/operate')
await expect(headline(page)).toHaveAttribute('data-kind', 'loading')
await expect(page.getByTestId('operate-overview')).toHaveAttribute('aria-busy', 'true')
await expect(head(page, 'needs')).toBeDisabled()
await expect(headline(page)).toHaveAttribute('data-kind', 'ok', { timeout: 10_000 })
await expect(page.getByTestId('operate-overview')).toHaveAttribute('aria-busy', 'false')
})
test('a first run offers a backend and the model list instead of a report', async ({ page }) => {
await mockOperate(page, {
catalog: CATALOG.map(b => ({ ...b, installed: false })),
installed: [],
models: { data: [] },
loaded: [],
resources: gpuHost({ usedGB: 0.4 }),
})
await page.goto('/app/operate')
await expect(headline(page)).toHaveText('Nothing is running yet.')
await expect(page.getByTestId('operate-first-run').getByRole('link', { name: 'Install a backend' }))
.toHaveAttribute('href', '/app/backends?view=catalog')
await expect(page.getByTestId('operate-first-run').getByRole('link', { name: 'Browse models' }))
.toHaveAttribute('href', '/app/models')
// The rows are still there, saying what they would say.
await expect(row(page, 'running')).toContainText('Nothing is running.')
})
test('a cluster says so, sums memory across the workers and names the node that is down', async ({ page }) => {
await mockOperate(page, {
distributed: true,
nodes: NODES.map(n => (n.id === 'n3' ? { ...n, status: 'unhealthy', healthy: false } : n)),
})
await page.goto('/app/operate')
await expect(page.locator('.op-status__scope')).toHaveText('Cluster, 4 nodes')
await expect(headline(page)).toHaveText('1 thing needs you.')
const item = page.getByTestId('operate-attention-item')
await expect(item).toContainText('n3 is not answering')
await expect(item.getByRole('link', { name: 'Open nodes' })).toHaveAttribute('href', '/app/nodes')
await expect(row(page, 'capacity')).toContainText('GPU memory 36.0 GB of 72.0 GB in use (50%) · across 3 nodes')
await expect(row(page, 'running')).toContainText('3 of 4 nodes healthy')
await head(page, 'capacity').click()
await expect(row(page, 'capacity').getByRole('img', { name: /gpu-box-1: 18.0 GB of 24.0 GB in use/ })).toBeVisible()
await expect(page.getByTestId('operate-capacity')).toContainText('GPU memory in use, all nodes')
})
})
test.describe('Status actions', () => {
test('Update starts the backend update', async ({ page }) => {
await mockOperate(page, { upgrades: UPGRADE_LLAMA })
const calls = []
await page.route('**/api/backends/upgrade/llama-cpp', route => { calls.push(route.request().method()); return route.fulfill({ json: { status: 'ok' } }) })
await page.goto('/app/operate')
await page.getByTestId('operate-attention-item').getByRole('button', { name: 'Update' }).click()
await expect.poll(() => calls).toEqual(['POST'])
})
test('Retry installs the failed model again, after moving the failure to the record', async ({ page }) => {
await mockOperate(page, { operations: [FAILED_OP] })
const calls = []
await page.route('**/api/operations/job-gemma27/dismiss', route => { calls.push('dismiss'); return route.fulfill({ json: {} }) })
await page.route('**/api/models/install/**', route => { calls.push(new URL(route.request().url()).pathname); return route.fulfill({ json: {} }) })
await page.goto('/app/operate')
const item = page.getByTestId('operate-attention-item')
await expect(item).toContainText('gemma-3-27b did not finish')
await expect(item).toContainText('Connection reset by the registry')
await item.getByRole('button', { name: 'Retry' }).click()
await expect.poll(() => calls).toEqual(['dismiss', '/api/models/install/localai@gemma-3-27b'])
})
test('Dismiss moves a failure out of the list', async ({ page }) => {
await mockOperate(page, { operations: [FAILED_OP] })
const calls = []
await page.route('**/api/operations/job-gemma27/dismiss', route => { calls.push('dismiss'); return route.fulfill({ json: {} }) })
await page.goto('/app/operate')
await page.getByTestId('operate-attention-item').getByRole('button', { name: 'Dismiss' }).click()
await expect.poll(() => calls).toEqual(['dismiss'])
})
test('Unload asks first, then stops the model', async ({ page }) => {
await mockOperate(page, { resources: gpuHost({ usedGB: 22.5 }) })
const bodies = []
await page.route('**/backend/shutdown', route => { bodies.push(route.request().postDataJSON()); return route.fulfill({ json: {} }) })
await page.goto('/app/operate')
await page.getByRole('button', { name: 'Unload gemma-3-12b-it' }).click()
const dialog = page.getByRole('alertdialog')
await expect(dialog).toContainText('Stop gemma-3-12b-it?')
expect(bodies).toEqual([])
await dialog.getByRole('button', { name: 'Stop model' }).click()
await expect.poll(() => bodies).toEqual([{ model: 'gemma-3-12b-it' }])
})
test('leaving the dialog keeps the model loaded', async ({ page }) => {
await mockOperate(page, { resources: gpuHost({ usedGB: 22.5 }) })
let calls = 0
await page.route('**/backend/shutdown', route => { calls += 1; return route.fulfill({ json: {} }) })
await page.goto('/app/operate')
await page.getByRole('button', { name: 'Unload gemma-3-12b-it' }).click()
await page.getByRole('alertdialog').getByRole('button', { name: 'Cancel' }).click()
await expect(page.getByRole('alertdialog')).toHaveCount(0)
expect(calls).toBe(0)
})
})
test.describe('Status capacity chart', () => {
test('is drawn from readings taken while the page is open, and says so', async ({ page }) => {
await page.clock.install()
await mockOperate(page)
await page.goto('/app/operate')
const capacity = page.getByTestId('operate-capacity')
await expect(capacity).toContainText('Since you opened Operate. LocalAI keeps no memory history.')
// One reading is a bar, not a history.
await expect(page.getByTestId('operate-capacity-wait')).toBeVisible()
await expect(capacity.locator('.dk-chart-plot')).toHaveCount(0)
await page.clock.fastForward(16_000)
await expect(capacity.locator('.dk-chart-plot')).toBeVisible()
await expect(capacity.locator('.dk-chart-threshold-label')).toHaveText('capacity 24.0 GB')
await expect(capacity.locator('.dk-chart-label')).toHaveText('18.0 GB now')
// The data table behind it lists the same readings.
await capacity.locator('.dk-chart-data summary').click()
await expect(capacity.locator('.dk-chart-data tbody tr')).toHaveCount(2)
})
test('reads a value with the arrow keys', async ({ page }) => {
await page.clock.install()
await mockOperate(page)
await page.goto('/app/operate')
await page.clock.fastForward(16_000)
const plot = page.getByTestId('operate-capacity').locator('.dk-chart-plot')
await plot.focus()
await page.keyboard.press('ArrowLeft')
await expect(page.getByTestId('operate-capacity').locator('.dk-chart-readout')).toContainText('18.0 GB')
await page.keyboard.press('Escape')
await expect(page.getByTestId('operate-capacity').locator('.dk-chart-readout')).toContainText('Readings over')
})
test('keeps no more than a bounded number of readings', async ({ page }) => {
await page.clock.install()
await mockOperate(page)
await page.goto('/app/operate')
const capacity = page.getByTestId('operate-capacity')
await expect(capacity).toBeVisible()
// The page takes a reading on each poll. Advance the clock in small steps and
// let the page run between them, so every step lands on a mounted poller.
for (let i = 0; i < 20; i += 1) {
await page.clock.fastForward(16_000)
await page.waitForTimeout(40)
}
await capacity.locator('.dk-chart-data summary').click()
const rows = capacity.locator('.dk-chart-data tbody tr')
await expect.poll(() => rows.count()).toBeGreaterThan(5)
expect(await rows.count()).toBeLessThanOrEqual(240)
})
})
test.describe('Status layout', () => {
test('fits a phone without sideways scrolling and keeps every row reachable', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockOperate(page, { upgrades: UPGRADE_LLAMA, operations: [FAILED_OP] })
await page.goto('/app/operate')
await expect(headline(page)).toHaveText('2 things need you.')
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(1)
await expect(page.getByTestId('operate-attention-item').first().getByRole('button', { name: 'Update' })).toBeVisible()
await head(page, 'running').click()
await expect(head(page, 'running')).toHaveAttribute('aria-expanded', 'true')
})
test('has no coloured rail on the edge of a row or an item', async ({ page }) => {
await mockOperate(page, { upgrades: UPGRADE_LLAMA, operations: [FAILED_OP] })
await page.goto('/app/operate')
await expect(page.getByTestId('operate-attention-item').first()).toBeVisible()
const widths = await page.evaluate(() => [...document.querySelectorAll('.op-row, .op-row__head, .op-item')]
.map(el => parseFloat(getComputedStyle(el).borderLeftWidth)))
expect(widths.length).toBeGreaterThan(4)
expect(Math.max(...widths)).toBe(0)
})
test('moves nothing when motion is reduced', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockOperate(page)
await page.goto('/app/operate')
await expect(headline(page)).toBeVisible()
const duration = await page.evaluate(() => {
const el = document.querySelector('.op-row__toggle .lai-icon')
return parseFloat(getComputedStyle(el).transitionDuration)
})
expect(duration).toBeLessThan(0.001)
})
})
test('a running operation shows in the Running now row with its progress', async ({ page }) => {
await mockOperate(page, { operations: [RUNNING_OP] })
await page.goto('/app/operate')
await expect(row(page, 'running')).toContainText('1 operation in progress')
await head(page, 'running').click()
await expect(row(page, 'running')).toContainText('sglang')
await expect(row(page, 'running')).toContainText('38%')
await expect(row(page, 'running').getByRole('link', { name: 'Open Activity' })).toHaveAttribute('href', '/app/activity')
})
@@ -167,6 +167,8 @@ test('cancelling the last operation does not announce it as installed', async ({
await expect(page.locator('.operations-strip')).toContainText('Installing model')
await page.locator('.operation-card__cancel').click()
// The cancel waits for its undo window; "Cancel now" ends the wait.
await page.getByTestId('activity-undo-toast').getByRole('button', { name: 'Cancel now' }).click()
// The poll that empties the page is the same render that would raise the
// completion hold, so the strip has to be counted the instant the card
+76 -17
View File
@@ -1,26 +1,85 @@
import { test, expect } from './coverage-fixtures.js'
import { mockSwarm } from './swarm-fixtures.js'
// P2P (Swarm) admin page — renders in the no-auth test harness (isAdmin).
test.describe('P2P page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/p2p')
test.describe('when P2P is off', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/p2p')
})
test('renders the P2P distribution overview and capability list', async ({ page }) => {
await expect(page).toHaveURL(/\/app\/p2p$/)
await expect(page.getByRole('heading', { name: /P2P is not enabled/i })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Instance federation' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Model sharding' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Resource sharing' })).toBeVisible()
await expect(page.getByRole('heading', { name: /How to enable P2P/i })).toBeVisible()
})
test('hardware selector offers build targets and changes the command', async ({ page }) => {
const cpu = page.getByRole('radio', { name: 'CPU', exact: true })
const cuda = page.getByRole('radio', { name: 'CUDA 12', exact: true })
await expect(cpu).toBeVisible()
await expect(cuda).toBeVisible()
await expect(page.getByTestId('command-block').first()).not.toContainText('--gpus all')
await cuda.click()
await expect(cuda).toHaveAttribute('aria-checked', 'true')
await expect(page.getByTestId('command-block').first()).toContainText('--gpus all')
await expect(page.getByRole('heading', { name: /How to enable P2P/i })).toBeVisible()
})
})
test('renders the P2P distribution overview and capability cards', async ({ page }) => {
await expect(page).toHaveURL(/\/app\/p2p$/)
await expect(page.getByRole('heading', { name: /P2P Distribution Not Enabled/i })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Instance Federation' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Model Sharding' })).toBeVisible()
await expect(page.getByRole('heading', { name: 'Resource Sharing' })).toBeVisible()
await expect(page.getByRole('heading', { name: /How to Enable P2P/i })).toBeVisible()
})
test.describe('when P2P is on', () => {
const p2p = {
token: 'b64-network-token',
stats: { federated: { online: 2, total: 3 }, llama_cpp_workers: { online: 1, total: 1 }, mlx_workers: { online: 0, total: 0 } },
federation: { nodes: [{ id: 'peer-a', isOnline: true }, { id: 'peer-b', isOnline: true }, { id: 'peer-c', isOnline: false }] },
workers: { llama_cpp: { nodes: [{ id: 'rpc-1', isOnline: true }] }, mlx: { nodes: [] } },
}
test('hardware selector offers build targets and responds to selection', async ({ page }) => {
const cpu = page.getByRole('button').filter({ hasText: /^CPU$/ })
const cuda = page.getByRole('button').filter({ hasText: /^CUDA 12$/ })
await expect(cpu).toBeVisible()
await expect(cuda).toBeVisible()
await cuda.click() // selecting a build target must not break the page
await expect(page.getByRole('heading', { name: /How to Enable P2P/i })).toBeVisible()
test('shows the network token with a copy button, and who is online', async ({ page }) => {
await mockSwarm(page, { p2p })
await page.goto('/app/p2p')
await expect(page.getByTestId('p2p-token')).toContainText('b64-network-token', { timeout: 15_000 })
await expect(page.getByRole('tab', { name: /Federation/ })).toContainText('2/3 online')
const table = page.getByRole('table', { name: 'Connected instances' })
await expect(table.getByRole('row', { name: /peer-a/ })).toContainText('Online')
await expect(table.getByRole('row', { name: /peer-c/ })).toContainText('Offline')
await page.getByRole('button', { name: 'Copy token' }).click()
await expect(page.getByText(/Token copied to clipboard|Could not copy/)).toBeVisible()
})
test('shows the model sharding workers on their own tab', async ({ page }) => {
await mockSwarm(page, { p2p })
await page.goto('/app/p2p')
await page.getByRole('tab', { name: /Model sharding/ }).click()
await expect(page.getByRole('table', { name: 'llama.cpp RPC workers' })).toContainText('rpc-1')
await expect(page.getByTestId('peer-empty')).toContainText('No MLX workers connected yet')
await expect(page.getByRole('link', { name: 'Add a memory shard' }).first()).toHaveAttribute('href', '/app/nodes/add?join=shard')
})
test('hands the person to Add a node to bring a peer in', async ({ page }) => {
await mockSwarm(page, { p2p })
await page.goto('/app/p2p')
await page.getByRole('link', { name: 'Add a peer instance' }).click()
await expect(page).toHaveURL(/\/app\/nodes\/add\?join=peer$/)
await expect(page.getByRole('radio', { name: 'Peer instance' })).toHaveAttribute('aria-checked', 'true')
})
test('builds the federated server command from the token', async ({ page }) => {
await mockSwarm(page, { p2p })
await page.goto('/app/p2p')
await expect(page.getByTestId('command-block')).toContainText('-e TOKEN="b64-network-token"', { timeout: 15_000 })
await expect(page.getByTestId('command-block')).toContainText('local-ai-federated')
})
test('fits a phone', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockSwarm(page, { p2p })
await page.goto('/app/p2p')
await expect(page.getByTestId('p2p-token')).toBeVisible({ timeout: 15_000 })
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
})
})
})
@@ -0,0 +1,23 @@
import { test, expect } from './coverage-fixtures.js'
// A page header is its title and supporting line. The hub or section name is
// not repeated above the title as a small crumb: the sidebar and the hub tab
// bar already say where the page lives.
const PAGES = [
['/app/studio', 'Studio'],
['/app/agents', 'Agents'],
['/app/activity', 'Activity'],
]
for (const [route, title] of PAGES) {
test(`${route} shows its title with no crumb above it`, async ({ page }) => {
await page.goto(route)
const heading = page.locator('.page-header .page-title').first()
await expect(heading).toBeVisible({ timeout: 15_000 })
await expect(heading).toContainText(title)
await expect(page.locator('.page-header__eyebrow')).toHaveCount(0)
// Nothing sits in the lead block ahead of the title.
const lead = page.locator('.page-header__lead').first()
expect(await lead.evaluate((el) => el.firstElementChild?.tagName)).toBe('H1')
})
}
@@ -16,6 +16,12 @@ const PAGES = [
['/app/talk', 'Talk'],
['/app/3d', '3D Generation'],
['/app/usage', 'Usage'],
['/app/traffic', 'Traffic overview'],
['/app/traffic/models', 'Traffic models'],
['/app/traffic/host', 'GPU and host'],
['/app/traffic/prometheus', 'Prometheus'],
['/app/traces', 'Traces'],
['/app/middleware', 'Middleware'],
['/app/account', 'Account'],
['/app/studio', 'Studio'],
['/app/models', 'Models'],
@@ -24,7 +30,10 @@ const PAGES = [
['/app/activity', 'Activity'],
['/app/settings', 'Settings'],
['/app/nodes', 'Nodes'],
['/app/scheduling', 'Scheduling'],
['/app/scheduling', 'Placement rules'],
['/app/failover', 'Failover'],
['/app/nodes/add', 'Add a node'],
['/app/p2p', 'P2P'],
['/app/face', 'Face recognition'],
['/app/voice', 'Voice recognition'],
['/app/fine-tune', 'Fine-tuning'],
@@ -38,8 +47,9 @@ test.describe('Page render smoke', () => {
test(`renders ${label} (${path})`, async ({ page }) => {
await page.goto(path)
// .page-title for the normal header; .empty-state-title for pages that
// render a gated/empty state (e.g. Account when auth is disabled).
await expect(page.locator('.page-title, .view-bar__title, .empty-state-title').first()).toBeVisible({ timeout: 15_000 })
// render a gated/empty state (e.g. Account when auth is disabled); Talk
// carries its own header.
await expect(page.locator('.page-title, .view-bar__title, .empty-state-title, .dk-empty-title, .talk-hd h1, .op-status__title, .op-runtime h1, .sw-page h1, .tf-title, .st-title').first()).toBeVisible({ timeout: 15_000 })
await expect(page).toHaveURL(new RegExp(path.replace(/\//g, '\\/') + '$'))
})
}
@@ -0,0 +1,219 @@
import { test, expect } from './coverage-fixtures.js'
import { mockSwarm, clusterNodes, clusterRules } from './swarm-fixtures.js'
// The Swarm Placement rules page: rules written as sentences, where each model
// is loaded now, a side sheet with a preview of what the draft would do, and a
// delete that can be taken back. The preview is worked out in the browser from
// the node list, the labels and the loaded replicas, and says so.
const rule = page => page.getByTestId('rule-row')
test.describe('Placement rules: the list', () => {
test('writes each kind of rule as a sentence', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/scheduling')
await expect(rule(page)).toHaveCount(5, { timeout: 15_000 })
await expect(rule(page).nth(0)).toContainText('qwen3-8b-instruct keeps 2 to 3 replicas on nodes with gpu=4090. It is routed by prefix cache.')
await expect(rule(page).nth(1)).toContainText('llama-3.3-70b-q4 runs as one replica on a node with gpu=4090.')
await expect(rule(page).nth(3)).toContainText('bge-m3 runs on every node with role=edge.')
await expect(rule(page).nth(0)).toContainText('Auto-scaling')
await expect(rule(page).nth(3)).toContainText('Spread')
})
test('words the other shapes of replica count', async ({ page }) => {
await mockSwarm(page, {
rules: [
{ id: '1', model_name: 'a', min_replicas: 2, max_replicas: 0 },
{ id: '2', model_name: 'b', min_replicas: 3, max_replicas: 3, node_selector: { zone: 'x' } },
{ id: '3', model_name: 'c', min_replicas: 0, max_replicas: 4 },
{ id: '4', model_name: 'd', spread_all: true },
{ id: '5', model_name: 'e', route_policy: 'round_robin' },
],
})
await page.goto('/app/scheduling')
await expect(rule(page).nth(0)).toContainText('a keeps at least 2 replicas on any healthy node.')
await expect(rule(page).nth(1)).toContainText('b keeps 3 replicas on nodes with zone=x.')
await expect(rule(page).nth(2)).toContainText('c runs up to 4 replicas on any healthy node.')
await expect(rule(page).nth(3)).toContainText('d runs on every healthy node.')
await expect(rule(page).nth(4)).toContainText('e has no placement of its own')
await expect(rule(page).nth(4)).toContainText('Round robin routing')
})
test('shows where each model is loaded now, from the loaded replicas', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/scheduling')
await expect(rule(page).nth(0)).toContainText('Now placed on gpu-box-1, gpu-box-2', { timeout: 15_000 })
await expect(rule(page).nth(3)).toContainText('Now placed on edge-cpu')
})
test('says a model is not loaded when no replica is', async ({ page }) => {
await mockSwarm(page, { replicas: [] })
await page.goto('/app/scheduling')
await expect(rule(page).first()).toContainText('Not loaded now', { timeout: 15_000 })
})
test('shows an empty state with the next step', async ({ page }) => {
await mockSwarm(page, { rules: [] })
await page.goto('/app/scheduling')
await expect(page.getByTestId('rules-empty')).toContainText('No placement rules yet', { timeout: 15_000 })
await expect(page.getByTestId('rules-empty')).toContainText('Add a rule')
})
test('labels the placement preview as a preview and reads loaded replicas for it', async ({ page }) => {
await mockSwarm(page, { nodes: clusterNodes().map(node => (node.id === 'n-gpu3' ? { ...node, status: 'healthy', labels: { gpu: '4090' } } : node)) })
await page.goto('/app/scheduling')
const preview = page.getByTestId('placement-preview')
await expect(preview).toContainText('Preview', { timeout: 15_000 })
await expect(preview).toContainText('loaded now')
await expect(preview).toContainText('planned')
await expect(preview).toContainText('Worked out in this browser')
// Agent workers host no models, so they are not a column.
await expect(preview.getByRole('columnheader', { name: 'agent-host' })).toHaveCount(0)
const qwen = preview.getByRole('row', { name: /qwen3-8b-instruct/ })
await expect(qwen.getByRole('cell').filter({ hasText: 'loaded now' })).toHaveCount(2)
})
test('marks a shadowed rule and one that cannot be met', async ({ page }) => {
const until = new Date(Date.now() + 10 * 60_000).toISOString()
await mockSwarm(page, {
rules: [
{ id: '1', model_name: 'a', node_selector: { gpu: 'x' }, min_replicas: 1, shadowed: true },
{ id: '2', model_name: 'b', node_selector: { gpu: 'x' }, min_replicas: 5, unsatisfiable_until: until },
],
})
await page.goto('/app/scheduling')
await expect(rule(page).nth(0).locator('.scheduling-rule-shadowed')).toContainText('Shadowed', { timeout: 15_000 })
await expect(rule(page).nth(1)).toContainText('Unsatisfiable until')
})
})
test.describe('Placement rules: the sheet', () => {
test('previews where a draft rule would put its model, from the node labels', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Edit flux-dev' }).click()
const preview = page.getByTestId('rule-preview')
await expect(preview).toContainText('Preview')
// zone=b is a node that stopped answering, so nothing can take the rule.
await expect(preview).toContainText('No healthy node matches zone=b')
await expect(preview).toContainText('free memory and disk')
// Swap the label for one a healthy node carries.
await page.getByLabel('Node selector').getByRole('button', { name: /remove/i }).first().click()
await page.getByRole('combobox', { name: 'Selector key' }).fill('role')
await page.getByRole('combobox', { name: 'Selector value' }).fill('edge')
await page.getByRole('button', { name: 'Add selector' }).click()
await expect(preview).toContainText('would load on demand on one of 1 matching nodes: edge-cpu')
})
test('previews a spread and a shortfall', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Edit bge-m3' }).click()
await expect(page.getByRole('radio', { name: 'Spread' })).toHaveAttribute('aria-checked', 'true')
await expect(page.getByTestId('rule-preview')).toContainText('would run on all 1 matching nodes: edge-cpu')
await page.getByRole('radio', { name: 'Auto-scaling' }).click()
await page.getByLabel('Min replicas').fill('3')
await expect(page.getByTestId('rule-preview')).toContainText('would keep 3 replicas on edge-cpu')
await expect(page.getByTestId('rule-preview')).toContainText('2 more replicas than these nodes can hold')
})
test('saves a new rule with the fields it was given', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'New rule' }).click()
await expect(page.getByRole('button', { name: 'Save rule' })).toBeDisabled()
await page.getByRole('combobox', { name: 'Model' }).fill('new-model')
await page.keyboard.press('Escape')
await expect(page.getByTestId('rule-sheet')).toBeVisible()
await page.getByRole('radio', { name: 'Auto-scaling' }).click()
await page.getByLabel('Min replicas').fill('2')
await page.getByRole('combobox', { name: 'Selector key' }).fill('zone')
await page.getByRole('combobox', { name: 'Selector value' }).fill('a')
await page.getByRole('button', { name: 'Add selector' }).click()
await page.getByRole('button', { name: 'Save rule' }).click()
await expect.poll(() => log.find(entry => entry.path === '/api/nodes/scheduling' && entry.method === 'POST')?.body).toMatchObject({
model_name: 'new-model', node_selector: { zone: 'a' }, min_replicas: 2, max_replicas: 0, spread_all: false,
})
await expect(page.getByText('Placement rule saved')).toBeVisible()
await expect(page.getByTestId('rule-sheet')).toHaveCount(0)
})
test('opens on a rule that asks for two replicas when Failover sends a model here', async ({ page }) => {
await mockSwarm(page)
await page.goto('/app/scheduling?new=kokoro-82m&min=2')
await expect(page.getByTestId('rule-sheet')).toBeVisible({ timeout: 15_000 })
await expect(page.getByRole('radio', { name: 'Auto-scaling' })).toHaveAttribute('aria-checked', 'true')
await expect(page.getByLabel('Min replicas')).toHaveValue('2')
await expect(page.getByRole('combobox', { name: 'Model' })).toHaveValue('kokoro-82m')
await expect(page).not.toHaveURL(/new=/)
})
test('is a bottom sheet on a phone and fits the screen', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Edit qwen3-8b-instruct' }).click()
const sheet = page.getByTestId('rule-sheet')
await expect(sheet).toBeVisible()
// The sheet rises into place; measure once it has stopped.
await expect.poll(async () => { const b = await sheet.boundingBox(); return b.y + b.height }).toBeLessThanOrEqual(844)
const box = await sheet.boundingBox()
expect(box.width).toBeLessThanOrEqual(390)
expect(box.y).toBeGreaterThan(0)
await expect(sheet.getByRole('button', { name: 'Save rule' })).toBeInViewport()
})
})
test.describe('Placement rules: delete with undo', () => {
test('takes a rule back with Undo and sends nothing', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Delete kokoro-82m' }).click()
await expect(rule(page)).toHaveCount(4)
await expect(page.getByTestId('rule-undo-toast')).toContainText('Rule for kokoro-82m deleted.')
await page.getByTestId('rule-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect(rule(page)).toHaveCount(5)
await page.waitForTimeout(500)
expect(log.filter(entry => entry.method === 'DELETE')).toEqual([])
})
test('deletes when the toast is dismissed', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Delete kokoro-82m' }).click()
await page.getByTestId('rule-undo-toast').getByRole('button', { name: 'Dismiss' }).click()
await expect.poll(() => log.filter(entry => entry.method === 'DELETE').map(entry => entry.path)).toEqual(['/api/nodes/scheduling/kokoro-82m'])
await expect(page.getByText('Placement rule removed')).toBeVisible()
})
test('finishes a waiting delete when another is asked for', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Delete kokoro-82m' }).click()
await page.getByRole('button', { name: 'Delete bge-m3' }).click()
await expect.poll(() => log.filter(entry => entry.method === 'DELETE').map(entry => entry.path)).toEqual(['/api/nodes/scheduling/kokoro-82m'])
await expect(rule(page)).toHaveCount(3)
})
test('finishes a waiting delete when the page is left', async ({ page }) => {
const log = await mockSwarm(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Delete kokoro-82m' }).click()
await page.getByRole('link', { name: 'Nodes', exact: true }).click()
await expect.poll(() => log.filter(entry => entry.method === 'DELETE').length).toBe(1)
})
test('puts the rule back when it cannot be deleted', async ({ page }) => {
await mockSwarm(page)
await page.route('**/api/nodes/scheduling/*', route => (route.request().method() === 'DELETE'
? route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"database unavailable"}' })
: route.fallback()))
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Delete kokoro-82m' }).click()
await page.getByTestId('rule-undo-toast').getByRole('button', { name: 'Dismiss' }).click()
await expect(page.getByText(/Failed to remove rule/)).toBeVisible()
await expect(rule(page)).toHaveCount(clusterRules().length)
})
})
+29 -22
View File
@@ -35,27 +35,28 @@ async function mockScheduling(page, { rules = [rule], nodeList = nodes } = {}) {
}))
}
test.describe('Scheduling page', () => {
test.describe('Placement rules page', () => {
// Node labels are only ever needed while writing a rule's node selector, so
// they live in that field rather than in a card standing open above the
// rules whether or not anyone is writing one.
test('keeps no standing label browser on the page', async ({ page }) => {
await mockScheduling(page)
await page.goto('/app/scheduling')
await expect(page.getByText('llama-3.3')).toBeVisible()
await expect(page.getByTestId('rule-row').getByText('llama-3.3')).toBeVisible()
await expect(page.getByTestId('node-label-reference')).toHaveCount(0)
await expect(page.getByRole('button', { name: /node labels/i })).toHaveCount(0)
await expect(page.locator('.scheduling-node-card')).toHaveCount(0)
// Falcon GPU is a node name, and nothing on this page has a reason to
// enumerate node names until a selector is being filled.
await expect(page.getByText('Falcon GPU')).toHaveCount(0)
// Label values are for filling a selector. The placement preview names
// nodes, but it never lists the labels they carry.
await expect(page.getByText('NVIDIA', { exact: true })).toHaveCount(0)
await expect(page.getByText('gpu.vendor', { exact: true })).toHaveCount(0)
})
test('suggests the cluster\'s own label keys and values as the selector is typed', async ({ page }) => {
await mockScheduling(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
await page.getByRole('button', { name: 'New rule' }).click()
const keyInput = page.getByRole('combobox', { name: 'Selector key' })
await keyInput.click()
@@ -85,7 +86,7 @@ test.describe('Scheduling page', () => {
test('picks a suggestion from the keyboard', async ({ page }) => {
await mockScheduling(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
await page.getByRole('button', { name: 'New rule' }).click()
const keyInput = page.getByRole('combobox', { name: 'Selector key' })
await keyInput.fill('zon')
@@ -103,7 +104,7 @@ test.describe('Scheduling page', () => {
test('still accepts a label the cluster has never reported', async ({ page }) => {
await mockScheduling(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
await page.getByRole('button', { name: 'New rule' }).click()
await page.getByRole('combobox', { name: 'Selector key' }).fill('tenant')
await page.getByRole('combobox', { name: 'Selector value' }).fill('acme')
@@ -128,7 +129,7 @@ test.describe('Scheduling page', () => {
await expect(page.getByLabel('Model')).toHaveValue('llama-3.3')
await expect(page.getByLabel('Model')).toHaveAttribute('readonly', '')
await expect(page.getByRole('radio', { name: 'Auto-scale' })).toHaveAttribute('aria-checked', 'true')
await expect(page.getByRole('radio', { name: 'Auto-scaling' })).toHaveAttribute('aria-checked', 'true')
await expect(page.getByLabel('Node selector').getByText('gpu.vendor=nvidia', { exact: true })).toBeVisible()
await expect(page.getByLabel('Min replicas')).toHaveValue('2')
await expect(page.getByLabel('Max replicas')).toHaveValue('8')
@@ -147,15 +148,18 @@ test.describe('Scheduling page', () => {
await expect(page.getByLabel('Min replicas')).toHaveValue('2')
})
test('keeps a single add or edit form open and leaves Add blank', async ({ page }) => {
test('keeps a single sheet open and leaves New blank', async ({ page }) => {
await mockScheduling(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Edit llama-3.3' }).click()
await expect(page.locator('.scheduling-form')).toHaveCount(1)
await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
await expect(page.locator('.scheduling-form')).toHaveCount(1)
await expect(page.getByRole('combobox', { name: '' }).first()).toHaveValue('')
await expect(page.getByRole('combobox', { name: '' }).first()).toBeEnabled()
await expect(page.getByTestId('rule-sheet')).toHaveCount(1)
await page.keyboard.press('Escape')
await expect(page.getByTestId('rule-sheet')).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Edit llama-3.3' })).toBeFocused()
await page.getByRole('button', { name: 'New rule' }).click()
await expect(page.getByTestId('rule-sheet')).toHaveCount(1)
await expect(page.getByRole('combobox', { name: 'Model' })).toHaveValue('')
await expect(page.getByRole('combobox', { name: 'Model' })).toBeEnabled()
})
// The roster feeds suggestions and nothing else now, so failing to load it
@@ -166,9 +170,9 @@ test.describe('Scheduling page', () => {
await page.goto('/app/scheduling')
// The rules still render: the roster is not on their path.
await expect(page.getByText('llama-3.3')).toBeVisible()
await expect(page.getByTestId('rule-row').getByText('llama-3.3')).toBeVisible()
await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
await page.getByRole('button', { name: 'New rule' }).click()
await page.getByRole('combobox', { name: 'Selector key' }).fill('gpu.vendor')
await page.getByRole('combobox', { name: 'Selector value' }).fill('nvidia')
await page.getByRole('button', { name: 'Add selector' }).click()
@@ -207,7 +211,7 @@ test.describe('Scheduling page', () => {
await mockAliases(page)
await page.goto('/app/scheduling')
await expect(page.getByText('production')).toBeVisible()
await expect(page.getByTestId('rule-row').getByText('production')).toBeVisible()
await expect(page.locator('.scheduling-rule-target')).toHaveText(/llama-3\.3/)
})
@@ -234,11 +238,11 @@ test.describe('Scheduling page', () => {
await mockScheduling(page)
await mockAliases(page)
await page.goto('/app/scheduling')
await page.getByRole('button', { name: 'Add Scheduling Rule' }).click()
await page.getByRole('button', { name: 'New rule' }).click()
const picker = page.locator('.searchable-model-select input')
const picker = page.getByRole('combobox', { name: 'Model' })
await picker.click()
await expect(page.locator('.sms-hint')).toHaveText('alias of llama-3.3')
await expect(page.locator('.dk-cmd-desc')).toHaveText('alias of llama-3.3')
await page.getByRole('option', { name: /production/ }).click()
await expect(page.getByText(/production is an alias for llama-3\.3/)).toBeVisible()
@@ -253,6 +257,9 @@ test.describe('Scheduling page', () => {
const actions = page.locator('.scheduling-rule-actions')
await expect(actions.getByRole('button', { name: 'Edit llama-3.3' })).toBeVisible()
await expect(actions.getByRole('button', { name: 'Delete llama-3.3' })).toBeVisible()
expect((await actions.boundingBox()).width).toBeGreaterThan(200)
const box = await actions.boundingBox()
expect(box.x).toBeGreaterThanOrEqual(0)
expect(box.x + box.width).toBeLessThanOrEqual(390)
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390)
})
})
@@ -1,17 +1,20 @@
import { test, expect } from './coverage-fixtures.js'
// The backend-logging, download and gallery settings, now under their intent
// groups. The page waits for edits to be applied, so saving is Apply.
test.describe('Settings - Backend Logging', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/settings')
// Wait for settings to load
await expect(page.locator('h3', { hasText: 'Tracing' })).toBeVisible({ timeout: 10_000 })
await expect(page.getByTestId('settings-page')).toBeVisible({ timeout: 10_000 })
await page.getByRole('button', { name: /^Debugging and traces/ }).click()
})
test('backend logging toggle is visible in tracing section', async ({ page }) => {
test('backend logging toggle is visible in the debugging group', async ({ page }) => {
await expect(page.locator('text=Enable Backend Logging')).toBeVisible()
})
test('artifact download concurrency is configurable', async ({ page }) => {
await page.getByRole('button', { name: /^Speed and defaults/ }).click()
const input = page.getByLabel('Artifact Download Concurrency')
await expect(input).toBeVisible()
await input.fill('4')
@@ -19,58 +22,34 @@ test.describe('Settings - Backend Logging', () => {
})
test('persistent VRAM cache can be toggled', async ({ page }) => {
const row = page.locator('.form-row', { hasText: 'Persist remote VRAM estimates' })
await expect(row).toBeVisible()
const checkbox = row.locator('input[type="checkbox"]')
const wasChecked = await checkbox.isChecked()
await checkbox.locator('..').click()
if (wasChecked) {
await expect(checkbox).not.toBeChecked()
} else {
await expect(checkbox).toBeChecked()
}
await page.getByRole('button', { name: /^Backends and galleries/ }).click()
const toggle = page.locator('.st-row', { hasText: 'Persist remote VRAM estimates' }).getByRole('switch')
await expect(toggle).toBeVisible()
const before = await toggle.getAttribute('aria-checked')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-checked', before === 'true' ? 'false' : 'true')
})
test('gallery startup loading and pre-warming can be toggled together', async ({ page }) => {
const row = page.locator('.form-row', { hasText: 'Load and pre-warm galleries on boot' })
await expect(row).toBeVisible()
const checkbox = row.locator('input[type="checkbox"]')
const wasChecked = await checkbox.isChecked()
await checkbox.locator('..').click()
if (wasChecked) {
await expect(checkbox).not.toBeChecked()
} else {
await expect(checkbox).toBeChecked()
}
await page.getByRole('button', { name: /^Backends and galleries/ }).click()
const toggle = page.locator('.st-row', { hasText: 'Load and pre-warm galleries on boot' }).getByRole('switch')
await expect(toggle).toBeVisible()
const before = await toggle.getAttribute('aria-checked')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-checked', before === 'true' ? 'false' : 'true')
})
test('backend logging toggle can be toggled', async ({ page }) => {
// Find the checkbox associated with backend logging
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
const checkbox = section.locator('input[type="checkbox"]').last()
// Toggle on
const wasChecked = await checkbox.isChecked()
await checkbox.locator('..').click()
if (wasChecked) {
await expect(checkbox).not.toBeChecked()
} else {
await expect(checkbox).toBeChecked()
}
const toggle = page.locator('.st-row', { hasText: 'Enable Backend Logging' }).getByRole('switch')
const before = await toggle.getAttribute('aria-checked')
await toggle.click()
await expect(toggle).toHaveAttribute('aria-checked', before === 'true' ? 'false' : 'true')
})
test('save shows toast', async ({ page }) => {
// Toggle a setting to enable the Save button (it's disabled when no changes)
const section = page.locator('div', { has: page.locator('text=Enable Backend Logging') })
const checkbox = section.locator('input[type="checkbox"]').last()
await checkbox.locator('..').click()
// Click save button
await page.locator('button', { hasText: /Save Changes/ }).click()
// Verify toast appears
test('apply shows toast', async ({ page }) => {
// The bar appears with the first edit and its Apply button saves.
await page.locator('.st-row', { hasText: 'Enable Backend Logging' }).getByRole('switch').click()
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.locator('text=Settings saved successfully')).toBeVisible({ timeout: 5_000 })
})
})
+54
View File
@@ -0,0 +1,54 @@
// Fixtures for the Settings page: one settings object in the shape of
// GET /api/settings (with the Go duration form, "15m0s"), and one function that
// answers the calls the page makes and records what it is sent.
//
// Pure of Playwright imports so a script that is not a test can use it too.
export function settingsFixture(overrides = {}) {
return {
watchdog_enabled: true, watchdog_idle_enabled: true, watchdog_busy_enabled: false,
watchdog_idle_timeout: '15m0s', watchdog_busy_timeout: '5m0s', watchdog_interval: '500ms',
max_active_backends: 0, auto_upgrade_backends: false, prefer_development_backends: false,
memory_reclaimer_enabled: true, memory_reclaimer_threshold: 0.9,
force_eviction_when_busy: false, size_aware_eviction: false,
lru_eviction_max_retries: 50, lru_eviction_retry_interval: '1s',
threads: 8, context_size: 8192, artifact_download_concurrency: 4, vram_budget: '80%', f16: false, debug: false,
enable_tracing: true, tracing_max_items: 500, tracing_max_body_bytes: 65536, enable_backend_logging: true,
cors: true, csrf: false, cors_allow_origins: 'https://app.example.org',
p2p_token: '', p2p_network_id: '', federated: false,
galleries: [{ url: 'https://index.localai.io/models', name: 'localai' }],
backend_galleries: [{ url: 'https://index.localai.io/backends', name: 'localai' }],
autoload_galleries: true, autoload_backend_galleries: true, vram_persistent_cache: true,
api_keys: ['sk-shared-1'], agent_job_retention_days: 30,
open_responses_store_ttl: '0', agent_pool_enabled: true, agent_pool_default_model: 'qwen3-8b-instruct',
agent_pool_embedding_model: 'bge-m3', agent_pool_max_chunking_size: 400, agent_pool_chunk_overlap: 0,
agent_pool_enable_logs: false, agent_pool_collection_db_path: '', agent_pool_vector_engine: 'chromem',
agent_pool_database_url: '', agent_pool_agent_hub_url: '',
distributed_disk_headroom_check: true, localai_assistant_enabled: true,
instance_name: 'Lab AI', instance_tagline: '',
...overrides,
}
}
export const RESOURCES = { gpus: [{ used: 18.4e9, total: 24e9 }], ram: { used: 20e9, total: 64e9 } }
// Answers GET and POST /api/settings and /api/resources. `posts` collects the
// request bodies, in order. `failNext` makes the next POST answer 400.
export async function mockSettings(page, overrides = {}) {
const state = { posts: [], failNext: null, current: settingsFixture(overrides) }
await page.route('**/api/settings', async (route) => {
const req = route.request()
if (req.method() === 'GET') return route.fulfill({ json: state.current })
const body = req.postDataJSON()
state.posts.push(body)
if (state.failNext) {
const error = state.failNext
state.failNext = null
return route.fulfill({ status: 400, json: { success: false, error } })
}
state.current = { ...state.current, ...body }
return route.fulfill({ json: { success: true, message: 'Settings updated successfully' } })
})
await page.route('**/api/resources', route => route.fulfill({ json: RESOURCES }))
return state
}
@@ -0,0 +1,404 @@
import { test, expect } from './coverage-fixtures.js'
import { mockSettings } from './settings-fixtures.js'
const row = (page, key) => page.locator(`[data-field="${key}"]`)
const group = (page, name) => page.getByRole('button', { name: new RegExp(`^${name}`) })
const sw = (page, key) => row(page, key).getByRole('switch')
test.describe('Settings: groups and search', () => {
let state
test.beforeEach(async ({ page }) => {
state = await mockSettings(page)
await page.goto('/app/settings')
await expect(page.getByTestId('settings-page')).toBeVisible()
})
test('shows eight groups by intent and opens on memory and models', async ({ page }) => {
const names = await page.locator('.st-group .st-group__label').allTextContents()
expect(names).toEqual([
'Memory and models', 'Speed and defaults', 'Backends and galleries', 'Access and security',
'Debugging and traces', 'Agents and responses', 'Swarm and sharing', 'Look and feel',
])
await expect(page.locator('.st-group[aria-current="true"]')).toHaveText(/Memory and models/)
await expect(page.getByRole('heading', { level: 2, name: /Memory and models/ })).toBeVisible()
await expect(row(page, 'watchdog_idle_timeout')).toBeVisible()
await expect(row(page, 'debug')).toHaveCount(0)
})
test('every real field sits in exactly one group', async ({ page }) => {
const seen = new Set()
for (const name of await page.locator('.st-group .st-group__label').allTextContents()) {
await group(page, name).click()
for (const key of await page.locator('[data-field]').evaluateAll(els => els.map(e => e.getAttribute('data-field')))) {
expect(seen.has(key), `${key} is listed twice`).toBe(false)
seen.add(key)
}
}
// 53 settings plus the three branding images.
expect(seen.size).toBe(56)
await expect(page.getByLabel(/Search all 56 settings/)).toBeVisible()
})
test('a group lists its settings, and the memory group shows memory use now', async ({ page }) => {
await expect(page.getByTestId('settings-memory-now')).toContainText('GPU memory now: 17.1 GB of 22.4 GB')
await expect(page.getByTestId('settings-memory-now')).toContainText('Eviction starts at 90%')
await group(page, 'Debugging and traces').click()
await expect(page.getByText('Record API traces')).toBeVisible()
await expect(page.getByText('Enable Backend Logging')).toBeVisible()
await expect(page.getByTestId('settings-memory-now')).toHaveCount(0)
})
test('search finds a setting by its name, its key and the section it used to be in', async ({ page }) => {
const search = page.getByRole('searchbox')
await search.fill('cors')
await expect(page.getByTestId('settings-results-title')).toHaveText(/3 settings for “cors”/)
await expect(row(page, 'cors_allow_origins')).toBeVisible()
// Results come from every group and say where each one lives now.
await expect(row(page, 'cors')).toContainText('Access and security')
await expect(row(page, 'cors')).toContainText('was API & CORS')
await search.fill('Watchdog')
await expect(row(page, 'watchdog_busy_timeout')).toContainText('was Watchdog')
await expect(row(page, 'size_aware_eviction')).toBeVisible()
await search.fill('Memory Reclaimer')
await expect(row(page, 'memory_reclaimer_threshold')).toBeVisible()
await search.fill('lru_eviction_max')
await expect(page.locator('[data-field]')).toHaveCount(1)
await search.fill('zzzz')
await expect(page.getByTestId('settings-results-title')).toHaveText('No settings found')
await page.getByRole('button', { name: 'Clear search' }).click()
await expect(search).toHaveValue('')
await expect(row(page, 'watchdog_idle_timeout')).toBeVisible()
})
test('Escape clears the search and a group click leaves search mode', async ({ page }) => {
const search = page.getByRole('searchbox')
await search.fill('debug')
await expect(page.getByTestId('settings-results')).toBeVisible()
await search.press('Escape')
await expect(search).toHaveValue('')
await search.fill('debug')
await group(page, 'Swarm and sharing').click()
await expect(search).toHaveValue('')
await expect(row(page, 'p2p_token')).toBeVisible()
})
})
test.describe('Settings: defaults and hints', () => {
test.beforeEach(async ({ page }) => {
await mockSettings(page)
await page.goto('/app/settings')
await expect(page.getByTestId('settings-page')).toBeVisible()
})
test('marks a value as changed only when the built-in default is known', async ({ page }) => {
// lru_eviction_max_retries is 50 against a default of 30.
const retries = row(page, 'lru_eviction_max_retries')
await expect(retries.locator('.st-tag', { hasText: 'Changed' })).toBeVisible()
await expect(retries).toContainText('default 30')
// The idle timeout equals its default in another spelling (15m0s and 15m).
await expect(row(page, 'watchdog_idle_timeout').locator('.st-tag', { hasText: 'Changed' })).toHaveCount(0)
// The memory threshold is 90% against 95%.
await expect(row(page, 'memory_reclaimer_threshold')).toContainText('default 95%')
// Threads has no default the code states, so no marker is drawn however
// far the value is from anything.
await group(page, 'Speed and defaults').click()
await expect(row(page, 'threads').locator('.st-tag', { hasText: 'Changed' })).toHaveCount(0)
await expect(row(page, 'threads')).not.toContainText('default')
await expect(row(page, 'context_size')).not.toContainText('default')
await expect(row(page, 'artifact_download_concurrency')).toContainText('default 1')
})
test('counts the settings that differ from their default', async ({ page }) => {
const count = Number(await page.getByTestId('settings-changed-count').locator('.dk-mono').textContent())
expect(count).toBeGreaterThan(0)
await row(page, 'lru_eviction_max_retries').getByRole('button', { name: 'Reset' }).click()
await expect(row(page, 'lru_eviction_max_retries').getByRole('spinbutton')).toHaveValue('30')
await expect(page.getByTestId('settings-changed-count').locator('.dk-mono')).toHaveText(String(count - 1))
await expect(page.getByTestId('settings-pending')).toContainText('1 pending')
})
test('says when a setting applies only where the server says so', async ({ page }) => {
await expect(row(page, 'watchdog_idle_timeout')).toContainText('Applies now')
await expect(row(page, 'max_active_backends')).toContainText('Applies now')
await group(page, 'Agents and responses').click()
await expect(row(page, 'agent_pool_enable_logs')).toContainText('Needs restart')
await expect(row(page, 'agent_job_retention_days')).toContainText('Applies now')
await group(page, 'Speed and defaults').click()
await expect(row(page, 'threads')).not.toContainText('Applies now')
await expect(row(page, 'threads')).not.toContainText('Needs restart')
})
test('CSRF protection reads the right way round', async ({ page }) => {
// The wire field carries "disable CSRF", and the fixture has it false.
await group(page, 'Access and security').click()
await expect(sw(page, 'csrf')).toHaveAttribute('aria-checked', 'true')
})
test('a setting that needs another one is disabled while that one is off', async ({ page }) => {
await expect(row(page, 'watchdog_busy_timeout').getByRole('textbox')).toBeDisabled()
await sw(page, 'watchdog_busy_enabled').click()
await expect(row(page, 'watchdog_busy_timeout').getByRole('textbox')).toBeEnabled()
})
})
test.describe('Settings: pending, diff, apply, discard and undo', () => {
let state
test.beforeEach(async ({ page }) => {
state = await mockSettings(page)
await page.goto('/app/settings')
await expect(page.getByTestId('settings-page')).toBeVisible()
})
test('shows no bar until something changes, then counts what is pending', async ({ page }) => {
await expect(page.getByTestId('settings-pending')).toHaveCount(0)
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await expect(page.getByTestId('settings-pending')).toContainText('1 pending')
await expect(row(page, 'watchdog_idle_timeout').locator('.st-tag', { hasText: 'Draft' })).toBeVisible()
await expect(page.locator('.st-group[data-group="memory"] [title="Has a draft"]')).toBeVisible()
await row(page, 'max_active_backends').getByRole('spinbutton').fill('3')
await expect(page.getByTestId('settings-pending')).toContainText('2 pending')
})
test('the diff lists old and new values with the checks the browser can make', async ({ page }) => {
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await sw(page, 'force_eviction_when_busy').click()
await page.getByRole('button', { name: 'Show diff' }).click()
const diff = page.getByTestId('settings-diff')
await expect(diff.locator('li.st-diff__row')).toHaveCount(2)
await expect(diff).toContainText('watchdog_idle_timeout')
await expect(diff.locator('del').first()).toHaveText('15m')
await expect(diff).toContainText('30m')
await expect(diff).toContainText('off')
await expect(diff.getByLabel('Checks')).toContainText('30m is a valid duration for idle timeout')
await expect(diff.getByLabel('Checks')).toContainText('Evicting while busy can interrupt requests in flight')
await page.getByRole('button', { name: 'Hide diff' }).click()
await expect(diff).toHaveCount(0)
})
test('an invalid duration is an error and stops Apply', async ({ page }) => {
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('soon')
await page.getByRole('button', { name: 'Show diff' }).click()
await expect(page.getByLabel('Checks')).toContainText('"soon" is not a duration')
await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled()
await expect(page.getByTestId('settings-pending')).toContainText('fix the errors to apply')
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('1h30m')
await expect(page.getByRole('button', { name: 'Apply' })).toBeEnabled()
})
test('a restart-only change is counted and warned about', async ({ page }) => {
await group(page, 'Agents and responses').click()
await sw(page, 'agent_pool_enable_logs').click()
await expect(page.getByTestId('settings-pending')).toContainText('1 pending')
await expect(page.getByTestId('settings-pending')).toContainText('1 needs a restart')
await page.getByRole('button', { name: 'Show diff' }).click()
await expect(page.getByLabel('Checks')).toContainText('Restart LocalAI for Agent logs')
})
test('apply sends only the changed keys, then offers undo', async ({ page }) => {
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByText('Settings saved successfully')).toBeVisible()
expect(state.posts).toEqual([{ watchdog_idle_timeout: '30m' }])
await expect(page.getByTestId('settings-pending')).toHaveCount(0)
const toast = page.getByTestId('settings-undo-toast')
await expect(toast).toContainText('Settings saved successfully (1 change). Undo saves the old values again.')
await toast.getByRole('button', { name: 'Undo' }).click()
await expect(page.getByText('Previous values saved again')).toBeVisible()
// Undo is a second save with the old value, not a rollback.
expect(state.posts).toEqual([{ watchdog_idle_timeout: '30m' }, { watchdog_idle_timeout: '15m' }])
await expect(row(page, 'watchdog_idle_timeout').getByRole('textbox')).toHaveValue('15m')
await expect(page.getByTestId('settings-pending')).toHaveCount(0)
})
test('the master watchdog switch sends the three flags it controls', async ({ page }) => {
await sw(page, 'watchdog_enabled').click()
await expect(page.getByTestId('settings-pending')).toContainText('1 pending')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-undo-toast')).toBeVisible()
expect(state.posts[0]).toEqual({ watchdog_enabled: false, watchdog_idle_enabled: false, watchdog_busy_enabled: false })
})
test('discard drops every edit', async ({ page }) => {
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await sw(page, 'size_aware_eviction').click()
await page.getByRole('button', { name: 'Discard' }).click()
await expect(page.getByTestId('settings-pending')).toHaveCount(0)
await expect(row(page, 'watchdog_idle_timeout').getByRole('textbox')).toHaveValue('15m')
await expect(sw(page, 'size_aware_eviction')).toHaveAttribute('aria-checked', 'false')
expect(state.posts).toEqual([])
})
test('a failed save keeps the edits and says why', async ({ page }) => {
state.failNext = 'Invalid watchdog_idle_timeout format'
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByText(/Save failed/)).toBeVisible()
await expect(page.getByTestId('settings-pending')).toContainText('1 pending')
await expect(page.getByTestId('settings-undo-toast')).toHaveCount(0)
})
test('leaving with unapplied edits asks first', async ({ page }) => {
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await page.locator('.dk-hubtabs a[href="/app/traffic"]').click()
await expect(page.getByRole('alertdialog')).toBeVisible()
})
})
test.describe('Settings: fields with a different wire form', () => {
let state
test.beforeEach(async ({ page }) => {
state = await mockSettings(page)
await page.goto('/app/settings')
await expect(page.getByTestId('settings-page')).toBeVisible()
})
test('the CSRF switch writes the inverse of what it shows', async ({ page }) => {
await group(page, 'Access and security').click()
await sw(page, 'csrf').click()
await page.getByRole('button', { name: 'Show diff' }).click()
await expect(page.getByLabel('Checks')).toContainText('forged cross-site browser requests are not blocked')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-undo-toast')).toBeVisible()
expect(state.posts[0]).toEqual({ csrf: true })
})
test('shared API keys are sent as a list, and never shown in the diff or the history', async ({ page }) => {
await group(page, 'Access and security').click()
await row(page, 'api_keys').getByRole('textbox').fill('sk-shared-1\nsk-shared-2, sk-shared-3')
await page.getByRole('button', { name: 'Show diff' }).click()
await expect(page.getByTestId('settings-diff')).toContainText('Shared API keys')
await expect(page.getByTestId('settings-diff')).not.toContainText('sk-shared-2')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-undo-toast')).toBeVisible()
expect(state.posts[0]).toEqual({ api_keys: ['sk-shared-1', 'sk-shared-2', 'sk-shared-3'] })
await page.getByRole('button', { name: 'History' }).click()
const hist = page.getByTestId('settings-history')
await expect(hist).toContainText('Shared API keys')
await expect(hist).not.toContainText('sk-shared-2')
await expect(hist.getByRole('button', { name: 'Revert' })).toHaveCount(0)
})
test('a gallery list is parsed, and bad JSON stops Apply', async ({ page }) => {
await group(page, 'Backends and galleries').click()
const box = row(page, 'galleries').getByRole('textbox')
await box.fill('{oops')
await page.getByRole('button', { name: 'Show diff' }).click()
await expect(page.getByLabel('Checks')).toContainText('this must be a JSON list')
await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled()
await box.fill('[{"url":"https://example.org/models","name":"mine"}]')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-undo-toast')).toBeVisible()
expect(state.posts[0]).toEqual({ galleries: [{ url: 'https://example.org/models', name: 'mine' }] })
})
test('the GPU memory budget is checked the way the server checks it', async ({ page }) => {
const budget = row(page, 'vram_budget').getByRole('textbox')
await budget.fill('150%')
await page.getByRole('button', { name: 'Show diff' }).click()
await expect(page.getByLabel('Checks')).toContainText('between 0 and 100')
await expect(page.getByRole('button', { name: 'Apply' })).toBeDisabled()
await budget.fill('12GB')
await expect(page.getByLabel('Checks')).toContainText('12GB is a valid GPU memory budget')
await expect(page.getByRole('button', { name: 'Apply' })).toBeEnabled()
})
test('a new P2P token is generated by the server, so the page sends 0', async ({ page }) => {
await group(page, 'Swarm and sharing').click()
await row(page, 'p2p_token').getByRole('button', { name: /Generate/ }).click()
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-undo-toast')).toBeVisible()
expect(state.posts[0]).toEqual({ p2p_token: '0' })
})
})
test.describe('Settings: history', () => {
test.beforeEach(async ({ page }) => {
await mockSettings(page)
await page.goto('/app/settings')
await expect(page.getByTestId('settings-page')).toBeVisible()
})
test('starts empty and says what it is', async ({ page }) => {
await page.getByRole('button', { name: 'History' }).click()
const sheet = page.getByTestId('settings-history')
await expect(sheet).toContainText('LocalAI keeps no settings log')
await expect(sheet).toContainText('Nothing applied yet')
await page.keyboard.press('Escape')
await expect(sheet).toHaveCount(0)
})
test('lists applied changes, newest first, and revert stages the old value as an edit', async ({ page }) => {
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-undo-toast')).toBeVisible()
await sw(page, 'size_aware_eviction').click()
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-pending')).toHaveCount(0)
await page.getByRole('button', { name: 'History' }).click()
const rows = page.getByTestId('history-row')
await expect(rows).toHaveCount(2)
await expect(rows.first()).toContainText('Evict the largest model first')
await expect(rows.first()).toContainText('off → on')
await expect(rows.nth(1)).toContainText('15m → 30m')
await rows.nth(1).getByRole('button', { name: 'Revert' }).click()
await expect(page.getByTestId('settings-history')).toHaveCount(0)
await expect(row(page, 'watchdog_idle_timeout').getByRole('textbox')).toHaveValue('15m')
await expect(page.getByTestId('settings-pending')).toContainText('1 pending')
})
test('survives a reload and can be cleared', async ({ page }) => {
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
await page.getByRole('button', { name: 'Apply' }).click()
await expect(page.getByTestId('settings-undo-toast')).toBeVisible()
await page.reload()
await expect(page.getByTestId('settings-page')).toBeVisible()
await page.getByRole('button', { name: 'History' }).click()
await expect(page.getByTestId('history-row')).toHaveCount(1)
await page.getByRole('button', { name: 'Clear this list' }).click()
await expect(page.getByTestId('settings-history')).toContainText('Nothing applied yet')
})
})
test.describe('Settings: phone', () => {
test.use({ viewport: { width: 390, height: 844 } })
test('stacks the groups above the settings, keeps labels wide and does not overflow', async ({ page }) => {
await mockSettings(page)
await page.goto('/app/settings')
await expect(page.getByTestId('settings-page')).toBeVisible()
const groups = await page.locator('.st-groups').boundingBox()
const content = await page.locator('.st-content').boundingBox()
expect(content.y).toBeGreaterThanOrEqual(groups.y + groups.height - 1)
const label = await page.locator('.st-row__main').first().boundingBox()
expect(label.width).toBeGreaterThan(150)
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
await row(page, 'watchdog_idle_timeout').getByRole('textbox').fill('30m')
const bar = page.getByTestId('settings-pending')
await expect(bar).toBeInViewport()
await page.getByRole('button', { name: 'Show diff' }).click()
await expect(page.getByTestId('settings-diff')).toBeVisible()
expect(await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth)).toBe(false)
})
})
test.describe('Settings: reduced motion', () => {
test('transitions on the page are switched off', async ({ page }) => {
await page.emulateMedia({ reducedMotion: 'reduce' })
await mockSettings(page)
await page.goto('/app/settings')
await expect(page.getByTestId('settings-page')).toBeVisible()
const seconds = await page.locator('.st-group').first().evaluate(el => parseFloat(getComputedStyle(el).transitionDuration))
expect(seconds).toBeLessThan(0.001)
const sw = await page.locator('.dk-switch').first().evaluate(el => parseFloat(getComputedStyle(el).transitionDuration))
expect(sw).toBeLessThan(0.001)
})
})
@@ -0,0 +1,90 @@
import { test, expect } from './coverage-fixtures.js'
// The collapsed sidebar is a single icon column. The signed-in avatar sat about
// ten pixels right of it: its link had a zero flex basis, so the icon spilled out
// of the link from the link's left edge instead of being centred in it.
const signedIn = (page) =>
page.route('**/api/auth/status', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
authEnabled: true,
staticApiKeyRequired: false,
providers: ['local'],
user: { id: 'u1', name: 'Admin', role: 'admin', provider: 'local' },
}),
})
)
// Centre x of every icon in the sidebar, keyed by what it is.
const centres = (page) =>
page.evaluate(() => {
const cx = (el) => {
const b = el.getBoundingClientRect()
return b.x + b.width / 2
}
const one = (sel) => {
const el = document.querySelector(sel)
return el ? cx(el) : null
}
return {
nav: [...document.querySelectorAll('.sidebar-nav .nav-icon')].map(cx),
mark: one('.sidebar-logo-icon-img'),
avatar: one('.sidebar-user-avatar, .sidebar-user-avatar-icon'),
language: one('.language-switcher-trigger svg'),
theme: one('.theme-toggle__icon'),
collapse: one('.sidebar-collapse-btn svg'),
}
})
for (const theme of ['light', 'dark']) {
test.describe(`sidebar icon column (${theme})`, () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript((t) => localStorage.setItem('localai-theme', t), theme)
await signedIn(page)
})
test('collapsed: nav icons, mark, avatar, language, theme and collapse share one centre', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('localai_sidebar_collapsed', 'true'))
await page.goto('/app')
await expect(page.locator('.sidebar.collapsed')).toBeVisible({ timeout: 15_000 })
await expect(page.locator('.sidebar-user-avatar, .sidebar-user-avatar-icon')).toBeVisible()
// Let the width transition and the icon fonts settle before measuring.
await page.waitForTimeout(600)
const c = await centres(page)
const column = c.nav[0]
expect(c.nav.length).toBeGreaterThan(3)
for (const x of c.nav) expect(Math.abs(x - column)).toBeLessThanOrEqual(1)
for (const key of ['mark', 'avatar', 'language', 'theme', 'collapse']) {
expect(c[key], `${key} is rendered`).not.toBeNull()
expect(Math.abs(c[key] - column), `${key} centre ${c[key]} vs column ${column}`).toBeLessThanOrEqual(1)
}
})
test('tablet rail: the same column holds at 800px wide', async ({ page }) => {
await page.setViewportSize({ width: 800, height: 900 })
await page.goto('/app')
await expect(page.locator('.sidebar-user-avatar, .sidebar-user-avatar-icon')).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(600)
const c = await centres(page)
const column = c.nav[0]
for (const key of ['avatar', 'language', 'theme']) {
expect(Math.abs(c[key] - column), `${key} centre ${c[key]} vs column ${column}`).toBeLessThanOrEqual(1)
}
})
test('expanded: avatar and language icon sit on the nav icon column', async ({ page }) => {
await page.addInitScript(() => localStorage.setItem('localai_sidebar_collapsed', 'false'))
await page.goto('/app')
await expect(page.locator('.sidebar-user-avatar, .sidebar-user-avatar-icon')).toBeVisible({ timeout: 15_000 })
await page.waitForTimeout(600)
const c = await centres(page)
const column = c.nav[0]
for (const x of c.nav) expect(Math.abs(x - column)).toBeLessThanOrEqual(1)
for (const key of ['avatar', 'language']) {
expect(Math.abs(c[key] - column), `${key} centre ${c[key]} vs column ${column}`).toBeLessThanOrEqual(1)
}
})
})
}
+173 -6
View File
@@ -1,20 +1,187 @@
import { test, expect } from './coverage-fixtures.js'
import { mockLibrary, SKILLS } from './library-fixtures.js'
// Skills page (src/pages/Skills.jsx): the library of instructions, who uses
// each one, and how to add one to an agent.
// Skills feature page (src/pages/Skills.jsx).
test.describe('Skills page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/app/skills')
})
test('renders the skills list with create affordances', async ({ page }) => {
await page.goto('/app/skills')
await expect(page).toHaveURL(/\/app\/skills$/)
await expect(page.getByRole('heading', { name: 'Skills', exact: true })).toBeVisible()
await expect(page.getByRole('button', { name: 'New skill' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Git Repos' })).toBeVisible()
await expect(page.getByRole('button', { name: 'Git repositories' })).toBeVisible()
})
test('New skill navigates to the skill editor', async ({ page }) => {
await page.goto('/app/skills')
await page.getByRole('button', { name: 'New skill' }).click()
await expect(page).toHaveURL(/\/app\/skills\/new$/)
})
})
test.describe('Skills library', () => {
test('each skill says who uses it, from the agents configs, and never claims Chat', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/skills')
await expect(page.getByTestId('skill-used-summarise-pdf')).toHaveText('Used by research-assistant +1')
await expect(page.getByTestId('skill-used-query-database')).toHaveText('Used by research-assistant')
await expect(page.getByTestId('skill-used-triage-issue')).toHaveText('Used by support-desk')
await expect(page.getByTestId('skill-used-translate-document')).toHaveText('Not used yet')
await expect(page.getByTestId('skill-used-weekly-digest')).toHaveText('Not used yet')
for (const skill of SKILLS) {
await expect(page.getByTestId(`skill-used-${skill.name}`)).not.toContainText(/chat/i)
}
})
test('an agent with skills on and no selection uses every skill', async ({ page }) => {
await mockLibrary(page, {
agents: { everything: { name: 'everything', model: 'm', enable_skills: true, selected_skills: [] } },
})
await page.goto('/app/skills?skill=weekly-digest')
await expect(page.getByTestId('skill-used-weekly-digest')).toHaveText('Used by everything')
const chip = page.getByTestId('used-by-everything')
await expect(chip).toContainText('all skills')
})
test('the Used and Not used yet filters split the list', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/skills')
await page.getByTestId('skills-filter-used').click()
await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(3)
await expect(page.getByTestId('skill-row-translate-document')).toHaveCount(0)
await page.getByTestId('skills-filter-unused').click()
await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(2)
await expect(page.getByTestId('skill-row-translate-document')).toBeVisible()
await expect(page.getByTestId('skill-row-weekly-digest')).toBeVisible()
await page.getByTestId('skills-filter-all').click()
await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(5)
})
test('search narrows the list to the matching skills', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/skills')
await page.getByTestId('skills-search').fill('triage')
await expect(page.locator('[data-testid^="skill-row-"]')).toHaveCount(1)
await expect(page.getByTestId('skill-row-triage-issue')).toBeVisible()
await page.getByTestId('skills-search').fill('zzzz')
await expect(page.getByTestId('skills-none')).toBeVisible()
})
test('the pane shows instructions, an estimate, files and who uses the skill', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/skills')
await page.getByTestId('skill-row-summarise-pdf').click()
await expect(page.getByTestId('skill-title')).toHaveText('summarise-pdf')
await expect(page.getByTestId('used-by-research-assistant').getByRole('link')).toHaveAttribute('href', '/app/agents/research-assistant')
await expect(page.getByTestId('used-by-handbook')).toBeVisible()
// 456 characters, 114 tokens by the stated rule
await expect(page.getByTestId('skill-tokens')).toContainText('About 114 tokens')
await page.getByRole('tab', { name: 'Instructions' }).click()
await expect(page.getByTestId('skill-content')).toContainText('Read the file the user attached')
await page.getByRole('tab', { name: 'Files' }).click()
await expect(page.getByText('scripts/extract.py')).toBeVisible()
await expect(page.getByText('references/style.md')).toBeVisible()
})
test('Add to... lists agents with the cost, and adding saves the agent', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/skills?skill=translate-document')
await expect(page.getByTestId('skill-not-used')).toBeVisible()
await page.getByTestId('add-to-button').click()
const menu = page.getByTestId('add-to-menu')
await expect(menu.getByTestId('add-to-research-assistant')).toContainText('tokens per message (estimate)')
await expect(menu.getByTestId('add-to-support-desk')).toContainText('Loaded when the model asks for it')
await menu.getByRole('button', { name: 'Add to handbook' }).click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0].name).toBe('handbook')
expect(seen.saves[0].body.selected_skills).toEqual(['summarise-pdf', 'translate-document'])
expect(seen.saves[0].body.enable_skills).toBe(true)
await expect(page.getByTestId('skill-used-translate-document')).toHaveText('Used by handbook')
await expect(page.getByTestId('skill-not-used')).toHaveCount(0)
})
test('adding to an agent with skills off turns them on with only that skill', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/skills?skill=translate-document')
await page.getByTestId('add-to-button').click()
await page.getByRole('button', { name: 'Add to idle-agent' }).click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0].body).toMatchObject({ name: 'idle-agent', enable_skills: true, selected_skills: ['translate-document'] })
})
test('removing a skill from an agent saves the rest, and Undo puts it back', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/skills?skill=query-database')
await page.getByRole('button', { name: 'Remove from research-assistant' }).click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0].body.selected_skills).toEqual(['summarise-pdf'])
await expect(page.getByTestId('skill-used-query-database')).toHaveText('Not used yet')
await page.getByTestId('library-undo-toast').getByRole('button', { name: 'Undo' }).click()
await expect.poll(() => seen.saves.length).toBe(2)
expect(seen.saves[1].body.selected_skills).toEqual(['summarise-pdf', 'query-database'])
await expect(page.getByTestId('skill-used-query-database')).toHaveText('Used by research-assistant')
})
test('removing the only skill switches skills off instead of selecting every skill', async ({ page }) => {
const { seen } = await mockLibrary(page)
await page.goto('/app/skills?skill=triage-issue')
await page.getByRole('button', { name: 'Remove from support-desk' }).click()
await expect.poll(() => seen.saves.length).toBe(1)
expect(seen.saves[0].body).toMatchObject({ name: 'support-desk', enable_skills: false, selected_skills: [] })
await expect(page.getByTestId('library-undo-toast')).toContainText('Skills are now off')
})
test('when the agents cannot be read the page says nothing about use', async ({ page }) => {
await mockLibrary(page, { agentsStatus: 500 })
await page.goto('/app/skills')
await expect(page.getByTestId('skill-row-summarise-pdf')).toBeVisible()
await expect(page.getByTestId('skill-used-summarise-pdf')).toHaveText('Use unknown')
await expect(page.getByTestId('skills-filter-used')).toBeDisabled()
await expect(page.locator('[data-testid^="skill-used-"]').filter({ hasText: 'Not used yet' })).toHaveCount(0)
await expect(page.getByTestId('used-by')).toContainText('could not be read')
})
test('an empty library teaches what a skill is', async ({ page }) => {
await mockLibrary(page, { skills: [] })
await page.goto('/app/skills')
const empty = page.getByTestId('skills-empty')
await expect(empty.getByRole('heading', { name: 'Skills are instructions an agent can follow' })).toBeVisible()
await expect(empty.getByRole('button', { name: 'Create skill' })).toBeVisible()
await expect(empty.getByText('Import', { exact: true })).toBeVisible()
})
test('Git repositories open in the pane and a repository can be added', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/skills')
await page.getByTestId('skills-git-toggle').click()
const git = page.getByTestId('skills-git')
await expect(git.getByText('No Git repos configured')).toBeVisible()
await git.getByLabel('Repository URL').fill('https://github.com/example/skills')
await git.getByRole('button', { name: 'Add repo' }).click()
await expect(git.getByText('https://github.com/example/skills')).toBeVisible()
})
test('on a phone the list and the skill take turns', async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 })
await mockLibrary(page)
await page.goto('/app/skills')
await expect(page.getByTestId('skill-row-summarise-pdf')).toBeVisible()
await expect(page.getByTestId('skill-pane')).toBeHidden()
await page.getByTestId('skill-row-summarise-pdf').click()
await expect(page.getByTestId('skill-title')).toBeVisible()
await expect(page.getByTestId('skill-row-summarise-pdf')).toBeHidden()
await page.getByRole('button', { name: 'All skills' }).click()
await expect(page.getByTestId('skill-row-summarise-pdf')).toBeVisible()
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
expect(overflow).toBeLessThanOrEqual(0)
})
test('wide, the first skill is open until another is picked', async ({ page }) => {
await mockLibrary(page)
await page.goto('/app/skills')
await expect(page.getByTestId('skill-title')).toHaveText('summarise-pdf')
await page.getByTestId('skill-row-triage-issue').click()
await expect(page.getByTestId('skill-title')).toHaveText('triage-issue')
})
})
Loaded 100 of 579 files, more files were not shown because too many files have changed in this diff. Show more