mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 18:39:33 +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>
912 lines
41 KiB
TypeScript
912 lines
41 KiB
TypeScript
/**
|
|
* @fileoverview The local model install from the first GET to Remove, with the REAL modules
|
|
* joined together: the routes, the install service, the catalogue and `recommend()`, the
|
|
* runtime download and unpack, the supervisor, the pull, the checks, the connection and the
|
|
* state file. Only what would touch the world is a stand-in:
|
|
*
|
|
* - the runtime is the fake HTTP server of test/fixtures/fake-ollama.ts (one instance serves
|
|
* the archive, a second one is what the supervisor "starts" through the fake's `spawn`);
|
|
* - the pin points at the fake's small archive and carries that archive's own sha256, so the
|
|
* real download, the real hash comparison and the real `/usr/bin/tar` run;
|
|
* - the hardware is the owner's MacBook as its `sysctl` reported it (Apple M5 Pro, 48 GB);
|
|
* - the coding agent's installer is a function that says it ran;
|
|
* - the waits between retries are skipped (the retries themselves are the real ones).
|
|
*
|
|
* Nothing is downloaded from the network, no process is started and no fixed port is used
|
|
* (the runtime's port is one the system handed out). Files live in the test's temp data dir.
|
|
*
|
|
* The walks: the whole install and its removal; a damaged runtime download; a model download
|
|
* that fails twice and then succeeds; one that keeps failing; a model that calls no tool;
|
|
* Cancel during the model download; a server restart during the model download; a server
|
|
* that is KILLED during the model download and leaves its runtime running.
|
|
*
|
|
* "A server" here is one install service with its own runtime supervisor (`startServer()`).
|
|
* A restart is `shutdown()` of the one and a new one over the same data dir.
|
|
*
|
|
* The other tests give each module fakes of its neighbours. This one is what fails when two
|
|
* modules that each pass their own tests do not fit together.
|
|
*
|
|
* Port: the fake runtime binds port 0 twice; the routes use app.inject().
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { rm } from 'node:fs/promises';
|
|
import { dirname } from 'node:path';
|
|
import { createRouteTestHarness } from './routes/_route-test-utils.js';
|
|
import {
|
|
createFakeOllama,
|
|
freeLoopbackPort,
|
|
MACBOOK_MEASURED_TOTAL_BYTES,
|
|
startFakeOllama,
|
|
type FakeOllama,
|
|
} from './fixtures/fake-ollama.js';
|
|
import { dataPath, getDataDir } from '../src/config/instance.js';
|
|
import { customModelHostsPath, readCustomModelHosts } from '../src/custom-model-hosts.js';
|
|
import { createLocalModelService, type LocalModelService } from '../src/local-model/install-job.js';
|
|
import { memClassGb, formatGb } from '../src/local-model/format.js';
|
|
import { pullModel, PULL_RETRIES } from '../src/local-model/ollama-client.js';
|
|
import { localModelPaths, type LocalModelPaths } from '../src/local-model/paths.js';
|
|
import {
|
|
createRuntimeSupervisor,
|
|
ensureRuntimeInstalled,
|
|
type RuntimeSupervisorDeps,
|
|
} from '../src/local-model/runtime.js';
|
|
import { readLocalModelState } from '../src/local-model/state-store.js';
|
|
import { registerLocalModelRoutes } from '../src/web/routes/local-model-routes.js';
|
|
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
|
|
import type { Candidate, HardwareFacts, HarnessFact, LocalModelInstallView, LocalModelStatus } from '../src/types.js';
|
|
|
|
const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash';
|
|
|
|
/** The owner's MacBook, from its sysctl capture of 2026-11-06. */
|
|
const MACBOOK: HardwareFacts = {
|
|
platform: 'darwin',
|
|
arch: 'arm64',
|
|
arm64: true,
|
|
translated: false,
|
|
memBytes: 51539607552,
|
|
memGb: formatGb(51539607552),
|
|
memClassGb: memClassGb(51539607552),
|
|
chip: 'Apple M5 Pro',
|
|
model: 'Mac17,13',
|
|
macosVersion: '27.2',
|
|
macosMajor: 27,
|
|
wiredLimitMb: 0,
|
|
freeDiskBytes: 912308310016,
|
|
freeDiskGb: formatGb(912308310016),
|
|
source: 'sysctl',
|
|
};
|
|
|
|
interface World {
|
|
archive: FakeOllama;
|
|
runtime: FakeOllama | undefined;
|
|
port: number;
|
|
paths: LocalModelPaths;
|
|
agentInstalled: boolean;
|
|
installerRuns: number;
|
|
/** How many of the next archive downloads arrive with one byte changed (the size stays the pinned one). */
|
|
damagedDownloads: number;
|
|
/** The server that is up. A restart replaces it. */
|
|
service: LocalModelService;
|
|
/** Every server of the test, for the clean-up. */
|
|
services: LocalModelService[];
|
|
sessions: Map<string, { name: string; customModel?: { endpointId: string } }>;
|
|
}
|
|
|
|
let world: World;
|
|
|
|
const HOSTS = () => customModelHostsPath(getDataDir());
|
|
const STATE = () => dataPath('local-model.json');
|
|
|
|
const harnessFacts = async (): Promise<HarnessFact[]> => [
|
|
{
|
|
cli: 'opencode',
|
|
label: 'OpenCode',
|
|
installed: world.agentInstalled,
|
|
installable: !world.agentInstalled,
|
|
...(world.agentInstalled ? {} : { installCommand: INSTALL_COMMAND }),
|
|
acceptsModelId: () => true,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* The download of the runtime archive: the real `fetch` against the fake's archive. While
|
|
* `world.damagedDownloads` is above 0 the body arrives with its last byte changed, as a
|
|
* download damaged on the way does: the size is still the pinned one, only the hash can tell.
|
|
*/
|
|
const archiveFetch: typeof fetch = async (input, init) => {
|
|
const res = await fetch(input, init);
|
|
if (world.damagedDownloads <= 0 || !res.ok) return res;
|
|
world.damagedDownloads -= 1;
|
|
const body = Buffer.from(await res.arrayBuffer());
|
|
body[body.length - 1] ^= 0xff;
|
|
return new Response(body, { status: res.status, headers: { 'content-length': String(body.length) } });
|
|
};
|
|
|
|
/**
|
|
* One Codeman server as far as this feature goes: an install service with its own runtime
|
|
* supervisor, over the data dir, the pin and the port of the test. It becomes `world.service`,
|
|
* which is the one the routes ask.
|
|
*/
|
|
function startServer(processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = {}): LocalModelService {
|
|
const pin = world.archive.pin();
|
|
const { paths, port } = world;
|
|
const supervisor = createRuntimeSupervisor({
|
|
// "Starting the runtime" makes the second fake listen on the port it was given.
|
|
spawn: (command, args, options) => {
|
|
if (!world.runtime) throw new Error('The test did not prepare a runtime');
|
|
return world.runtime.spawn(command, args, options);
|
|
},
|
|
fetch,
|
|
env: { PATH: process.env.PATH, CODEMAN_PASSWORD: 'must-not-reach-the-runtime', OLLAMA_ORIGINS: '*' },
|
|
commandOfPid: async () => undefined,
|
|
kill: () => {},
|
|
...processes,
|
|
sleep: async () => {},
|
|
pin,
|
|
paths,
|
|
onChange: (status) => service.onRuntimeChange(status),
|
|
});
|
|
const service: LocalModelService = createLocalModelService({
|
|
readHardware: async () => MACBOOK,
|
|
buildHarnessFacts: harnessFacts,
|
|
pin,
|
|
paths,
|
|
// The real download and unpack, told where the network is: the fake's archive.
|
|
ensureRuntimeInstalled: (options) =>
|
|
ensureRuntimeInstalled({ ...options, deps: { fetch: archiveFetch, sleep: async () => {} } }),
|
|
// The real pull with its real retries, without the seconds between them.
|
|
pullModel: (target, modelId, options) => pullModel(target, modelId, { ...options, sleep: async () => {} }),
|
|
chooseRuntimePort: async () => port,
|
|
supervisor,
|
|
recheckCliInstalled: async () => world.agentInstalled,
|
|
tryBeginCliInstall: () => () => {},
|
|
runInstallCommand: async () => {
|
|
world.installerRuns++;
|
|
world.agentInstalled = true;
|
|
return { code: 0, output: 'opencode installed\n', timedOut: false, aborted: false };
|
|
},
|
|
audit: async () => {},
|
|
checkDeveloperTools: async () => 'missing',
|
|
});
|
|
world.service = service;
|
|
world.services.push(service);
|
|
return service;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
const archive = await startFakeOllama();
|
|
world = {
|
|
archive,
|
|
runtime: undefined,
|
|
port: await freeLoopbackPort(),
|
|
paths: localModelPaths('0.40.15'),
|
|
agentInstalled: false,
|
|
installerRuns: 0,
|
|
damagedDownloads: 0,
|
|
service: undefined as unknown as LocalModelService,
|
|
services: [],
|
|
sessions: new Map(),
|
|
};
|
|
startServer();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
for (const service of world.services) await service.shutdown();
|
|
await world.archive.close();
|
|
await world.runtime?.close();
|
|
await rm(world.paths.root, { recursive: true, force: true });
|
|
rmSync(STATE(), { force: true });
|
|
rmSync(HOSTS(), { force: true });
|
|
rmSync(SETTINGS_PATH, { force: true });
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
async function routes() {
|
|
const { app } = await createRouteTestHarness((instance) =>
|
|
registerLocalModelRoutes(
|
|
instance,
|
|
{ sessions: world.sessions },
|
|
{ readHardware: async () => MACBOOK, buildHarnessFacts: harnessFacts, service: () => world.service }
|
|
)
|
|
);
|
|
const get = async (): Promise<LocalModelStatus> =>
|
|
(await app.inject({ method: 'GET', url: '/api/local-model' })).json().data;
|
|
/** Polls the GET the way the window does, until the job is no longer running. */
|
|
const settled = async (): Promise<LocalModelInstallView> => {
|
|
let install = (await get()).install;
|
|
await vi.waitFor(
|
|
async () => {
|
|
install = (await get()).install;
|
|
expect(install.state).not.toBe('installing');
|
|
},
|
|
{ timeout: 20_000, interval: 25 }
|
|
);
|
|
return install;
|
|
};
|
|
/** The click on "Download and install" for the row the server recommends, with the size it showed. */
|
|
const install = (pick: Candidate) =>
|
|
app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.installBytes },
|
|
});
|
|
/** Polls until the model download is under way and has moved. */
|
|
const pulling = async (): Promise<void> => {
|
|
await vi.waitFor(
|
|
async () => {
|
|
const status = await get();
|
|
expect(status.install.step).toBe('model');
|
|
expect(status.install.progress?.completedBytes ?? 0).toBeGreaterThan(0);
|
|
},
|
|
{ timeout: 20_000, interval: 20 }
|
|
);
|
|
};
|
|
return { app, get, settled, install, pulling };
|
|
}
|
|
|
|
/** The runtime the supervisor will "start": it has exactly the model the server recommended. */
|
|
function prepareRuntime(pick: Candidate): FakeOllama {
|
|
world.runtime = createFakeOllama({
|
|
model: { id: pick.modelId, bytes: pick.downloadBytes },
|
|
contextLength: pick.context,
|
|
});
|
|
return world.runtime;
|
|
}
|
|
|
|
/** A model download that takes a while: 400 lines, 5 ms apart. */
|
|
function slowPull(runtime: FakeOllama): void {
|
|
runtime.switches.pull.progressLines = 400;
|
|
runtime.switches.pull.lineDelayMs = 5;
|
|
}
|
|
|
|
function quickPull(runtime: FakeOllama): void {
|
|
runtime.switches.pull.progressLines = 20;
|
|
runtime.switches.pull.lineDelayMs = 0;
|
|
}
|
|
|
|
describe('the install from the first GET to Remove, real modules over a fake runtime', () => {
|
|
it('recommends for the MacBook, installs what was confirmed, connects it, refuses a changed model, and removes all of it', async () => {
|
|
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
|
|
writeFileSync(SETTINGS_PATH, JSON.stringify({ tmuxHistoryLimit: 5000 }));
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
|
|
// ---- The chooser: what the window shows before anything is installed ----
|
|
const before = await get();
|
|
expect(before.install).toMatchObject({
|
|
state: 'idle',
|
|
running: false,
|
|
developerTools: 'missing',
|
|
actions: ['install'],
|
|
folder: world.paths.root,
|
|
runtime: { version: '0.40.15', installed: false, downloadBytes: world.archive.archive.size },
|
|
});
|
|
expect(before.recommendation.verdict).toBe('ok');
|
|
expect(before.recommendation.budgetSource).toBe('estimated');
|
|
const pick = before.recommendation.pick!;
|
|
expect(pick).toBeDefined();
|
|
// The MacBook's row: the one model that was run on a Mac like it, with its measured speed.
|
|
expect(pick).toMatchObject({
|
|
modelId: 'qwen3.6:35b-a3b',
|
|
context: 131072,
|
|
fit: 'tight',
|
|
needSource: 'measured',
|
|
tested: true,
|
|
tag: { kind: 'tested' },
|
|
});
|
|
// The row carries the coding agent and the literal command the consent screen prints.
|
|
expect(pick.harnesses[0]).toMatchObject({
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
installed: false,
|
|
installCommand: INSTALL_COMMAND,
|
|
});
|
|
// The server's own total: the model plus the runtime archive that is still to download.
|
|
expect(pick.installBytes).toBe(pick.downloadBytes + world.archive.archive.size);
|
|
expect(pick.installGb).toBe(formatGb(pick.installBytes));
|
|
const runtime = prepareRuntime(pick);
|
|
|
|
// ---- Consent is bound to the size shown ----
|
|
const stale = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/local-model/install',
|
|
payload: { modelId: pick.modelId, cli: 'opencode', context: pick.context, acknowledgedBytes: pick.downloadBytes },
|
|
});
|
|
expect(stale.statusCode).toBe(409);
|
|
expect(stale.json().data).toMatchObject({ reason: 'size-changed', expectedBytes: pick.installBytes });
|
|
expect(world.archive.state.archiveRequests).toBe(0);
|
|
|
|
// ---- Install ----
|
|
const started = await clickInstall(pick);
|
|
expect(started.statusCode).toBe(200);
|
|
expect(started.json().data).toMatchObject({
|
|
state: 'installing',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
model: { id: pick.modelId, label: pick.modelLabel },
|
|
cli: 'opencode',
|
|
context: pick.context,
|
|
actions: ['cancel'],
|
|
});
|
|
// One job at a time.
|
|
const second = await clickInstall(pick);
|
|
expect(second.statusCode).toBe(409);
|
|
expect(second.json().data).toMatchObject({ reason: 'job-running' });
|
|
|
|
const install = await settled();
|
|
expect(install.error).toBeUndefined();
|
|
expect(install).toMatchObject({
|
|
state: 'installed',
|
|
running: true,
|
|
port: world.port,
|
|
model: { id: pick.modelId, label: pick.modelLabel },
|
|
cli: 'opencode',
|
|
cliLabel: 'OpenCode',
|
|
context: pick.context,
|
|
endpointId: 'local-ollama',
|
|
folder: world.paths.root,
|
|
loaded: { context: pick.context, onGpu: true },
|
|
actions: ['start-session', 'stop', 'install', 'remove'],
|
|
runtime: { installed: true, downloadBytes: 0, downloadGb: formatGb(0) },
|
|
});
|
|
|
|
// ---- What really happened ----
|
|
// The runtime was downloaded once, unpacked, and "started" with the allowlisted environment.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(existsSync(world.paths.binary)).toBe(true);
|
|
expect(existsSync(world.paths.archivePartial)).toBe(false);
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
const env = runtime.state.children[0].env;
|
|
expect(env.OLLAMA_HOST).toBe(`127.0.0.1:${world.port}`);
|
|
expect(env.OLLAMA_CONTEXT_LENGTH).toBe(String(pick.context));
|
|
expect(env.HOME).toBe(world.paths.homeDir);
|
|
expect(env.OLLAMA_MODELS).toBe(world.paths.modelsDir);
|
|
expect(Object.keys(env).filter((key) => key.startsWith('CODEMAN_'))).toEqual([]);
|
|
expect(env).not.toHaveProperty('OLLAMA_ORIGINS');
|
|
// One pull, one coding agent install.
|
|
expect(runtime.state.pulls).toBe(1);
|
|
expect(world.installerRuns).toBe(1);
|
|
|
|
// The state file: the measured budget, the pull's own total, the digest.
|
|
const state = await readLocalModelState();
|
|
expect(state).toMatchObject({
|
|
state: 'installed',
|
|
model: { id: pick.modelId, context: pick.context, pullBytes: runtime.model.bytes, digest: runtime.model.digest },
|
|
runtime: {
|
|
version: '0.40.15',
|
|
port: world.port,
|
|
enabled: true,
|
|
measuredTotalBytes: MACBOOK_MEASURED_TOTAL_BYTES,
|
|
},
|
|
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
|
|
});
|
|
expect(JSON.stringify(state)).not.toContain(getDataDir());
|
|
|
|
// The two writes outside the folder, and nothing else changed there.
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([
|
|
{
|
|
id: 'local-ollama',
|
|
label: 'This Mac',
|
|
baseUrl: `http://127.0.0.1:${world.port}`,
|
|
authStyle: 'bearer',
|
|
models: [pick.modelId],
|
|
defaultModelId: pick.modelId,
|
|
defaultContextLength: pick.context,
|
|
},
|
|
]);
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({
|
|
tmuxHistoryLimit: 5000,
|
|
customModelEndpointsEnabled: true,
|
|
});
|
|
|
|
// From now on the budget of the GET is the measured one.
|
|
const after = await get();
|
|
expect(after.recommendation.budgetSource).toBe('measured');
|
|
expect(after.recommendation.budgetBytes).toBeLessThanOrEqual(MACBOOK_MEASURED_TOTAL_BYTES);
|
|
|
|
// ---- A session may start, until the model changes under its name ----
|
|
expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBeUndefined();
|
|
runtime.model.digest = 'f'.repeat(64);
|
|
expect(await world.service.launchRefusalMessage('local-ollama', pick.modelId)).toBe(
|
|
'The model on this Mac changed since it was installed. Remove it and install it again.'
|
|
);
|
|
expect((await get()).install).toMatchObject({
|
|
state: 'installed',
|
|
error: { code: 'model-changed', next: 'remove' },
|
|
});
|
|
|
|
// ---- Remove ----
|
|
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
|
const refused = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(refused.statusCode).toBe(409);
|
|
expect(refused.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] });
|
|
expect(existsSync(world.paths.binary)).toBe(true);
|
|
|
|
world.sessions.clear();
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(removed.statusCode).toBe(200);
|
|
expect(removed.json().data).toMatchObject({ state: 'idle', running: false, actions: ['install'] });
|
|
// The child was stopped and the whole folder is gone: the runtime, the private home, the models.
|
|
expect(runtime.state.children[0].signals).toContain('SIGTERM');
|
|
expect(runtime.listening).toBe(false);
|
|
expect(existsSync(world.paths.root)).toBe(false);
|
|
// Our record is gone, and the flag this feature switched on is off again.
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([]);
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8'))).toEqual({
|
|
tmuxHistoryLimit: 5000,
|
|
customModelEndpointsEnabled: false,
|
|
});
|
|
expect((await readLocalModelState()).state).toBe('idle');
|
|
// The chooser is back, with the runtime download in the total again.
|
|
const again = await get();
|
|
expect(again.install.runtime).toMatchObject({ installed: false, downloadBytes: world.archive.archive.size });
|
|
expect(again.recommendation.pick?.installBytes).toBe(pick.installBytes);
|
|
}, 60_000);
|
|
|
|
it('a runtime download that arrives damaged is deleted before anything is unpacked, and Try again gets it', async () => {
|
|
const { get, settled, install: clickInstall, app } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
world.damagedDownloads = 1;
|
|
|
|
await clickInstall(pick);
|
|
const failed = await settled();
|
|
expect(failed).toMatchObject({
|
|
state: 'failed',
|
|
step: 'runtime',
|
|
error: { code: 'runtime-damaged', next: 'retry' },
|
|
actions: ['resume', 'install', 'remove'],
|
|
runtime: { installed: false },
|
|
});
|
|
expect(failed.error?.detail).toContain(world.archive.archive.sha256);
|
|
// The hash was compared first: the file is gone, nothing was unpacked, nothing was started.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(existsSync(world.paths.archivePartial)).toBe(false);
|
|
expect(existsSync(world.paths.unpackDir)).toBe(false);
|
|
expect(existsSync(world.paths.runtimeDir)).toBe(false);
|
|
expect(runtime.state.children).toHaveLength(0);
|
|
expect(runtime.state.pulls).toBe(0);
|
|
|
|
// "Try again" downloads the archive again, and this time it is the pinned one.
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
expect((await settled()).state).toBe('installed');
|
|
expect(world.archive.state.archiveRequests).toBe(2);
|
|
expect(existsSync(world.paths.binary)).toBe(true);
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
}, 60_000);
|
|
|
|
it('a model download that fails twice is asked again and finishes; Remove leaves another saved endpoint and the models', async () => {
|
|
writeFileSync(HOSTS(), JSON.stringify([{ id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' }]));
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
// The real error line of a pull whose download kept failing, on the first two pulls.
|
|
runtime.switches.pull.fail = 'error-line';
|
|
runtime.switches.pull.failTimes = 2;
|
|
|
|
await clickInstall(pick);
|
|
const install = await settled();
|
|
expect(install.error).toBeUndefined();
|
|
expect(install.state).toBe('installed');
|
|
expect(runtime.state.pulls).toBe(3);
|
|
// The size of the model is the pull's own total.
|
|
expect((await readLocalModelState()).model?.pullBytes).toBe(runtime.model.bytes);
|
|
expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'office' }, { id: 'local-ollama' }]);
|
|
|
|
// Remove without the box: the model store stays for a later install.
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: false } });
|
|
expect(removed.json().data.state).toBe('idle');
|
|
expect(existsSync(world.paths.runtimeRoot)).toBe(false);
|
|
expect(existsSync(world.paths.homeDir)).toBe(false);
|
|
expect(existsSync(world.paths.modelsDir)).toBe(true);
|
|
// The other endpoint stays, and so does the flag it needs, although this feature switched it on.
|
|
expect(await readCustomModelHosts(getDataDir())).toEqual([
|
|
{ id: 'office', label: 'Office', baseUrl: 'http://192.168.1.50:8080' },
|
|
]);
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(true);
|
|
}, 60_000);
|
|
|
|
it('a model download that keeps failing ends failed after its retries, and Try again continues it', async () => {
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
runtime.switches.pull.fail = 'error-line';
|
|
|
|
await clickInstall(pick);
|
|
const failed = await settled();
|
|
expect(failed).toMatchObject({
|
|
state: 'failed',
|
|
step: 'model',
|
|
error: { code: 'pull-failed', next: 'retry', detail: 'max retries exceeded: local error: tls: bad record MAC' },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
// The first pull and every retry, and no more.
|
|
expect(runtime.state.pulls).toBe(1 + PULL_RETRIES);
|
|
// The download as it stood, in the pull's own figures.
|
|
expect(failed.progress?.totalBytes).toBe(runtime.model.bytes);
|
|
expect(failed.progress?.completedBytes).toBeGreaterThan(0);
|
|
expect(failed.progress?.completedBytes).toBeLessThan(runtime.model.bytes);
|
|
// Nothing was checked, installed or connected.
|
|
expect(runtime.state.installed).toBe(false);
|
|
expect(world.installerRuns).toBe(0);
|
|
expect(existsSync(HOSTS())).toBe(false);
|
|
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
|
|
|
// The network is fine again: "Try again" enters the same step.
|
|
runtime.switches.pull.fail = 'none';
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.json().data).toMatchObject({ state: 'installing', step: 'model' });
|
|
expect((await settled()).state).toBe('installed');
|
|
expect(runtime.state.pulls).toBe(2 + PULL_RETRIES);
|
|
// The runtime was neither downloaded nor started a second time.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
}, 60_000);
|
|
|
|
it('a model that does not call a tool ends failed with the next model to try, and Remove cleans up', async () => {
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
runtime.switches.tool = 'none';
|
|
|
|
await clickInstall(pick);
|
|
const install = await settled();
|
|
expect(install).toMatchObject({
|
|
state: 'failed',
|
|
step: 'verify',
|
|
error: { code: 'no-tool-call', next: 'next-model', message: { vars: { model: pick.modelLabel } } },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
// The model to try next is a row of the list the window has.
|
|
const offered = (await get()).recommendation;
|
|
expect(install.error?.nextModel?.modelId).not.toBe(pick.modelId);
|
|
expect(offered.alternatives.map((row) => row.modelId)).toContain(install.error?.nextModel?.modelId);
|
|
// The coding agent was not installed and nothing was connected.
|
|
expect(world.installerRuns).toBe(0);
|
|
expect(existsSync(HOSTS())).toBe(false);
|
|
expect(existsSync(SETTINGS_PATH)).toBe(false);
|
|
|
|
// "Try again" with a model that now calls the tool finishes the same job.
|
|
runtime.switches.tool = 'call';
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
expect((await settled()).state).toBe('installed');
|
|
// The runtime was not downloaded again and the model was not pulled again.
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(runtime.state.pulls).toBe(1);
|
|
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model' });
|
|
expect(removed.json().data.state).toBe('idle');
|
|
// This feature switched the flag on and no other endpoint is saved: it is off again.
|
|
expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).customModelEndpointsEnabled).toBe(false);
|
|
// The model was not asked to go: the store stays for a later install.
|
|
expect(existsSync(world.paths.runtimeDir)).toBe(false);
|
|
expect(existsSync(world.paths.modelsDir)).toBe(true);
|
|
}, 60_000);
|
|
|
|
it('Cancel during the model download pauses the job, nothing goes on by itself, and Continue finishes it', async () => {
|
|
const { app, get, settled, install: clickInstall, pulling } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
slowPull(runtime);
|
|
|
|
await clickInstall(pick);
|
|
// The window sees the model download move.
|
|
await pulling();
|
|
expect((await get()).install).toMatchObject({ state: 'installing', step: 'model' });
|
|
|
|
const cancelled = await app.inject({ method: 'POST', url: '/api/local-model/cancel' });
|
|
expect(cancelled.statusCode).toBe(200);
|
|
const paused = cancelled.json().data as LocalModelInstallView;
|
|
expect(paused).toMatchObject({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] });
|
|
// The bytes as they stood, and the pull's own total.
|
|
expect(paused.progress?.totalBytes).toBe(runtime.model.bytes);
|
|
expect(paused.progress?.completedBytes).toBeLessThan(runtime.model.bytes);
|
|
expect(paused.progress).not.toHaveProperty('bytesPerSecond');
|
|
expect(runtime.state.installed).toBe(false);
|
|
|
|
// Nothing goes on by itself.
|
|
const pullsAfterCancel = runtime.state.pulls;
|
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
expect(runtime.state.pulls).toBe(pullsAfterCancel);
|
|
expect((await get()).install.state).toBe('paused');
|
|
|
|
quickPull(runtime);
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume' });
|
|
expect(resumed.json().data.state).toBe('installing');
|
|
expect((await settled()).state).toBe('installed');
|
|
expect(runtime.state.pulls).toBe(pullsAfterCancel + 1);
|
|
}, 60_000);
|
|
|
|
it('a server restart during the model download comes back paused, waits for a click, and Continue finishes it', async () => {
|
|
const { app, get, settled, install: clickInstall, pulling } = await routes();
|
|
const pick = (await get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
slowPull(runtime);
|
|
|
|
await clickInstall(pick);
|
|
await pulling();
|
|
|
|
// ---- The server stops ----
|
|
const first = world.service;
|
|
await first.shutdown();
|
|
// The runtime child was signalled and no longer listens; the download stopped with it.
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
expect(runtime.state.children[0].signals).toContain('SIGTERM');
|
|
expect(runtime.listening).toBe(false);
|
|
expect(runtime.state.installed).toBe(false);
|
|
// Stopping writes nothing: the file still says what the job was doing.
|
|
expect(await readLocalModelState()).toMatchObject({ state: 'installing', step: 'model' });
|
|
const pullsBefore = runtime.state.pulls;
|
|
|
|
// ---- A new server starts over the same data dir ----
|
|
const second = startServer();
|
|
expect(second).not.toBe(first);
|
|
await second.boot();
|
|
const paused = (await get()).install;
|
|
expect(paused).toMatchObject({
|
|
state: 'paused',
|
|
step: 'model',
|
|
steps: ['runtime', 'model', 'verify', 'agent', 'connect'],
|
|
model: { id: pick.modelId, label: pick.modelLabel },
|
|
cli: 'opencode',
|
|
context: pick.context,
|
|
port: world.port,
|
|
running: false,
|
|
runtime: { installed: true, downloadBytes: 0 },
|
|
actions: ['resume', 'install', 'remove'],
|
|
});
|
|
expect(paused.error).toBeUndefined();
|
|
expect(await readLocalModelState()).toMatchObject({ state: 'paused', step: 'model' });
|
|
|
|
// It never continues without a click: no runtime is started, nothing is pulled.
|
|
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
expect(runtime.state.children).toHaveLength(1);
|
|
expect(runtime.state.pulls).toBe(pullsBefore);
|
|
expect((await get()).install.state).toBe('paused');
|
|
|
|
// ---- Continue ----
|
|
quickPull(runtime);
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
expect(resumed.json().data.state).toBe('installing');
|
|
const install = await settled();
|
|
expect(install.error).toBeUndefined();
|
|
expect(install).toMatchObject({ state: 'installed', running: true, endpointId: 'local-ollama' });
|
|
// The new server started the runtime itself and pulled once more; the archive was not fetched again.
|
|
expect(runtime.state.children).toHaveLength(2);
|
|
expect(runtime.state.pulls).toBe(pullsBefore + 1);
|
|
expect(world.archive.state.archiveRequests).toBe(1);
|
|
expect(world.installerRuns).toBe(1);
|
|
expect(await readCustomModelHosts(getDataDir())).toMatchObject([{ id: 'local-ollama', models: [pick.modelId] }]);
|
|
}, 60_000);
|
|
});
|
|
|
|
describe('the library: a second model, the switch back, and the delete of one model', () => {
|
|
it('installs A, adds B beside it, switches back to A, and deletes B, with one model in use throughout', async () => {
|
|
const { app, get, settled, install: clickInstall } = await routes();
|
|
const hostsModels = async () =>
|
|
(await readCustomModelHosts(getDataDir())).find((host) => host.id === 'local-ollama')?.models;
|
|
const post = (url: string, payload: unknown) => app.inject({ method: 'POST', url, payload: payload as object });
|
|
const deleteModel = (modelId: string) =>
|
|
app.inject({ method: 'DELETE', url: '/api/local-model/models', payload: { modelId } });
|
|
|
|
// ---- A: the first install ----
|
|
world.agentInstalled = true;
|
|
const first = await get();
|
|
const a = first.recommendation.pick!;
|
|
const bRow = first.recommendation.alternatives[0];
|
|
expect(bRow).toBeDefined();
|
|
expect(bRow.modelId).not.toBe(a.modelId);
|
|
const B_DIGEST = 'b'.repeat(64);
|
|
world.runtime = createFakeOllama({
|
|
model: { id: a.modelId, bytes: a.downloadBytes },
|
|
contextLength: a.context,
|
|
otherModels: [
|
|
{
|
|
id: bRow.modelId,
|
|
bytes: bRow.downloadBytes,
|
|
digest: B_DIGEST,
|
|
format: 'gguf',
|
|
runner: 'ggml',
|
|
quantization: 'Q4_K_M',
|
|
capabilities: ['completion', 'tools'],
|
|
},
|
|
],
|
|
});
|
|
const runtime = world.runtime;
|
|
expect((await clickInstall(a)).statusCode).toBe(200);
|
|
expect((await settled()).state).toBe('installed');
|
|
|
|
let status = await get();
|
|
expect(status.install.library).toEqual([
|
|
{
|
|
id: a.modelId,
|
|
label: a.modelLabel,
|
|
context: a.context,
|
|
sizeBytes: a.downloadBytes,
|
|
sizeGb: formatGb(a.downloadBytes),
|
|
active: true,
|
|
verified: true,
|
|
status: 'ready',
|
|
actions: [],
|
|
},
|
|
]);
|
|
// The store of the fake runtime writes no blobs: measured, and empty.
|
|
expect(status.install.store).toEqual({ bytes: 0, gb: '0.0' });
|
|
// The chooser says the row is the model in use.
|
|
expect(status.recommendation.pick?.library).toBe('active');
|
|
expect(status.install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
|
|
|
|
// ---- B: a second model. Refused while a session uses A. ----
|
|
const b = [status.recommendation.pick!, ...status.recommendation.alternatives].find(
|
|
(row) => row.modelId === bRow.modelId
|
|
)!;
|
|
// The runtime is on disk now, so the server's total for B is the model alone.
|
|
expect(b.installBytes).toBe(b.downloadBytes);
|
|
const addB = { modelId: b.modelId, cli: 'opencode', context: b.context, acknowledgedBytes: b.installBytes };
|
|
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
|
const busy = await post('/api/local-model/install', addB);
|
|
expect(busy.statusCode).toBe(409);
|
|
expect(busy.json().data).toMatchObject({ reason: 'sessions-open', sessions: ['w1-app'] });
|
|
expect(runtime.state.pulls).toBe(1);
|
|
world.sessions.clear();
|
|
|
|
// The fake runtime reports the context it was "started with": B's from here on.
|
|
runtime.switches.ps.contextLength = b.context;
|
|
const started = await post('/api/local-model/install', addB);
|
|
expect(started.statusCode).toBe(200);
|
|
expect(started.json().data).toMatchObject({ state: 'installing', model: { id: b.modelId } });
|
|
const withB = await settled();
|
|
expect(withB.error).toBeUndefined();
|
|
expect(withB).toMatchObject({
|
|
state: 'installed',
|
|
model: { id: b.modelId, label: b.modelLabel },
|
|
context: b.context,
|
|
endpointId: 'local-ollama',
|
|
});
|
|
// Both are on disk, A was not deleted, and the ONE endpoint record lists the model in use.
|
|
expect(runtime.state.installed).toBe(true);
|
|
expect(runtime.state.others).toEqual([b.modelId]);
|
|
expect(await hostsModels()).toEqual([b.modelId]);
|
|
expect(runtime.requests.some((request) => request.path === '/api/delete')).toBe(false);
|
|
await vi.waitFor(async () => {
|
|
const library = (await get()).install.library;
|
|
expect(library.map((row) => [row.id, row.active, row.status, row.actions])).toEqual([
|
|
[b.modelId, true, 'ready', []],
|
|
[a.modelId, false, 'ready', ['use', 'delete']],
|
|
]);
|
|
});
|
|
const stored = await readLocalModelState();
|
|
expect(stored.library?.map((entry) => [entry.id, entry.context, entry.digest])).toEqual([
|
|
[a.modelId, a.context, runtime.model.digest],
|
|
[b.modelId, b.context, B_DIGEST],
|
|
]);
|
|
// Only the model in use may be launched on the endpoint.
|
|
expect(await world.service.launchRefusalMessage('local-ollama', b.modelId)).toBeUndefined();
|
|
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toContain('is in use on this Mac');
|
|
|
|
// ---- The model in use cannot be deleted ----
|
|
const active = await deleteModel(b.modelId);
|
|
expect(active.statusCode).toBe(409);
|
|
expect(active.json().data).toMatchObject({ reason: 'model-active' });
|
|
expect(runtime.state.others).toEqual([b.modelId]);
|
|
|
|
// ---- Back to A: nothing is downloaded ----
|
|
status = await get();
|
|
expect(status.recommendation.pick?.library ?? status.recommendation.alternatives[0].library).toBeDefined();
|
|
world.sessions.set('s1', { name: 'w1-app', customModel: { endpointId: 'local-ollama' } });
|
|
const useBusy = await post('/api/local-model/use', { modelId: a.modelId });
|
|
expect(useBusy.statusCode).toBe(409);
|
|
expect(useBusy.json().data).toMatchObject({ reason: 'sessions-open' });
|
|
world.sessions.clear();
|
|
|
|
runtime.switches.ps.contextLength = a.context;
|
|
const pullsBefore = runtime.state.pulls;
|
|
const switched = await post('/api/local-model/use', { modelId: a.modelId });
|
|
expect(switched.statusCode).toBe(200);
|
|
expect(switched.json().data).toMatchObject({ state: 'installing', steps: ['verify', 'connect'] });
|
|
const backOnA = await settled();
|
|
expect(backOnA.error).toBeUndefined();
|
|
expect(backOnA).toMatchObject({ state: 'installed', model: { id: a.modelId }, context: a.context });
|
|
expect(runtime.state.pulls).toBe(pullsBefore);
|
|
expect(await hostsModels()).toEqual([a.modelId]);
|
|
expect(await world.service.launchRefusalMessage('local-ollama', a.modelId)).toBeUndefined();
|
|
|
|
// ---- Delete B: through the runtime, by its name ----
|
|
const notOurs = await deleteModel('someone-else:7b');
|
|
expect(notOurs.statusCode).toBe(400);
|
|
const gone = await deleteModel(b.modelId);
|
|
expect(gone.statusCode).toBe(200);
|
|
expect(gone.json().data.library.map((row: { id: string }) => row.id)).toEqual([a.modelId]);
|
|
expect(runtime.state.others).toEqual([]);
|
|
expect(runtime.state.installed).toBe(true);
|
|
const deletes = runtime.requests.filter((request) => request.path === '/api/delete');
|
|
expect(deletes.map((request) => request.body)).toEqual([{ model: b.modelId }]);
|
|
expect((await readLocalModelState()).library?.map((entry) => entry.id)).toEqual([a.modelId]);
|
|
expect((await get()).install.actions).toEqual(['start-session', 'stop', 'install', 'remove']);
|
|
// A is in use: it can only go with the whole installation.
|
|
expect((await deleteModel(a.modelId)).statusCode).toBe(409);
|
|
});
|
|
});
|
|
|
|
describe('a server that is killed leaves its runtime running: the next server never deletes under it', () => {
|
|
/**
|
|
* Installs up to the model download, then "kills" the server: its job stops and it signals
|
|
* nothing, as a process that got SIGKILL does. Its runtime child goes on listening. Answers
|
|
* what the next server needs to find that process: `ps` and `kill` over the fake child.
|
|
*/
|
|
async function killedDuringTheDownload() {
|
|
const api = await routes();
|
|
const pick = (await api.get()).recommendation.pick!;
|
|
const runtime = prepareRuntime(pick);
|
|
slowPull(runtime);
|
|
await api.install(pick);
|
|
await api.pulling();
|
|
|
|
const child = runtime.state.children[0];
|
|
const signal = child.kill.bind(child);
|
|
// A killed server sends its child nothing.
|
|
child.kill = () => false;
|
|
await world.service.shutdown();
|
|
expect(runtime.listening).toBe(true);
|
|
// The pid was stored the moment the child was spawned.
|
|
expect((await readLocalModelState()).runtime?.pid).toBe(child.pid);
|
|
|
|
const signals: Array<{ pid: number; signal: string; folderThere: boolean }> = [];
|
|
const processes: Partial<Pick<RuntimeSupervisorDeps, 'commandOfPid' | 'kill'>> = {
|
|
// What `ps -o command= -p <pid>` prints for the runtime while it lives.
|
|
commandOfPid: async (pid) => (pid === child.pid && runtime.listening ? `${world.paths.binary} serve` : undefined),
|
|
kill: (pid, sig) => {
|
|
signals.push({ pid, signal: sig, folderThere: existsSync(world.paths.runtimeDir) });
|
|
if (pid === child.pid) signal(sig);
|
|
},
|
|
};
|
|
return { ...api, pick, runtime, child, signals, processes };
|
|
}
|
|
|
|
it('Remove of the job it left ends that runtime BEFORE the folder is deleted', async () => {
|
|
const { app, runtime, child, signals, processes } = await killedDuringTheDownload();
|
|
startServer(processes);
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: { removeModel: true } });
|
|
expect(removed.statusCode).toBe(200);
|
|
expect(removed.json().data.state).toBe('idle');
|
|
// The process was signalled while its binary was still on disk, and it is gone.
|
|
expect(signals).toEqual([{ pid: child.pid, signal: 'SIGTERM', folderThere: true }]);
|
|
await vi.waitFor(() => expect(runtime.listening).toBe(false));
|
|
expect(existsSync(world.paths.root)).toBe(false);
|
|
expect(await readLocalModelState()).toEqual(expect.objectContaining({ schema: 1, state: 'idle' }));
|
|
}, 60_000);
|
|
|
|
it('the next start of the server ends it too: nothing would use it until a click', async () => {
|
|
const { get, settled, app, runtime, child, signals, processes } = await killedDuringTheDownload();
|
|
const next = startServer(processes);
|
|
await next.boot();
|
|
expect(signals.map((entry) => [entry.pid, entry.signal])).toEqual([[child.pid, 'SIGTERM']]);
|
|
await vi.waitFor(() => expect(runtime.listening).toBe(false));
|
|
expect((await get()).install).toMatchObject({ state: 'paused', step: 'model', running: false });
|
|
|
|
// Continue starts a runtime of its own on the same port and finishes the install.
|
|
quickPull(runtime);
|
|
const resumed = await app.inject({ method: 'POST', url: '/api/local-model/resume', payload: {} });
|
|
expect(resumed.statusCode).toBe(200);
|
|
const install = await settled();
|
|
expect(install).toMatchObject({ state: 'installed', running: true });
|
|
expect(runtime.state.children).toHaveLength(2);
|
|
}, 60_000);
|
|
|
|
it('a pid that now runs something else is never signalled', async () => {
|
|
const { app, child, signals } = await killedDuringTheDownload();
|
|
// The pid was reused by another program since.
|
|
startServer({
|
|
commandOfPid: async () => '/Applications/Something.app/Contents/MacOS/Something',
|
|
kill: (pid, sig) => signals.push({ pid, signal: sig, folderThere: true }),
|
|
});
|
|
const removed = await app.inject({ method: 'DELETE', url: '/api/local-model', payload: {} });
|
|
expect(removed.statusCode).toBe(200);
|
|
expect(signals).toEqual([]);
|
|
// Left to the test's own cleanup.
|
|
child.crash(0);
|
|
}, 60_000);
|
|
});
|