mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
The local model window could hold one model. A second install was refused (409 already-installed), and a model a Remove kept could never be freed from the window again. The store can now hold several models, and exactly one of them is the model in use: the one the endpoint record lists, the runtime is started for and a session may be launched on. Server - LocalModelStateFile.library lists every model that passed the check (id, label, context, digest, size, coding agent). The service writes the entry when verify passes; the reducer carries it and remove drops it. An installed file of an earlier build counts its one model. - POST /api/local-model/install is allowed from installed: it adds a model, and the earlier one stays. A replaced job no longer deletes a model of the library, only an unchecked or partial download. - POST /api/local-model/use switches to a checked model: a job of the two steps verify and connect. The stored digest is compared before anything is loaded (model-changed otherwise). Nothing is downloaded. - DELETE /api/local-model/models deletes one model by its name, through the runtime's own delete, never by a path, never the model in use. - Adding and switching answer 409 sessions-open while a live session uses the endpoint. Until such a job ends installed, a session start is refused with the new code job-unfinished. - The view carries library (the state's entries joined with what the running runtime lists: ready, changed, missing, unverified) and store (the size of the blob folder). Both are cached and refreshed in the background, never read on the one-second poll. - With nothing installed, remove is offered while the store still holds models an earlier Remove kept. Window - The installed screen lists the models on this Mac with the context each was checked with, its state and its size on disk, with Use and Delete per row (Delete asks once, in the row) and Add a model. - In the chooser a model that is on this Mac already says so and is switched to without a download or a confirm screen. - A paused or failed job that has a checked model beside it shows the way back to that model. Every route is admin only in multi-user mode, like the existing ones. Not run on a Mac: the tests use fakes and the fake runtime, and the listing, store size and delete were checked against a real Ollama 0.13.0 on Linux, not the pinned 0.40.15 build for macOS. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
525 lines
22 KiB
TypeScript
525 lines
22 KiB
TypeScript
/**
|
|
* @fileoverview Real-Chromium coverage for the "Run a model on this Mac" window
|
|
* (src/web/public/local-model-ui.js): what DOM emulation cannot answer, because it lays nothing
|
|
* out.
|
|
*
|
|
* - the numbers of the model list line up: every row's speed and download size end on the
|
|
* same edge, under their captions, and every tag starts on the same edge;
|
|
* - a row is one line on a desktop and two lines under 560px, where nothing runs off the
|
|
* side and the buttons take the full width;
|
|
* - the memory bar is 12px high and filled to the selected model's share of its track, and
|
|
* the fill follows the selection;
|
|
* - the selected row carries the feature's colour on its reading edge;
|
|
* - the welcome button is really gone while it is `hidden`, and the window paints above App
|
|
* Settings;
|
|
* - the models on this Mac: the sizes on disk end on one edge under their caption, a row's
|
|
* buttons end on the list's edge, the sizes do not move when a row's Delete question opens,
|
|
* a long list scrolls from its top (it is never cut off above the frame), and under 560px a
|
|
* row is two lines with nothing running off the side.
|
|
*
|
|
* The REAL index.html is served with its scripts taken out and the two shipping stylesheets
|
|
* inlined (mobile.css under the media query its `<link>` carries); the real module runs on a
|
|
* bare `CodemanApp` whose `_api` answers a fixture payload. The states themselves are pinned in
|
|
* JSDOM by test/local-model-ui.test.ts.
|
|
*
|
|
* Port: none (page.route on a fake origin, no server).
|
|
*/
|
|
|
|
import { readFileSync } from 'node:fs';
|
|
import { resolve } from 'node:path';
|
|
import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
|
|
import { chromium, type Browser, type Page } from 'playwright';
|
|
|
|
const dir = resolve(import.meta.dirname, '../src/web/public');
|
|
const read = (name: string) => readFileSync(resolve(dir, name), 'utf8');
|
|
const ORIGIN = 'http://app.test/';
|
|
|
|
/**
|
|
* index.html as shipped, minus every script, with styles.css and mobile.css inlined. The
|
|
* comments go first: some of them mention a script tag, which would otherwise open a match
|
|
* that swallows the markup up to the next real one.
|
|
*/
|
|
const MARKUP = read('index.html')
|
|
.replace(/<!--[\s\S]*?-->/g, '')
|
|
.replace(/<script\b[\s\S]*?<\/script>/g, '');
|
|
const PAGE = MARKUP.replace(
|
|
'<link rel="stylesheet" href="styles.css">',
|
|
() => `<style>${read('styles.css')}</style>`
|
|
).replace(
|
|
'<link rel="stylesheet" href="mobile.css" media="(max-width: 1023px)">',
|
|
() => `<style media="(max-width: 1023px)">${read('mobile.css')}</style>`
|
|
);
|
|
|
|
const RUNTIME = 185971671;
|
|
|
|
function row(modelId: string, modelLabel: string, downloadBytes: number, downloadGb: string, over: object = {}) {
|
|
return {
|
|
modelId,
|
|
modelLabel,
|
|
license: 'Apache-2.0',
|
|
paramsB: 9,
|
|
context: 131072,
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
cliInstalled: true,
|
|
needBytes: 1,
|
|
needGb: '20.0',
|
|
downloadBytes,
|
|
downloadGb,
|
|
budgetShare: 0.5,
|
|
fit: 'good',
|
|
fitLabel: 'Good',
|
|
speed: 'ok',
|
|
tested: false,
|
|
needSource: 'estimated',
|
|
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', installed: true }],
|
|
installBytes: downloadBytes + RUNTIME,
|
|
installGb: downloadGb,
|
|
...over,
|
|
};
|
|
}
|
|
|
|
const STATUS = {
|
|
hardware: {
|
|
platform: 'darwin',
|
|
arch: 'arm64',
|
|
memBytes: 51539607552,
|
|
memGb: '51.5',
|
|
memClassGb: 48,
|
|
chip: 'Apple M5 Pro',
|
|
freeDiskBytes: 897000000000,
|
|
freeDiskGb: '897',
|
|
source: 'sysctl',
|
|
},
|
|
recommendation: {
|
|
verdict: 'ok',
|
|
headline: { text: 'Recommended for this Mac' },
|
|
notes: [],
|
|
pick: row('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', 23105798956, '23.1', {
|
|
needGb: '36.0',
|
|
budgetShare: 0.957,
|
|
tokensPerSecond: 67.7,
|
|
tested: true,
|
|
tag: { kind: 'tested', label: 'Tested on a Mac like this' },
|
|
}),
|
|
alternatives: [
|
|
row('qwen3.8:27b', 'Qwen3.8 27B', 17741871939, '17.7', {
|
|
needGb: '30.6',
|
|
budgetShare: 0.814,
|
|
tokensPerSecond: 9.1,
|
|
tag: { kind: 'slow', label: 'Slow' },
|
|
}),
|
|
row('gpt-oss:20b', 'GPT-OSS 20B', 13793441427, '13.8', { needGb: '26.6', budgetShare: 0.709, speed: 'unknown' }),
|
|
row('qwen3.5:9b', 'Qwen3.5 9B', 6550825373, '6.6', {
|
|
needGb: '17.0',
|
|
budgetShare: 0.25,
|
|
tokensPerSecond: 28.6,
|
|
tag: { kind: 'small', label: 'Small model' },
|
|
}),
|
|
],
|
|
budgetBytes: 37580963840,
|
|
budgetGb: '37.6',
|
|
budgetSource: 'estimated',
|
|
},
|
|
catalogUpdated: '2026-11-06',
|
|
install: {
|
|
state: 'idle',
|
|
running: false,
|
|
developerTools: 'present',
|
|
runtime: { version: '0.40.15', installed: false, downloadBytes: RUNTIME, downloadGb: '0.2', unpackedGb: '0.6' },
|
|
folder: '/Users/sam/.codeman/local-model',
|
|
library: [],
|
|
actions: ['install'],
|
|
},
|
|
};
|
|
|
|
const libraryRow = (id: string, label: string, sizeGb: string, over: object = {}) => ({
|
|
id,
|
|
label,
|
|
context: 131072,
|
|
sizeBytes: Number(sizeGb) * 1e9,
|
|
sizeGb,
|
|
active: false,
|
|
verified: true,
|
|
status: 'ready',
|
|
actions: ['use', 'delete'],
|
|
...over,
|
|
});
|
|
|
|
/** An installed model with two more models beside it: one checked, one the runtime lists and Codeman never checked. */
|
|
const INSTALLED = {
|
|
...STATUS,
|
|
install: {
|
|
...STATUS.install,
|
|
state: 'installed',
|
|
running: true,
|
|
runtime: { version: '0.40.15', installed: true, downloadBytes: 0, downloadGb: '0.0', unpackedGb: '0.6' },
|
|
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
context: 131072,
|
|
port: 11435,
|
|
endpointId: 'local-ollama',
|
|
loaded: { context: 131072, memoryBytes: 26300000000, memoryGb: '26.3', onGpu: true },
|
|
library: [
|
|
libraryRow('qwen3.6:35b-a3b', 'Qwen3.6 35B-A3B', '23.1', { active: true, actions: [] }),
|
|
libraryRow('qwen3.8:27b-mtp-q4_k_m', 'Qwen3.8 27B', '17.7'),
|
|
libraryRow('mimo-v2.6:9b-q4_k_m', 'MiMo V2.6 9B', '6.6', {
|
|
context: undefined,
|
|
verified: false,
|
|
status: 'unverified',
|
|
actions: ['delete'],
|
|
}),
|
|
],
|
|
store: { bytes: 47400000000, gb: '47.4' },
|
|
actions: ['start-session', 'stop', 'install', 'use', 'delete-model', 'remove'],
|
|
},
|
|
};
|
|
|
|
type Box = { left: number; right: number; top: number; bottom: number; width: number; height: number };
|
|
|
|
/** What the page holds once the module ran on the stand-in class. */
|
|
interface PageApp {
|
|
_api: () => Promise<unknown>;
|
|
loadLocalModel(): Promise<void>;
|
|
openLocalModelWindow(): void;
|
|
}
|
|
type PageWindow = { CodemanApp: new () => PageApp; app: PageApp };
|
|
|
|
describe('the local model window (real Chromium)', () => {
|
|
let browser: Browser;
|
|
let p: Page;
|
|
beforeAll(async () => {
|
|
browser = await chromium.launch();
|
|
});
|
|
afterAll(async () => {
|
|
await browser.close();
|
|
});
|
|
afterEach(async () => {
|
|
await p?.close();
|
|
});
|
|
|
|
it('runs on the real page with both stylesheets in it', () => {
|
|
expect(MARKUP).toContain('id="localModelModal"');
|
|
expect(MARKUP.includes('<script')).toBe(false);
|
|
expect(PAGE.includes('.lm-bar-fill {')).toBe(true);
|
|
expect(PAGE.includes('<style media="(max-width: 1023px)">')).toBe(true);
|
|
expect(PAGE.includes('href="styles.css"')).toBe(false);
|
|
});
|
|
|
|
/** The real page at a width, the module loaded, the answer in, the window closed. */
|
|
async function load(width: number, height = 900, answer: object = STATUS) {
|
|
p = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: 1 });
|
|
// No motion: the bar is at its width as soon as it is painted, so nothing here waits on a clock.
|
|
await p.emulateMedia({ reducedMotion: 'reduce' });
|
|
await p.route('**/*', (route) =>
|
|
route.request().url() === ORIGIN
|
|
? route.fulfill({ contentType: 'text/html', body: PAGE })
|
|
: route.fulfill({ status: 404, body: '' })
|
|
);
|
|
await p.goto(ORIGIN);
|
|
await p.addScriptTag({ content: `function CodemanApp() {}\n${read('local-model-ui.js')}` });
|
|
await p.evaluate(async (status) => {
|
|
const w = window as unknown as PageWindow;
|
|
document.body.classList.add('app-loaded');
|
|
document.getElementById('welcomeOverlay')!.classList.add('visible');
|
|
w.app = new w.CodemanApp();
|
|
w.app._api = async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: status }) });
|
|
await w.app.loadLocalModel();
|
|
}, answer);
|
|
}
|
|
|
|
/** Two animation frames: whatever was just written is laid out and painted. */
|
|
const painted = () =>
|
|
p.evaluate(() => new Promise<void>((done) => requestAnimationFrame(() => requestAnimationFrame(() => done()))));
|
|
|
|
async function open() {
|
|
await p.evaluate(() => (window as unknown as PageWindow).app.openLocalModelWindow());
|
|
await painted();
|
|
}
|
|
|
|
const boxes = (selector: string) =>
|
|
p.evaluate(
|
|
(sel) =>
|
|
[...document.querySelectorAll(sel)].map((el) => {
|
|
const r = el.getBoundingClientRect();
|
|
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height };
|
|
}),
|
|
selector
|
|
) as Promise<Box[]>;
|
|
|
|
const same = (values: number[]) => Math.max(...values) - Math.min(...values) <= 0.6;
|
|
|
|
it('shows the welcome button only once the server answered for a Mac', async () => {
|
|
p = await browser.newPage({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 });
|
|
await p.route('**/*', (route) =>
|
|
route.request().url() === ORIGIN
|
|
? route.fulfill({ contentType: 'text/html', body: PAGE })
|
|
: route.fulfill({ status: 404, body: '' })
|
|
);
|
|
await p.goto(ORIGIN);
|
|
// As shipped: `hidden`, and really not laid out although the chip class is inline-flex.
|
|
expect(await p.evaluate(() => getComputedStyle(document.getElementById('welcomeLocalModelBtn')!).display)).toBe(
|
|
'none'
|
|
);
|
|
await p.close();
|
|
await load(1440);
|
|
const [button] = await boxes('#welcomeLocalModelBtn');
|
|
expect(button.width).toBeGreaterThan(100);
|
|
// The first thing on the start screen, in the size of the primary launcher below it.
|
|
expect(Math.round(button.height)).toBe(46);
|
|
expect(await p.textContent('#welcomeLocalModelLabel')).toBe('Run a local model');
|
|
});
|
|
|
|
it('lines the numbers up down the list on a desktop, one line a row', async () => {
|
|
await load(1440);
|
|
await open();
|
|
const rows = await boxes('.lm-models .lm-model');
|
|
expect(rows).toHaveLength(4);
|
|
const names = await boxes('.lm-model-name');
|
|
const tags = await boxes('.lm-model-tag');
|
|
const speeds = await boxes('.lm-model-speed');
|
|
const sizes = await boxes('.lm-model-size');
|
|
// Every number ends on the same edge, and every tag starts on the same edge.
|
|
expect(same(sizes.map((box) => box.right))).toBe(true);
|
|
expect(same(speeds.map((box) => box.right))).toBe(true);
|
|
expect(same(tags.map((box) => box.left))).toBe(true);
|
|
expect(same(names.map((box) => box.left))).toBe(true);
|
|
// The captions sit over their columns.
|
|
const [speedCaption] = await boxes('.lm-caption-speed');
|
|
const [sizeCaption] = await boxes('.lm-caption-size');
|
|
expect(same([speedCaption.right, speeds[0].right])).toBe(true);
|
|
expect(same([sizeCaption.right, sizes[0].right])).toBe(true);
|
|
// One line: name, tag, speed and size share a row's middle.
|
|
for (let i = 0; i < rows.length; i++) {
|
|
const middle = (box: Box) => (box.top + box.bottom) / 2;
|
|
expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3);
|
|
expect(Math.abs(middle(names[i]) - middle(speeds[i]))).toBeLessThan(3);
|
|
expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6);
|
|
expect(speeds[i].right).toBeLessThanOrEqual(sizes[i].left + 0.6);
|
|
}
|
|
// The memory panel lines up with the rows on both edges, and the bar with its figure.
|
|
const [figure] = await boxes('.lm-figure');
|
|
const [radio] = await boxes('.lm-radio');
|
|
const [bar] = await boxes('.lm-bar');
|
|
const [meter] = await boxes('.lm-meter');
|
|
expect(same([figure.left, bar.left])).toBe(true);
|
|
expect(same([meter.left, radio.left])).toBe(true);
|
|
expect(same([meter.right, sizes[0].right])).toBe(true);
|
|
});
|
|
|
|
it('fills the bar to the selected model, and the fill follows the selection', async () => {
|
|
await load(1440);
|
|
await open();
|
|
const share = async () => {
|
|
const [track] = await boxes('.lm-bar');
|
|
const [fill] = await boxes('.lm-bar-fill');
|
|
expect(Math.round(track.height)).toBe(12);
|
|
expect(Math.round(fill.height)).toBe(12);
|
|
expect(same([track.left, fill.left])).toBe(true);
|
|
return fill.width / track.width;
|
|
};
|
|
// The pick is a tight fit: the bar is nearly full, and a sliver of the track still shows.
|
|
expect(await share()).toBeCloseTo(0.96, 2);
|
|
await p.click('.lm-models .lm-model:nth-child(4)');
|
|
await painted();
|
|
expect(await share()).toBeCloseTo(0.25, 2);
|
|
await p.keyboard.press('ArrowUp');
|
|
await painted();
|
|
expect(await share()).toBeCloseTo(0.71, 2);
|
|
// The fill moves over a short moment, and stands still for a reader who wants no motion.
|
|
const duration = () =>
|
|
p.evaluate(() => getComputedStyle(document.querySelector('.lm-bar-fill')!).transitionDuration);
|
|
expect(await duration()).toBe('0s');
|
|
await p.emulateMedia({ reducedMotion: 'no-preference' });
|
|
expect(await duration()).toBe('0.32s');
|
|
await p.emulateMedia({ reducedMotion: 'reduce' });
|
|
// The track is not the window's own colour, or the free part could not be seen.
|
|
const paint = await p.evaluate(() => {
|
|
const css = (sel: string, prop: string) => getComputedStyle(document.querySelector(sel)!).getPropertyValue(prop);
|
|
// The feature's colour as the browser paints it: a custom property reads back unresolved.
|
|
const probe = document.body.appendChild(document.createElement('i'));
|
|
probe.style.color = 'var(--lm-brand)';
|
|
const brand = getComputedStyle(probe).color;
|
|
probe.remove();
|
|
return {
|
|
brand,
|
|
track: css('.lm-bar', 'background-color'),
|
|
fillImage: css('.lm-bar-fill', 'background-image'),
|
|
window: css('.local-model-window', 'background-color'),
|
|
edge: css('.lm-model-on', 'border-left-color'),
|
|
idleEdge: css('.lm-model:not(.lm-model-on)', 'border-left-color'),
|
|
};
|
|
});
|
|
expect(paint.track).not.toBe(paint.window);
|
|
expect(paint.track).not.toBe(paint.brand);
|
|
// Without a memory map the one fill runs from the feature's colour to the context's.
|
|
expect(paint.fillImage).toContain('linear-gradient');
|
|
// The selected row carries the feature's colour on its reading edge.
|
|
expect(paint.edge).toBe(paint.brand);
|
|
expect(paint.idleEdge).not.toBe(paint.brand);
|
|
});
|
|
|
|
it('stacks two lines a row under 560px, with nothing running off the side', async () => {
|
|
await load(390, 844);
|
|
await open();
|
|
const names = await boxes('.lm-model-name');
|
|
const tags = await boxes('.lm-model-tag');
|
|
const speeds = await boxes('.lm-model-speed');
|
|
const sizes = await boxes('.lm-model-size');
|
|
for (let i = 0; i < names.length; i++) {
|
|
// Name and tag, then speed and size.
|
|
expect(speeds[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
|
|
expect(sizes[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
|
|
expect(tags[i].top).toBeLessThan(names[i].bottom);
|
|
expect(same([names[i].left, speeds[i].left])).toBe(true);
|
|
}
|
|
expect(same(sizes.map((box) => box.right))).toBe(true);
|
|
// The window fills the phone, the bar and the button take its width, and nothing overflows.
|
|
const [win] = await boxes('.local-model-window');
|
|
expect(Math.round(win.width)).toBe(390);
|
|
expect(Math.round(win.height)).toBe(844);
|
|
const [meter] = await boxes('.lm-meter');
|
|
const [button] = await boxes('.lm-buttons button');
|
|
expect(same([meter.left, button.left])).toBe(true);
|
|
expect(same([meter.right, button.right])).toBe(true);
|
|
expect(button.height).toBeGreaterThanOrEqual(44);
|
|
const overflow = await p.evaluate(() => {
|
|
const body = document.getElementById('localModelWindowBody')!;
|
|
return body.scrollWidth - body.clientWidth;
|
|
});
|
|
expect(overflow).toBeLessThanOrEqual(0);
|
|
});
|
|
|
|
it('the models on this Mac: sizes on one edge, a row one line, its buttons on the list edge', async () => {
|
|
await load(1440, 900, INSTALLED);
|
|
await open();
|
|
const rows = await boxes('.lm-lib-row');
|
|
expect(rows).toHaveLength(3);
|
|
const names = await boxes('.lm-lib-name');
|
|
const tags = await boxes('.lm-lib-tag');
|
|
const sizes = await boxes('.lm-lib-size');
|
|
const [caption] = await boxes('.lm-lib-caption-size');
|
|
expect(same(sizes.map((box) => box.right))).toBe(true);
|
|
expect(same([caption.right, sizes[0].right])).toBe(true);
|
|
expect(same(names.map((box) => box.left))).toBe(true);
|
|
expect(same(tags.map((box) => box.left))).toBe(true);
|
|
// One line a row: the name, the tag, the size and the buttons share its middle.
|
|
const middle = (box: Box) => (box.top + box.bottom) / 2;
|
|
const actions = await boxes('.lm-lib-row .lm-lib-actions');
|
|
for (let i = 0; i < rows.length; i++) {
|
|
expect(Math.abs(middle(names[i]) - middle(sizes[i]))).toBeLessThan(3);
|
|
expect(Math.abs(middle(tags[i]) - middle(sizes[i]))).toBeLessThan(3);
|
|
expect(names[i].right).toBeLessThanOrEqual(tags[i].left + 0.6);
|
|
expect(sizes[i].right).toBeLessThanOrEqual(actions[i].left + 0.6);
|
|
}
|
|
// The last button of a row ends where every line of the window ends: on "Add a model"'s edge.
|
|
const rowButtons = await boxes('.lm-lib-row .lm-lib-actions button:last-child');
|
|
const [add] = await boxes('.lm-lib-foot button');
|
|
expect(same([...rowButtons.map((box) => box.right), add.right])).toBe(true);
|
|
// The model in use carries the feature's colour on its reading edge; the others do not.
|
|
const paint = await p.evaluate(() => {
|
|
const probe = document.body.appendChild(document.createElement('i'));
|
|
probe.style.color = 'var(--lm-brand)';
|
|
const brand = getComputedStyle(probe).color;
|
|
probe.remove();
|
|
const edge = (sel: string) => getComputedStyle(document.querySelector(sel)!).borderLeftColor;
|
|
return { brand, on: edge('.lm-lib-row-on'), off: edge('.lm-lib-row:not(.lm-lib-row-on)') };
|
|
});
|
|
expect(paint.on).toBe(paint.brand);
|
|
expect(paint.off).not.toBe(paint.brand);
|
|
// The screen starts at the top of the frame, right under the header.
|
|
const [body] = await boxes('#localModelWindowBody');
|
|
const [title] = await boxes('.lm-title');
|
|
expect(title.top - body.top).toBeLessThan(40);
|
|
});
|
|
|
|
it('the Delete question opens under its row without moving the sizes', async () => {
|
|
await load(1440, 900, INSTALLED);
|
|
await open();
|
|
const before = await boxes('.lm-lib-size');
|
|
await p.click('.lm-lib-row:nth-of-type(3) .lm-lib-actions button:last-child');
|
|
await painted();
|
|
const [ask] = await boxes('.lm-lib-ask');
|
|
const rows = await boxes('.lm-lib-row');
|
|
const after = await boxes('.lm-lib-size');
|
|
// Sideways nothing moved; the rows after the question moved down by it.
|
|
expect(after.map((box) => Math.round(box.right))).toEqual(before.map((box) => Math.round(box.right)));
|
|
expect(ask.top).toBeGreaterThanOrEqual(rows[1].bottom - 1);
|
|
expect(rows[2].top).toBeGreaterThanOrEqual(ask.bottom - 1);
|
|
// The row's own buttons are unseen while it is asked about, and the red button is the question's.
|
|
const seen = await p.evaluate(() => ({
|
|
away: getComputedStyle(document.querySelector('.lm-lib-actions-away')!).visibility,
|
|
red: [...document.querySelectorAll('.local-model-window .wt-btn-danger')].map((el) => el.textContent),
|
|
focus: document.activeElement?.textContent,
|
|
}));
|
|
expect(seen).toEqual({ away: 'hidden', red: ['Delete'], focus: 'Cancel' });
|
|
});
|
|
|
|
it('a long list of models scrolls from its top: the first lines are never cut off above the frame', async () => {
|
|
const many = Array.from({ length: 14 }, (_unused, i) => libraryRow(`model-${i}:9b`, `Model ${i}`, '6.6'));
|
|
const answer = {
|
|
...INSTALLED,
|
|
install: { ...INSTALLED.install, library: [...INSTALLED.install.library, ...many] },
|
|
};
|
|
for (const [width, height] of [
|
|
[1440, 900],
|
|
[390, 844],
|
|
]) {
|
|
await load(width, height, answer);
|
|
await open();
|
|
const scroll = await p.evaluate(() => {
|
|
const body = document.getElementById('localModelWindowBody')!;
|
|
const title = body.querySelector('.lm-title')!.getBoundingClientRect();
|
|
const box = body.getBoundingClientRect();
|
|
return {
|
|
overflows: body.scrollHeight > body.clientHeight,
|
|
top: body.scrollTop,
|
|
titleInside: title.top >= box.top - 0.5,
|
|
sideways: body.scrollWidth - body.clientWidth,
|
|
};
|
|
});
|
|
expect(scroll).toEqual({ overflows: true, top: 0, titleInside: true, sideways: 0 });
|
|
// The buttons of the screen stay in the frame's foot, under the part that scrolls.
|
|
const [foot] = await boxes('#localModelWindowFoot');
|
|
const [win] = await boxes('.local-model-window');
|
|
expect(foot.bottom).toBeLessThanOrEqual(win.bottom + 0.6);
|
|
await p.close();
|
|
}
|
|
});
|
|
|
|
it('the models on this Mac under 560px: two lines a row, nothing off the side', async () => {
|
|
await load(390, 844, INSTALLED);
|
|
await open();
|
|
const names = await boxes('.lm-lib-name');
|
|
const tags = await boxes('.lm-lib-tag');
|
|
const sizes = await boxes('.lm-lib-size');
|
|
for (let i = 0; i < names.length; i++) {
|
|
// The name and its size, then the state and what can be done.
|
|
expect(tags[i].top).toBeGreaterThanOrEqual(names[i].bottom - 1);
|
|
expect(sizes[i].top).toBeLessThan(names[i].bottom);
|
|
expect(same([names[i].left, tags[i].left])).toBe(true);
|
|
}
|
|
expect(same(sizes.map((box) => box.right))).toBe(true);
|
|
const buttons = await boxes('.lm-lib-row .lm-lib-actions button');
|
|
for (const button of buttons) expect(button.height).toBeGreaterThanOrEqual(36);
|
|
const overflow = await p.evaluate(() => {
|
|
const body = document.getElementById('localModelWindowBody')!;
|
|
return body.scrollWidth - body.clientWidth;
|
|
});
|
|
expect(overflow).toBeLessThanOrEqual(0);
|
|
});
|
|
|
|
it('paints above App Settings when it is opened from there', async () => {
|
|
await load(1440);
|
|
await p.evaluate(() => document.getElementById('appSettingsModal')!.classList.add('active'));
|
|
await open();
|
|
const top = await p.evaluate(() => {
|
|
const box = document.querySelector('.local-model-window')!.getBoundingClientRect();
|
|
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + 20);
|
|
return !!hit && !!hit.closest('#localModelModal');
|
|
});
|
|
expect(top).toBe(true);
|
|
});
|
|
});
|