diff --git a/test/client.test.js b/test/client.test.js new file mode 100644 index 0000000..811577b --- /dev/null +++ b/test/client.test.js @@ -0,0 +1,298 @@ +/** + * Tests for the browser half. + * + * There is no browser here, so the bundle is evaluated the way the real module + * loader would — inside `window.__ModuleLoader__.load` — with a fake `require` + * that supplies React and THROWS on anything else. That last part is the useful + * one: it proves the bundle stays inside the platform's seed table, which is the + * constraint a hand-written client half is most likely to break. + * + * The card is then rendered by calling the registered component and expanding + * the elements it returns, and its handlers are driven directly. That is not a + * substitute for looking at it in a browser, but it does check the wiring the + * card cannot work without. + */ + +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { test } from 'node:test' + +const SOURCE = readFileSync(new URL('../client/client.js', import.meta.url), 'utf8') + +/** Minimal React: enough for createElement plus the three hooks the card uses. */ +function makeFakeReact() { + const states = [] + let cursor = 0 + return { + reset() { + cursor = 0 + }, + api: { + createElement: (type, props, ...children) => ({ type, props: props ?? {}, children }), + useState(initial) { + const index = cursor + cursor += 1 + if (!(index in states)) states[index] = typeof initial === 'function' ? initial() : initial + return [ + states[index], + (next) => { + states[index] = typeof next === 'function' ? next(states[index]) : next + }, + ] + }, + useEffect() {}, + useMemo(factory) { + return factory() + }, + }, + } +} + +/** Evaluate the bundle against a fake loader and return its exports. */ +function loadBundle() { + let loaded + const window = { __ModuleLoader__: { load: (spec) => { loaded = spec } } } + new Function('window', SOURCE)(window) + assert.ok(loaded !== undefined, 'the bundle must call window.__ModuleLoader__.load') + const react = makeFakeReact() + const exported = loaded.factory((name) => { + if (name === 'react') return react.api + throw new Error(`the client bundle required "${name}", which is not in the platform seed table`) + }) + return { id: loaded.id, exported, react } +} + +/** + * Resolve function components by calling them, until a host element or null. + * + * This is the whole "renderer": the slot hands back an element whose type is the + * card component, and function-typed children are expanded the same way. + */ +function expand(node, depth = 0) { + if (depth > 50) throw new Error('the component tree did not settle') + if (node === null || node === undefined) return null + if (typeof node !== 'object') return node + if (Array.isArray(node)) return node + if (typeof node.type === 'function') return expand(node.type(node.props), depth + 1) + return node +} + +/** Depth-first walk over a rendered tree, expanding components as it goes. */ +function findAll(node, predicate, out = []) { + if (node === null || node === undefined) return out + if (Array.isArray(node)) { + for (const child of node) findAll(child, predicate, out) + return out + } + if (typeof node !== 'object') return out + if (typeof node.type === 'function') { + findAll(expand(node), predicate, out) + return out + } + if (predicate(node)) out.push(node) + for (const child of node.children ?? []) findAll(child, predicate, out) + return out +} + +function textOf(node) { + if (typeof node === 'string') return node + if (node === null || node === undefined || typeof node !== 'object') return '' + if (Array.isArray(node)) return node.map(textOf).join('') + return (node.children ?? []).map(textOf).join('') +} + +/** A SettingsScope stand-in recording the writes it was asked to make. */ +function fakeScope(snapshot) { + const calls = { set: [], unset: [] } + return { + calls, + getSnapshot: () => snapshot, + subscribe: () => () => {}, + set: async (field, value) => { + calls.set.push([field, value]) + }, + unset: async (field) => { + calls.unset.push(field) + }, + } +} + +const READY = { + status: 'ready', + value: { embeddingModel: '', embeddingDimensions: 0 }, + user: {}, + base: {}, + revision: 1, + writable: true, + mode: 'host', +} + +/** Register the card the way the host would, and hand back its component. */ +function registered() { + const { id, exported, react } = loadBundle() + let registration + let render + const ctx = { + inject(deps, callback) { + assert.deepEqual(deps, ['settingsScope']) + callback({ + settingsScope: { bind: () => fakeScope(READY) }, + slots: { + inject(_name, produce) { + produce() + }, + register(options, component) { + registration = options + render = component + }, + }, + }) + }, + } + exported.apply(ctx) + const draw = (scope) => expand(render({ scope })) + return { id, exported, react, registration, render, draw } +} + +const inputFor = (tree, label) => + findAll(tree, (node) => node.type === 'input' && node.props['aria-label'] === label)[0] + +const buttonStartingWith = (tree, prefix) => + findAll(tree, (node) => node.type === 'button' && textOf(node).startsWith(prefix))[0] + +test('the bundle id matches the package name the loader is told about', () => { + const { id } = loadBundle() + assert.equal(id, 'dsh-slopo') +}) + +test('the client bundle requires nothing outside the platform seed table', () => { + // loadBundle() throws on any specifier other than react. + assert.doesNotThrow(() => loadBundle()) +}) + +test('applying registers exactly one settings.plugin.item card, keyed on the namespace', () => { + const { registration, exported } = registered() + assert.equal(exported.name, 'dsh-slopo') + assert.deepEqual(exported.inject, ['slots']) + assert.equal(registration.name, 'settings.plugin.item') + // The key must equal the Host namespace, or the tab never dispatches the card. + assert.equal(registration.key, 'dsh-slopo') +}) + +test('the card renders a control for every setting the Host accepts', () => { + const { draw } = registered() + const tree = draw(fakeScope(READY)) + const labels = findAll(tree, (node) => node.type === 'label').map(textOf) + for (const expected of [ + 'Embedding model', + 'Embedding dimensions', + 'Embedding API base', + 'Embedding API key', + 'Extra embedding parameters', + 'slopo executable', + 'Source directory', + 'Exclude patterns', + 'State directory', + 'Ignore file', + 'Foreground timeout', + ]) { + assert.ok( + labels.some((label) => label.includes(expected)), + `no control labelled "${expected}"`, + ) + } +}) + +test('the API key is rendered as a password field', () => { + const { draw } = registered() + const tree = draw(fakeScope(READY)) + assert.equal(inputFor(tree, 'Embedding API key').props.type, 'password') +}) + +test('the card renders nothing when the namespace is unavailable', () => { + const { draw } = registered() + assert.equal(draw(fakeScope({ ...READY, status: 'unavailable' })), null) +}) + +test('editing a field marks the card dirty and saving writes it through set()', async () => { + const { draw, react } = registered() + const scope = fakeScope(READY) + + inputFor(draw(scope), 'Embedding model').props.onChange({ target: { value: 'ollama/example:latest' } }) + + // Re-render the way a state update would. + react.reset() + const tree = draw(scope) + const save = buttonStartingWith(tree, 'Save') + assert.equal(save.props.disabled, false, 'a changed field must enable Save') + await save.props.onClick() + + assert.deepEqual(scope.calls.set, [['embeddingModel', 'ollama/example:latest']]) + assert.deepEqual(scope.calls.unset, []) +}) + +test('a whole number field is sent as a number, not the string the input holds', async () => { + const { draw, react } = registered() + const scope = fakeScope(READY) + inputFor(draw(scope), 'Embedding dimensions').props.onChange({ target: { value: '768' } }) + react.reset() + await buttonStartingWith(draw(scope), 'Save').props.onClick() + assert.deepEqual(scope.calls.set, [['embeddingDimensions', 768]]) +}) + +test('clearing a field is an unset, so it re-inherits rather than becoming empty', async () => { + const { draw, react } = registered() + const scope = fakeScope({ ...READY, value: { embeddingModel: 'ollama/old:latest', embeddingDimensions: 768 } }) + inputFor(draw(scope), 'Embedding model').props.onChange({ target: { value: '' } }) + react.reset() + await buttonStartingWith(draw(scope), 'Save').props.onClick() + assert.deepEqual(scope.calls.unset, ['embeddingModel']) + assert.deepEqual(scope.calls.set, [], 'a cleared field must not be written as an empty string') +}) + +test('an exclude list round-trips through comma-separated text', async () => { + const { draw, react } = registered() + const scope = fakeScope({ ...READY, value: { embeddingModel: 'm', embeddingDimensions: 1 } }) + inputFor(draw(scope), 'Exclude patterns').props.onChange({ target: { value: '**/test/**, *.min.js' } }) + react.reset() + await buttonStartingWith(draw(scope), 'Save').props.onClick() + assert.deepEqual(scope.calls.set, [['sourceDirExclude', ['**/test/**', '*.min.js']]]) +}) + +test('a boolean field writes a real boolean', async () => { + const { draw, react } = registered() + const scope = fakeScope(READY) + const checkbox = findAll( + draw(scope), + (node) => node.type === 'input' && node.props['aria-label'] === 'Install slopo with uv when missing', + )[0] + checkbox.props.onChange({ target: { checked: true } }) + react.reset() + await buttonStartingWith(draw(scope), 'Save').props.onClick() + assert.deepEqual(scope.calls.set, [['autoInstall', true]]) +}) + +test('an overridden field is marked, and Reset clears every override', async () => { + const { draw } = registered() + const scope = fakeScope({ + ...READY, + value: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 }, + user: { embeddingModel: 'ollama/x:latest', embeddingDimensions: 768 }, + }) + const tree = draw(scope) + const labels = findAll(tree, (node) => node.type === 'label').map(textOf) + assert.ok(labels.some((label) => label.includes('overridden')), 'a user-set field must be marked') + + const reset = buttonStartingWith(tree, 'Reset') + assert.equal(reset.props.disabled, false) + await reset.props.onClick() + assert.deepEqual(scope.calls.unset.sort(), ['embeddingDimensions', 'embeddingModel']) +}) + +test('a read-only namespace disables every control', () => { + const { draw } = registered() + const tree = draw(fakeScope({ ...READY, writable: false, mode: 'memory' })) + const inputs = findAll(tree, (node) => node.type === 'input') + assert.ok(inputs.length > 0) + for (const input of inputs) assert.equal(input.props.disabled, true) +}) diff --git a/test/plugin.test.js b/test/plugin.test.js index 1fd7949..6f2bfdc 100644 --- a/test/plugin.test.js +++ b/test/plugin.test.js @@ -422,10 +422,13 @@ test('a workspace-write session with stateDir inside the workspace proceeds', as assert.deepEqual(state.shellRequests[0].sandboxPolicy, { mode: 'workspace-write', workspaceRoot: workspace }) }) -test('configure and status never touch the shell, so they work under any sandbox', async () => { +test('configure never touches the shell, and status only runs a read-only version check', async () => { const { state } = harness({}, { sandboxMode: 'workspace-write' }) const tool = state.tools.get('slopo') - await tool.execute({ action: 'status' }, execContext('/some/workspace')) await tool.execute({ action: 'configure', config: { embeddingModel: 'x' } }, execContext('/some/workspace')) - assert.deepEqual(state.shellRequests, []) + assert.deepEqual(state.shellRequests, [], 'configure must not execute anything') + await tool.execute({ action: 'status' }, execContext('/some/workspace')) + for (const request of state.shellRequests) { + assert.match(request.command, /--version/, 'status may only run a read-only version check') + } }) diff --git a/test/probe.test.js b/test/probe.test.js new file mode 100644 index 0000000..13a9577 --- /dev/null +++ b/test/probe.test.js @@ -0,0 +1,137 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { bareModelName, embeddingUrl, probeEmbeddingEndpoint } from '../lib/probe.js' + +/** A fetch stand-in returning one canned response. */ +function fakeFetch({ ok = true, status = 200, body = {}, throws, onCall } = {}) { + return async (url, options) => { + if (onCall !== undefined) onCall(url, options) + if (throws !== undefined) throw throws + return { + ok, + status, + async text() { + return typeof body === 'string' ? body : JSON.stringify(body) + }, + } + } +} + +test('bareModelName strips a known provider prefix', () => { + assert.equal(bareModelName('ollama/unclemusclez/jina-embeddings-v2-base-code:latest'), 'unclemusclez/jina-embeddings-v2-base-code:latest') + assert.equal(bareModelName('openai/text-embedding-3-small'), 'text-embedding-3-small') + assert.equal(bareModelName('hosted_vllm/meta-llama/Llama-3-8B'), 'meta-llama/Llama-3-8B') +}) + +test('bareModelName leaves a model that merely contains a slash alone', () => { + // An unknown first segment is far more likely to be part of the model name + // than a provider prefix, and mangling it would make the probe lie. + assert.equal(bareModelName('unclemusclez/jina-embeddings-v2-base-code:latest'), 'unclemusclez/jina-embeddings-v2-base-code:latest') + assert.equal(bareModelName('text-embedding-3-small'), 'text-embedding-3-small') +}) + +test('embeddingUrl tolerates a base that already carries the version segment', () => { + assert.equal(embeddingUrl('http://host:11434'), 'http://host:11434/v1/embeddings') + assert.equal(embeddingUrl('http://host:11434/'), 'http://host:11434/v1/embeddings') + assert.equal(embeddingUrl('http://host:11434/v1'), 'http://host:11434/v1/embeddings') + assert.equal(embeddingUrl(' http://host:11434/v2/ '), 'http://host:11434/v2/embeddings') + assert.equal(embeddingUrl(''), '') +}) + +test('probeEmbeddingEndpoint reports a matching dimension as ok', async () => { + let seen + const result = await probeEmbeddingEndpoint({ + apiBase: 'http://host:11434', + model: 'ollama/unclemusclez/jina:latest', + dimensions: 768, + fetchImpl: fakeFetch({ + body: { data: [{ embedding: new Array(768).fill(0.1) }] }, + onCall: (url, options) => { + seen = { url, options } + }, + }), + }) + assert.equal(result.ok, true) + assert.equal(result.dimensions, 768) + assert.equal(result.expected, 768) + assert.equal(result.url, 'http://host:11434/v1/embeddings') + // The prefix is LiteLLM's; the endpoint must receive the bare model name. + assert.equal(JSON.parse(seen.options.body).model, 'unclemusclez/jina:latest') +}) + +test('probeEmbeddingEndpoint catches a dimension mismatch, which is what slopo freezes', async () => { + const result = await probeEmbeddingEndpoint({ + apiBase: 'http://host:11434', + model: 'ollama/m:latest', + dimensions: 1024, + fetchImpl: fakeFetch({ body: { data: [{ embedding: new Array(768).fill(0) }] } }), + }) + assert.equal(result.ok, false) + assert.equal(result.dimensions, 768) + assert.equal(result.expected, 1024) + assert.match(result.error, /returns 768 dimensions but embeddingDimensions is 1024/) +}) + +test('probeEmbeddingEndpoint reports an HTTP error instead of throwing', async () => { + const result = await probeEmbeddingEndpoint({ + apiBase: 'http://host:11434', + model: 'ollama/m:latest', + dimensions: 768, + fetchImpl: fakeFetch({ ok: false, status: 404, body: 'model not found' }), + }) + assert.equal(result.ok, false) + assert.equal(result.status, 404) + assert.match(result.error, /model not found/) +}) + +test('probeEmbeddingEndpoint reports an unreachable endpoint instead of throwing', async () => { + const result = await probeEmbeddingEndpoint({ + apiBase: 'http://host:11434', + model: 'ollama/m:latest', + dimensions: 768, + fetchImpl: fakeFetch({ throws: new Error('connect ECONNREFUSED') }), + }) + assert.equal(result.ok, false) + assert.equal(result.reachable, false) + assert.match(result.error, /ECONNREFUSED/) +}) + +test('probeEmbeddingEndpoint reports a timeout as a timeout', async () => { + const abort = new Error('aborted') + abort.name = 'AbortError' + const result = await probeEmbeddingEndpoint({ + apiBase: 'http://host:11434', + model: 'm', + dimensions: 768, + timeoutMs: 25, + fetchImpl: fakeFetch({ throws: abort }), + }) + assert.equal(result.ok, false) + assert.match(result.error, /no response within 25ms/) +}) + +test('probeEmbeddingEndpoint skips cleanly with nothing to probe', async () => { + const result = await probeEmbeddingEndpoint({ apiBase: '', model: 'm', dimensions: 768 }) + assert.equal(result.checked, false) + assert.match(result.skipped, /no embeddingApiBase/) +}) + +test('probeEmbeddingEndpoint sends the key as a bearer token only when there is one', async () => { + const seen = [] + const call = (apiKey) => + probeEmbeddingEndpoint({ + apiBase: 'http://host', + model: 'm', + dimensions: 1, + apiKey, + fetchImpl: fakeFetch({ + body: { data: [{ embedding: [1] }] }, + onCall: (_url, options) => seen.push(options.headers), + }), + }) + await call('secret-key') + await call('') + assert.equal(seen[0].authorization, 'Bearer secret-key') + assert.equal(Object.hasOwn(seen[1], 'authorization'), false) +})