include llama.cpp since there were some edits
This commit is contained in:
1 parent
db7f28d087
commit
8c66990853
3918 files changed
+1640170
-1
No files matched your search
@@ -0,0 +1,58 @@
|
||||
# Agentic thread perf harness
|
||||
|
||||
Two tiers, both reusing the existing vitest projects (see `vite.config.ts`).
|
||||
|
||||
## Tier 1 - `agentic-stream.perf.svelte.test.ts` (project: `client`, real Chromium)
|
||||
|
||||
Mounts `ChatMessageAgenticContent` and replays a stream, replacing the message
|
||||
object on each chunk exactly as the real pipeline does:
|
||||
|
||||
- `chat.svelte.ts` `updateStreamingUI()` runs per SSE chunk
|
||||
- `conversations.svelte.ts` `updateMessageAtIndex` does `{ ...old, ...updates }`
|
||||
|
||||
That new object identity is the thing under test: it cascades through
|
||||
`deriveAgenticSections` (which returns fresh `AgenticSection` objects) into every
|
||||
tool-call block in the message, including completed ones.
|
||||
|
||||
```
|
||||
npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
|
||||
```
|
||||
|
||||
### Reading the output
|
||||
|
||||
- `mean` / `p95` / `max` - the synchronous window per token: prop write,
|
||||
`await tick()`, then a forced `offsetHeight` read so style and layout are
|
||||
included rather than deferred.
|
||||
- `sync` - sum of those windows. This is the number to optimize.
|
||||
- `wall` - the whole run including work `MarkdownContent` defers into its own
|
||||
`requestAnimationFrame`. It carries a ~16.7ms/token idle floor because the
|
||||
harness yields a frame each iteration, so compare `wall` **across fixtures**,
|
||||
never against `sync`.
|
||||
|
||||
### The knobs, and what each one discriminates
|
||||
|
||||
The point of the harness is the _scaling curve_, not any single number.
|
||||
|
||||
| Knob | Reads on |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `priorToolCalls` (0/1/5/20) | the reactive fan-out. Flat => no fan-out. Linear => confirmed. |
|
||||
| `toolResultBytes` | whole-blob string scans (`extractSearchResults`, `parseToolResultWithMedia`, `classifyToolResult`). |
|
||||
| `editFileEdits` | `computeLineDiff`, the O(m\*n) LCS. |
|
||||
| `openCodeFence` | `hljs.highlightAuto` on partial code. |
|
||||
|
||||
Deliberately no hard assertions: CI timing is noisy and the value here is the
|
||||
before/after delta, not a gate.
|
||||
|
||||
### Caveat
|
||||
|
||||
This measures one message's subtree. In the real app `ChatMessages.svelte`
|
||||
rebuilds its whole `displayMessages` list per token, so multiply by the number
|
||||
of rendered messages to get the conversation-level cost.
|
||||
|
||||
## Tier 2 - `../unit/agentic-hotpath.bench.ts` (project: `unit`, node)
|
||||
|
||||
Per-call costs for the pure functions the curve implicates.
|
||||
|
||||
```
|
||||
npx vitest bench --project=unit --run tests/unit/agentic-hotpath.bench.ts
|
||||
```
|
||||
@@ -0,0 +1,426 @@
|
||||
// Tier 1 perf harness for the agentic thread.
|
||||
//
|
||||
// Drives ChatMessageAgenticContent the way the real streaming pipeline does:
|
||||
// chat.svelte.ts -> conversations.svelte.ts:184 replaces the message object per
|
||||
// SSE chunk (`{ ...old, ...updates }`), which changes prop identity and cascades
|
||||
// through deriveAgenticSections into every tool-call block in the message.
|
||||
//
|
||||
// The fixture is parameterized so the *scaling curve* identifies the culprit -
|
||||
// a single number would not. See tests/client/README-perf.md.
|
||||
//
|
||||
// Run: npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
|
||||
|
||||
import { perfState } from './components/agentic-perf-state.svelte';
|
||||
import AgenticPerfWrapper from './components/AgenticPerfWrapper.svelte';
|
||||
import ChatMessagesPerfWrapper from './components/ChatMessagesPerfWrapper.svelte';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
// --- fixture construction -------------------------------------------------
|
||||
|
||||
interface FixtureOpts {
|
||||
/** Completed tool-call sections preceding the streaming text. */
|
||||
priorToolCalls: number;
|
||||
/** Size of each tool result blob. */
|
||||
toolResultBytes: number;
|
||||
/** Number of edit_file calls (each a 400x400-line diff). */
|
||||
editFileEdits: number;
|
||||
/** Leave an unclosed ``` fence at the end of the streamed content. */
|
||||
openCodeFence: boolean;
|
||||
/**
|
||||
* Emit a blank line every N chunks so the content forms real markdown
|
||||
* blocks. 0 = one unbroken paragraph, which defeats MarkdownContent's
|
||||
* stable-block cache entirely (worst case). Typical prose has breaks.
|
||||
*/
|
||||
paragraphEvery: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: FixtureOpts = {
|
||||
editFileEdits: 0,
|
||||
openCodeFence: false,
|
||||
paragraphEvery: 0,
|
||||
priorToolCalls: 0,
|
||||
toolResultBytes: 1024
|
||||
};
|
||||
|
||||
function blob(bytes: number, seed: string): string {
|
||||
const line = `${seed} output line with some representative width to it`;
|
||||
const n = Math.max(1, Math.ceil(bytes / (line.length + 1)));
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) out.push(`${line} ${i}`);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function diffLines(n: number, seed: string): string {
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) out.push(`${seed} line ${i} const value_${i} = compute(${i});`);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
let msgSeq = 0;
|
||||
|
||||
function baseMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'perf-conv',
|
||||
id: `m${msgSeq++}`,
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
function buildFixture(opts: FixtureOpts): {
|
||||
message: DatabaseMessage;
|
||||
toolMessages: DatabaseMessage[];
|
||||
} {
|
||||
const toolCalls: unknown[] = [];
|
||||
const toolMessages: DatabaseMessage[] = [];
|
||||
|
||||
for (let i = 0; i < opts.priorToolCalls; i++) {
|
||||
const id = `call_${i}`;
|
||||
|
||||
toolCalls.push({
|
||||
function: {
|
||||
arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
|
||||
name: 'exec_shell_command'
|
||||
},
|
||||
id,
|
||||
type: 'function'
|
||||
});
|
||||
toolMessages.push(
|
||||
baseMessage({
|
||||
content: `${blob(opts.toolResultBytes, `t${i}`)}\n[exit code: 0]`,
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < opts.editFileEdits; i++) {
|
||||
const id = `edit_${i}`;
|
||||
|
||||
toolCalls.push({
|
||||
function: {
|
||||
arguments: JSON.stringify({
|
||||
edits: [{ new_text: diffLines(400, 'new'), old_text: diffLines(400, 'old') }],
|
||||
path: `/src/file_${i}.ts`
|
||||
}),
|
||||
name: 'edit_file'
|
||||
},
|
||||
id,
|
||||
type: 'function'
|
||||
});
|
||||
toolMessages.push(
|
||||
baseMessage({
|
||||
content: JSON.stringify({ edits_applied: 1, result: 'ok' }),
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const message = baseMessage({
|
||||
content: '',
|
||||
toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined
|
||||
});
|
||||
|
||||
return { message, toolMessages };
|
||||
}
|
||||
|
||||
// --- the driver -----------------------------------------------------------
|
||||
|
||||
interface Sample {
|
||||
label: string;
|
||||
tokens: number;
|
||||
mean: number;
|
||||
p95: number;
|
||||
max: number;
|
||||
/** Sum of the synchronous per-token windows. */
|
||||
total: number;
|
||||
/** Wall-clock for the whole run incl. deferred rAF work, then settle. */
|
||||
wall: number;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
const results: Sample[] = [];
|
||||
|
||||
/**
|
||||
* Replays `tokens` streamed chunks, replacing the message object each time
|
||||
* exactly as conversations.svelte.ts:184 does, flushing Svelte and forcing
|
||||
* layout so the measurement includes render + style/layout, not just script.
|
||||
*/
|
||||
async function measure(label: string, partial: Partial<FixtureOpts>, tokens = 60) {
|
||||
const opts = { ...DEFAULTS, ...partial };
|
||||
const { message, toolMessages } = buildFixture(opts);
|
||||
|
||||
perfState.message = message;
|
||||
perfState.toolMessages = toolMessages;
|
||||
perfState.isStreaming = true;
|
||||
|
||||
// Every wrapper reads the same module state, so a leftover mount from a
|
||||
// previous fixture would re-render on each mutation and fold its cost into
|
||||
// this measurement. Tear down explicitly between fixtures.
|
||||
const { unmount } = render(AgenticPerfWrapper);
|
||||
|
||||
await tick();
|
||||
|
||||
const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
|
||||
|
||||
let accumulated = opts.openCodeFence ? '```notalanguage\n' : '';
|
||||
|
||||
const durations: number[] = [];
|
||||
const wallStart = performance.now();
|
||||
|
||||
for (let i = 0; i < tokens; i++) {
|
||||
accumulated += CHUNK;
|
||||
|
||||
if (opts.paragraphEvery > 0 && (i + 1) % opts.paragraphEvery === 0) {
|
||||
accumulated += '\n\n';
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
|
||||
// Mirrors updateMessageAtIndex: a brand-new object identity per chunk.
|
||||
perfState.message = { ...perfState.message!, content: accumulated };
|
||||
await tick();
|
||||
void document.body.offsetHeight; // force style + layout
|
||||
durations.push(performance.now() - t0);
|
||||
|
||||
// MarkdownContent coalesces its parse into a rAF, so that work lands
|
||||
// outside the window above. Yield a frame each iteration so it is
|
||||
// captured in `wall` - the gap between `wall` and `total` is the
|
||||
// deferred cost.
|
||||
await nextFrame();
|
||||
}
|
||||
|
||||
// Let any trailing coalesced work drain before stopping the clock.
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
const wall = performance.now() - wallStart;
|
||||
|
||||
await unmount();
|
||||
|
||||
durations.sort((a, b) => a - b);
|
||||
const total = durations.reduce((a, b) => a + b, 0);
|
||||
|
||||
results.push({
|
||||
label,
|
||||
max: durations[durations.length - 1],
|
||||
mean: total / durations.length,
|
||||
p95: durations[Math.floor(durations.length * 0.95)],
|
||||
tokens,
|
||||
total,
|
||||
wall
|
||||
});
|
||||
}
|
||||
|
||||
function report() {
|
||||
const pad = (s: string, n: number) => s.padEnd(n);
|
||||
const num = (n: number) => n.toFixed(2).padStart(8);
|
||||
const header = `${pad('fixture', 40)}${pad('tok', 5)}${'mean'.padStart(8)}${'p95'.padStart(8)}${'max'.padStart(8)}${'sync'.padStart(9)}${'wall'.padStart(9)}`;
|
||||
const lines = [
|
||||
'',
|
||||
'=== Tier 1: ms per streamed token (agentic content subtree) ===',
|
||||
'mean/p95/max = synchronous window per token (script + style + layout).',
|
||||
'sync = sum of those windows.',
|
||||
'wall = whole run, incl. work MarkdownContent defers into its own rAF.',
|
||||
' NOTE: wall carries a ~16.7ms/token idle floor from the harness',
|
||||
' yielding a frame each iteration (60 tokens => ~1000ms floor).',
|
||||
' Compare wall ACROSS fixtures / against the baseline row,',
|
||||
' never against sync.',
|
||||
'',
|
||||
header,
|
||||
'-'.repeat(header.length)
|
||||
];
|
||||
|
||||
for (const r of results) {
|
||||
lines.push(
|
||||
`${pad(r.label, 40)}${pad(String(r.tokens), 5)}${num(r.mean)}${num(r.p95)}${num(r.max)}${num(r.total)}${num(r.wall)}`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
// --- conversation-level driver --------------------------------------------
|
||||
// The per-message driver above cannot see the fan-out in ChatMessages: a single
|
||||
// token mutation invalidates `displayMessages`, which rebuilds a fresh
|
||||
// toolMessages array for EVERY message in the conversation. Drive the real
|
||||
// store through the real list component to measure that.
|
||||
|
||||
async function measureConversation(
|
||||
label: string,
|
||||
priorMessages: number,
|
||||
tokens = 60,
|
||||
/**
|
||||
* Give each prior assistant turn a resolved tool call, so the fixture pays
|
||||
* `hasAgenticContent`'s JSON.parse and the tool-message grouping walk that a
|
||||
* real agent thread would - plain prose messages skip both.
|
||||
*/
|
||||
agentic = false
|
||||
) {
|
||||
const history: DatabaseMessage[] = [];
|
||||
|
||||
for (let i = 0; i < priorMessages; i++) {
|
||||
const isAssistant = i % 2 !== 0;
|
||||
|
||||
if (isAssistant && agentic) {
|
||||
const id = `prior_call_${i}`;
|
||||
|
||||
history.push(
|
||||
baseMessage({
|
||||
content: `Message ${i}`,
|
||||
role: MessageRole.ASSISTANT,
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: {
|
||||
arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
|
||||
name: 'exec_shell_command'
|
||||
},
|
||||
id,
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
})
|
||||
);
|
||||
history.push(
|
||||
baseMessage({
|
||||
content: `${blob(1024, `r${i}`)}\n[exit code: 0]`,
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
history.push(
|
||||
baseMessage({
|
||||
content: `Message ${i}: ${blob(512, `m${i}`)}`,
|
||||
role: isAssistant ? MessageRole.ASSISTANT : MessageRole.USER
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const streaming = baseMessage({ content: '', role: MessageRole.ASSISTANT });
|
||||
|
||||
history.push(streaming);
|
||||
|
||||
conversationsStore.activeMessages = history;
|
||||
|
||||
const { unmount } = render(ChatMessagesPerfWrapper);
|
||||
|
||||
await tick();
|
||||
|
||||
const idx = conversationsStore.findMessageIndex(streaming.id);
|
||||
const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
|
||||
|
||||
let accumulated = '';
|
||||
|
||||
const durations: number[] = [];
|
||||
const wallStart = performance.now();
|
||||
|
||||
for (let i = 0; i < tokens; i++) {
|
||||
accumulated += CHUNK;
|
||||
|
||||
const t0 = performance.now();
|
||||
|
||||
// The real path: chat.svelte.ts -> conversations.svelte.ts.
|
||||
conversationsStore.updateMessageAtIndex(idx, { content: accumulated });
|
||||
await tick();
|
||||
void document.body.offsetHeight;
|
||||
durations.push(performance.now() - t0);
|
||||
|
||||
await nextFrame();
|
||||
}
|
||||
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
const wall = performance.now() - wallStart;
|
||||
|
||||
await unmount();
|
||||
conversationsStore.activeMessages = [];
|
||||
|
||||
durations.sort((a, b) => a - b);
|
||||
const total = durations.reduce((a, b) => a + b, 0);
|
||||
|
||||
results.push({
|
||||
label,
|
||||
max: durations[durations.length - 1],
|
||||
mean: total / durations.length,
|
||||
p95: durations[Math.floor(durations.length * 0.95)],
|
||||
tokens,
|
||||
total,
|
||||
wall
|
||||
});
|
||||
}
|
||||
|
||||
// --- the matrix -----------------------------------------------------------
|
||||
// Sequential, in one test, so the table prints together and the samples do not
|
||||
// interleave with other suites competing for the main thread.
|
||||
|
||||
describe('agentic streaming perf', () => {
|
||||
it('scales', { timeout: 600_000 }, async () => {
|
||||
// Baseline: plain text streaming, nothing agentic.
|
||||
await measure('baseline: no tool calls', {});
|
||||
|
||||
// Knob: priorToolCalls. Flat => no fan-out. Linear => fan-out confirmed.
|
||||
await measure('priorToolCalls=1 (1KB results)', { priorToolCalls: 1 });
|
||||
await measure('priorToolCalls=5 (1KB results)', { priorToolCalls: 5 });
|
||||
await measure('priorToolCalls=20 (1KB results)', { priorToolCalls: 20 });
|
||||
|
||||
// Knob: toolResultBytes, at fixed section count.
|
||||
await measure('5 calls x 200KB results', {
|
||||
priorToolCalls: 5,
|
||||
toolResultBytes: 200 * 1024
|
||||
});
|
||||
|
||||
// Knob: editFileEdits (computeLineDiff, 400x400 LCS).
|
||||
await measure('3 edit_file calls (400x400 diff)', { editFileEdits: 3 });
|
||||
|
||||
// Knob: openCodeFence (hljs highlightAuto on partial code).
|
||||
await measure('open code fence, unknown language', { openCodeFence: true });
|
||||
|
||||
// Conversation level: does streaming one message cost more as the
|
||||
// conversation grows? Flat => no fan-out. Linear => confirmed.
|
||||
await measureConversation('convo: 1 prior message', 1);
|
||||
await measureConversation('convo: 10 prior messages', 10);
|
||||
await measureConversation('convo: 40 prior messages', 40);
|
||||
|
||||
// Same, but each prior assistant turn carries a resolved tool call.
|
||||
await measureConversation('convo: 10 prior, agentic', 10, 60, true);
|
||||
await measureConversation('convo: 40 prior, agentic', 40, 60, true);
|
||||
|
||||
// Message length: MarkdownContent re-parses the whole accumulated string
|
||||
// each frame, so per-token cost should climb as the response grows.
|
||||
// A rising mean across these three rows means O(n^2) over the stream.
|
||||
// One unbroken paragraph: worst case, stable-block cache never applies.
|
||||
await measure('len 60tok 1-para (worst case)', {}, 60);
|
||||
await measure('len 250tok 1-para (worst case)', {}, 250);
|
||||
await measure('len 600tok 1-para (worst case)', {}, 600);
|
||||
|
||||
// Same lengths, broken into paragraphs every 8 chunks: the typical shape,
|
||||
// where only the trailing paragraph should be unstable.
|
||||
await measure('len 60tok paras (typical)', { paragraphEvery: 8 }, 60);
|
||||
await measure('len 250tok paras (typical)', { paragraphEvery: 8 }, 250);
|
||||
await measure('len 600tok paras (typical)', { paragraphEvery: 8 }, 600);
|
||||
|
||||
report();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { validateApiKey } from '$lib/utils/api-key-validation';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function fakeFetch(status: number, capture: { auth?: string | null } = {}) {
|
||||
return (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
capture.auth = (init?.headers as Record<string, string>)?.['Authorization'] ?? null;
|
||||
|
||||
return new Response(status === 200 ? '{}' : 'Unauthorized', { status });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const is401 = (err: unknown) =>
|
||||
typeof err === 'object' && err !== null && 'status' in err && err.status === 401;
|
||||
|
||||
describe('api key validation surfaces the splash', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('keyed server, no stored key: throws 401 so the splash shows (onboarding)', async () => {
|
||||
await expect(validateApiKey(fakeFetch(401))).rejects.toSatisfy(is401);
|
||||
});
|
||||
|
||||
it('keyed server, wrong stored key: throws 401 so the splash shows', async () => {
|
||||
settingsStore.updateConfig('apiKey', 'wrong-key');
|
||||
await expect(validateApiKey(fakeFetch(401))).rejects.toSatisfy(is401);
|
||||
});
|
||||
|
||||
it('open server, no stored key: passes silently', async () => {
|
||||
await expect(validateApiKey(fakeFetch(200))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('valid stored key: passes and sends the bearer header', async () => {
|
||||
settingsStore.updateConfig('apiKey', 'sk-good');
|
||||
const capture: { auth?: string | null } = {};
|
||||
|
||||
await expect(validateApiKey(fakeFetch(200, capture))).resolves.toBeUndefined();
|
||||
expect(capture.auth).toBe('Bearer sk-good');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { capImageDataURLSize } from '$lib/utils/cap-img-size';
|
||||
import { getJpegOrientationFromDataURL } from '$lib/utils/jpeg-orientation';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Real 64x32 jpegs generated with Pillow, quality 90. The upright picture is
|
||||
// four solid quadrants: top left red, top right green, bottom left blue,
|
||||
// bottom right yellow. For each exif value the stored pixels are inverse
|
||||
// transposed so a conforming decoder shows the upright picture, exactly like
|
||||
// a rotated smartphone photo.
|
||||
const EXIF1 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
|
||||
const EXIF3 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAMAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD7Nooor/Mo/XQooooA/Cqiiiv+og/moKKKKAPuuiiiv+fw/lcKKKKAPEaKKK+9P+jMKKKKAP/Z`;
|
||||
const EXIF5 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAUAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UzwKiiiv9xj/HQ99ooor/Dk/wBizwKiiiv9xj/HQ+66KKK/5/D+Vz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/9k=`;
|
||||
const EXIF6 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDCooor+Tz+Hz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/Keiiiv7CP72PAqKKK/3GP8AHQ99ooor/Dk/2LPAqKKK/wBxj/HQ/9k=`;
|
||||
const EXIF8 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAgAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD896KKK/1TPhz32iiiv8OT/Ys8Cooor/cY/wAdD32iiiv8OT/Ys/Viiiiv49P4JPhWiiiv6FP8wj7qooor+ej/AE9PhWiiiv6FP8wj/9k=`;
|
||||
const NOEXIF = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
|
||||
const RED: Rgb = [255, 0, 0];
|
||||
const GREEN: Rgb = [0, 200, 0];
|
||||
const BLUE: Rgb = [0, 0, 255];
|
||||
const YELLOW: Rgb = [255, 220, 0];
|
||||
// Wide tolerance per channel, jpeg compression shifts solid colors a bit
|
||||
const COLOR_TOLERANCE = 70;
|
||||
// 0.000512 megapixels is 512 pixels, a quarter of the area of the 2048 pixel fixtures
|
||||
const QUARTER_AREA_MEGAPIXELS = 0.000512;
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Failed to decode image.'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// Decodes a data URL and samples the center of each quadrant of the picture
|
||||
async function quadrantColors(dataUrl: string): Promise<Rgb[]> {
|
||||
const img = await loadImage(dataUrl);
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const points = [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.25],
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75]
|
||||
];
|
||||
|
||||
return points.map(([fx, fy]) => {
|
||||
const d = ctx.getImageData(
|
||||
Math.floor(canvas.width * fx),
|
||||
Math.floor(canvas.height * fy),
|
||||
1,
|
||||
1
|
||||
).data;
|
||||
|
||||
return [d[0], d[1], d[2]];
|
||||
});
|
||||
}
|
||||
|
||||
function expectUpright(colors: Rgb[]) {
|
||||
const targets = [RED, GREEN, BLUE, YELLOW];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
expect(Math.abs(colors[i][c] - targets[i][c])).toBeLessThan(COLOR_TOLERANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('capImageDataURLSize orientation and capping', () => {
|
||||
it('passes upright jpegs through untouched when capping is disabled', async () => {
|
||||
expect(await capImageDataURLSize(EXIF1, 0)).toBe(EXIF1);
|
||||
expect(await capImageDataURLSize(NOEXIF, 0)).toBe(NOEXIF);
|
||||
});
|
||||
|
||||
it('passes upright jpegs through untouched when under the cap threshold', async () => {
|
||||
expect(await capImageDataURLSize(EXIF1, 1)).toBe(EXIF1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['orientation 3', EXIF3],
|
||||
['orientation 5', EXIF5],
|
||||
['orientation 6', EXIF6],
|
||||
['orientation 8', EXIF8]
|
||||
])('bakes %s into upright pixels without capping', async (_label, fixture) => {
|
||||
const result = await capImageDataURLSize(fixture, 0);
|
||||
|
||||
expect(result).not.toBe(fixture);
|
||||
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(64);
|
||||
expect(img.naturalHeight).toBe(32);
|
||||
expectUpright(await quadrantColors(result));
|
||||
|
||||
// The re-encoded jpeg carries no orientation tag anymore
|
||||
expect(getJpegOrientationFromDataURL(result)).toBe(1);
|
||||
});
|
||||
|
||||
it('caps and bakes the orientation in a single output', async () => {
|
||||
const result = await capImageDataURLSize(EXIF6, QUARTER_AREA_MEGAPIXELS);
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(32);
|
||||
expect(img.naturalHeight).toBe(16);
|
||||
expectUpright(await quadrantColors(result));
|
||||
expect(getJpegOrientationFromDataURL(result)).toBe(1);
|
||||
});
|
||||
|
||||
it('caps upright jpegs without disturbing the picture', async () => {
|
||||
const result = await capImageDataURLSize(EXIF1, QUARTER_AREA_MEGAPIXELS);
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(32);
|
||||
expect(img.naturalHeight).toBe(16);
|
||||
expectUpright(await quadrantColors(result));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// Guards the Enter-key contract of the chat form against the
|
||||
// fenced-code-block flow: while the caret sits inside a fenced
|
||||
// block region - closed, or still OPEN while the user is typing
|
||||
// one - plain Enter adds a line instead of submitting the message.
|
||||
// The textarea path is covered here end-to-end (the ChatFormInputRich
|
||||
// consumes the same case locally; see chat-form-input-rich).
|
||||
|
||||
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
function textareaIn(container: HTMLElement): HTMLTextAreaElement {
|
||||
const el = container.querySelector('textarea');
|
||||
|
||||
if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('ChatForm Enter in code blocks', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
|
||||
});
|
||||
|
||||
it('adds a line after a still-open fence instead of submitting', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(textarea.value).toBe('```\n');
|
||||
});
|
||||
|
||||
it('keeps adding lines while the block stays open', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```js');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await userEvent.keyboard('const a = 1;');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(textarea.value).toBe('```js\nconst a = 1;\n');
|
||||
});
|
||||
|
||||
it('submits when the caret is before the opening fence', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
textarea.setSelectionRange(0, 0);
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter outside a code block', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('hello');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Ctrl+Enter even inside a code block', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// Guards the newline contract of the ChatFormInputRich: browsers
|
||||
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
|
||||
// serialization must fold those back into `\n` so the emitted value never
|
||||
// diverges from what is on screen.
|
||||
|
||||
import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b) here';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function fireInput(root: HTMLElement) {
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function setCaret(node: Node, offset: number) {
|
||||
const range = document.createRange();
|
||||
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) throw new Error('no selection');
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
describe('ChatFormInputRich browser newline shapes', () => {
|
||||
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.textContent = 'second line';
|
||||
root.appendChild(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const first = document.createElement('div');
|
||||
|
||||
while (root.firstChild) first.appendChild(root.firstChild);
|
||||
const second = document.createElement('div');
|
||||
|
||||
second.textContent = 'second line';
|
||||
root.appendChild(first);
|
||||
root.appendChild(second);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a <br> as a newline', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'here' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
root.appendChild(document.createElement('br'));
|
||||
root.appendChild(document.createTextNode('second line'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('here\nsecond line');
|
||||
});
|
||||
|
||||
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
root.appendChild(document.createElement('br'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('serializes one newline per empty-line <div><br></div>', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.appendChild(div);
|
||||
}
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc\n\n');
|
||||
});
|
||||
|
||||
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.replaceChildren(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(root.dataset.empty).toBe('true');
|
||||
});
|
||||
|
||||
it('maps the caret across block boundaries in both directions', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc\ndef' });
|
||||
|
||||
await tick();
|
||||
|
||||
// Rebuild into the Chromium block shape; the source is unchanged,
|
||||
// so no re-render fires.
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.textContent = 'def';
|
||||
root.replaceChildren(document.createTextNode('abc'), div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc\ndef');
|
||||
|
||||
const divText = div.firstChild;
|
||||
|
||||
if (!divText) throw new Error('div text missing');
|
||||
|
||||
setCaret(divText, 2);
|
||||
expect(screen.component.getCaretOffset()).toBe(6);
|
||||
|
||||
screen.component.setCaretOffset(6);
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(selection?.anchorNode).toBe(divText);
|
||||
expect(selection?.anchorOffset).toBe(2);
|
||||
|
||||
// The boundary newline itself: offset 3 is the end of "abc", offset
|
||||
// 4 the start of the "def" line.
|
||||
screen.component.setCaretOffset(4);
|
||||
expect(window.getSelection()?.anchorNode).toBe(divText);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(0);
|
||||
|
||||
screen.component.setCaretOffset(3);
|
||||
expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// Guards the editing-key contract of the ChatFormInputRich:
|
||||
// undo/redo is replayed from source snapshots (the token rebuilds destroy
|
||||
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
||||
// keyboard trap), matching the plain textarea.
|
||||
|
||||
import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b)';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function type(root: HTMLElement, text: string, inputType = 'insertText') {
|
||||
root.appendChild(document.createTextNode(text));
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
|
||||
}
|
||||
|
||||
function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('ChatFormInputRich undo/redo', () => {
|
||||
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
|
||||
const undoEvent = keydown(root, { ctrlKey: true, key: 'z' });
|
||||
|
||||
await tick();
|
||||
expect(undoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
|
||||
|
||||
await tick();
|
||||
expect(redoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('redoes with Ctrl+Y as well', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', metaKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'y' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('coalesces a typing burst into one undo step', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, 'd');
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcde');
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('keeps a newline as its own undo step', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, 'd');
|
||||
type(root, '\n', 'insertLineBreak');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd\n');
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd');
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('is a no-op when there is nothing to undo', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { ctrlKey: true, key: 'z' });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('abandons the redo branch after a fresh edit', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, 'd');
|
||||
await tick();
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abce');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich Tab key', () => {
|
||||
it('does not trap Tab (focus can leave the editable)', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { key: 'Tab' });
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,804 @@
|
||||
// Guards the clipboard contract of the ChatFormInputRich:
|
||||
// copy/cut expose the markdown SOURCE of the selection (each badge
|
||||
// contributes its full `[name](file://...)` link) and pasting such
|
||||
// markdown re-renders the badges.
|
||||
|
||||
import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
|
||||
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const SOURCE = 'hello [docs](file:///a/b) world';
|
||||
const BADGE_SELECTOR = '[data-mention-badge="true"]';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
|
||||
const range = document.createRange();
|
||||
|
||||
place(range, root);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) throw new Error('no selection');
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
|
||||
const data = new DataTransfer();
|
||||
|
||||
if (text) data.setData('text/plain', text);
|
||||
|
||||
const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
|
||||
|
||||
return { data, event };
|
||||
}
|
||||
|
||||
describe('ChatFormInputRich clipboard', () => {
|
||||
it('copy exposes the markdown source of the selection', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe(SOURCE);
|
||||
});
|
||||
|
||||
it('cut exposes the markdown source and removes the slice', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => {
|
||||
const badge = root.querySelector(BADGE_SELECTOR);
|
||||
|
||||
if (!badge) throw new Error('badge not rendered');
|
||||
|
||||
range.setStartBefore(badge);
|
||||
range.setEndAfter(badge);
|
||||
});
|
||||
|
||||
const { data, event } = clipboardEvent('cut');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
|
||||
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||
expect(root.textContent).toBe('hello world');
|
||||
});
|
||||
|
||||
it('paste of markdown mention links re-renders badges', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'hello ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
const badge = root.querySelector(BADGE_SELECTOR);
|
||||
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge!.getAttribute('data-mention-name')).toBe('docs');
|
||||
expect(root.textContent).toContain('world');
|
||||
});
|
||||
|
||||
it('paste without mention links keeps the DOM untouched', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'hello ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
const firstChild = root.firstChild;
|
||||
const { event } = clipboardEvent('paste', 'plain text');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||
// no rebuild: the live text node is the same instance
|
||||
expect(root.firstChild).toBe(firstChild);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich code spans', () => {
|
||||
it('renders inline code from the initial value', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'run `npm test` now' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="code_inline"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
});
|
||||
|
||||
it('renders a fenced code block with a language', async () => {
|
||||
const source = 'before\n```js\nconst a = 1;\n```\nafter';
|
||||
const { container } = render(ChatFormInputRich, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
|
||||
});
|
||||
|
||||
it('copy exposes the markdown source of a selection spanning code', async () => {
|
||||
const source = 'run `npm test` now';
|
||||
const { container } = render(ChatFormInputRich, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe(source);
|
||||
});
|
||||
|
||||
it('paste of a code span renders the styled element', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'run ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
const { event } = clipboardEvent('paste', '`npm test` now');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
const code = root.querySelector('code[data-code-token="code_inline"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
expect(root.textContent).toContain('now');
|
||||
});
|
||||
|
||||
it('highlights a fenced block content and stays byte-exact', async () => {
|
||||
const source = '```js\nconst a = 1;\n```';
|
||||
const { container } = render(ChatFormInputRich, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
|
||||
expect(code!.textContent).toBe(source);
|
||||
});
|
||||
|
||||
it('does not highlight inline code', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'run `const` now' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich code block escape hatches', () => {
|
||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||
const BLOCK_SELECTOR = 'code[data-code-token="code_block"]';
|
||||
|
||||
function blockIn(root: HTMLElement): HTMLElement {
|
||||
const el = root.querySelector(BLOCK_SELECTOR);
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// Caret at the very start/end of the block's text (across highlight spans)
|
||||
function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
|
||||
const code = blockIn(root);
|
||||
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
|
||||
|
||||
let target: Node | null = null;
|
||||
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
target = where === 'start' ? (target ?? n) : n;
|
||||
}
|
||||
|
||||
if (!target) throw new Error('no text inside code block');
|
||||
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
|
||||
range.collapse(true);
|
||||
});
|
||||
}
|
||||
|
||||
function caretContainer(): Node {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) throw new Error('no selection');
|
||||
|
||||
return selection.getRangeAt(0).startContainer;
|
||||
}
|
||||
|
||||
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
// no permanent empty line above a leading block
|
||||
expect(root.firstChild).toBe(blockIn(root));
|
||||
expect(root.lastChild?.nodeName).toBe('BR');
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
|
||||
});
|
||||
|
||||
it('escapes a trailing code block with ArrowDown and types after it', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||
// the DOM holds no separator newline (it would render as a
|
||||
// phantom empty line); serialization synthesizes it so the
|
||||
// markdown source keeps the text below the block
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
// the stale trailing hatch is removed once real text follows the block
|
||||
expect(root.lastChild?.nodeName).not.toBe('BR');
|
||||
});
|
||||
|
||||
it('escapes a leading code block with ArrowUp and types before it', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
// the transient hatch line exists while the caret sits on it
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
|
||||
await userEvent.keyboard('y');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||
expect(root.textContent).toBe('y' + BLOCK_SOURCE);
|
||||
expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
|
||||
// the typed text consumed the hatch
|
||||
expect(root.firstChild?.nodeName).not.toBe('BR');
|
||||
});
|
||||
|
||||
it('escapes a leading code block with ArrowLeft from its first character', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
});
|
||||
|
||||
it('removes the transient leading hatch when the caret moves back into the block', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(true);
|
||||
expect(root.firstChild).toBe(blockIn(root));
|
||||
});
|
||||
|
||||
it('extends the selection out of the block with Shift+ArrowDown', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(selection).not.toBeNull();
|
||||
expect(selection!.isCollapsed).toBe(false);
|
||||
expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
|
||||
});
|
||||
|
||||
it('line-separates text typed right after the closing fence', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
// no arrow keys: the caret sits at the block's end edge, where the
|
||||
// post-rebuild restore lands it, and the typed text renders on the
|
||||
// line below the block
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
// the text stays on the caret's line in the DOM (no phantom empty
|
||||
// line); the source gets the separator newline
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
});
|
||||
|
||||
it('does not double the newline when Shift+Enter already added one', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
});
|
||||
|
||||
it('moves a caret stuck before the inserted newline onto the new line', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// post-break DOM some browsers produce: the inserted newline plus
|
||||
// the artificial trailing one, with the caret stuck BEFORE the
|
||||
// inserted one (visually at the end of the old line)
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root.childNodes[2], 0);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
});
|
||||
|
||||
it('appends the artificial trailing newline when the browser did not add one', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// post-break DOM some browsers produce: a lone trailing \n (or a
|
||||
// <br> the hatch sync strips). Collapsed by the renderer, so the
|
||||
// caret looks stuck on the old line and the next typed character
|
||||
// would consume the newline.
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root.childNodes[2], 1);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
});
|
||||
|
||||
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
const text = root.childNodes[1];
|
||||
|
||||
range.setStart(text, (text.textContent ?? '').length);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
|
||||
// the next typed character lands on the new line
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
|
||||
});
|
||||
|
||||
it('lets Backspace at the text start move into the block without a source fight', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await userEvent.keyboard('create');
|
||||
await tick();
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||
|
||||
// Backspace at the start of the text line: the separator newline
|
||||
// is structural (synthesized while text follows the block), so
|
||||
// the caret just moves to the block's edge - nothing is re-added
|
||||
await userEvent.keyboard('{Home}');
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await tick();
|
||||
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||
expect(caretContainer()).toBe(root);
|
||||
});
|
||||
|
||||
it('lets forward Delete eat the text after a block normally', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await userEvent.keyboard('create');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Home}');
|
||||
await userEvent.keyboard('{Delete}');
|
||||
await tick();
|
||||
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
|
||||
});
|
||||
|
||||
it('renders text after a block without a phantom empty line', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\nhello'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
|
||||
});
|
||||
|
||||
it('keeps an intentional blank line after a block out of the separator', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\n\nhello'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
|
||||
});
|
||||
|
||||
it('re-highlights while typing inside a block and keeps the caret', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
const code = blockIn(root);
|
||||
|
||||
expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
|
||||
expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
|
||||
expect(code.querySelector('.hljs-number')).not.toBeNull();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich Enter in code blocks', () => {
|
||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||
|
||||
it('adds a line instead of submitting on plain Enter inside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
// consumed locally: the parent's submit handler never sees it
|
||||
expect(onKeydown).not.toHaveBeenCalled();
|
||||
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
|
||||
|
||||
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||
});
|
||||
|
||||
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: '```js\nconst a = 1;'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onKeydown).not.toHaveBeenCalled();
|
||||
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
|
||||
});
|
||||
|
||||
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE + '\nafter'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards plain Enter on the trailing hatch line after a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
// root-level caret between the block and its trailing br hatch
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root, 1);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ctrlKey: true, key: 'Enter' })
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
|
||||
});
|
||||
|
||||
it('forwards Enter inside an inline code span', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: 'run `npm test` now'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
const code = root.querySelector('code[data-code-token="code_inline"]')!;
|
||||
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(code.firstChild!, 3);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Guards the @-mention picker's file_glob_search gate: when the server
|
||||
// does not expose the tool (started without --tools) or the user disabled
|
||||
// it, the picker still opens but explains why instead of firing searches
|
||||
// that would only fail with "Search failed".
|
||||
|
||||
import ChatFormPickerMention from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
|
||||
import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const FILE_SEARCH_DEF: OpenAIToolDefinition = {
|
||||
function: { description: '', name: BuiltInTool.SERVER_FILE_GLOB_SEARCH, parameters: {} },
|
||||
type: 'function'
|
||||
};
|
||||
const FILE_SEARCH_KEY = `server:${BuiltInTool.SERVER_FILE_GLOB_SEARCH}`;
|
||||
|
||||
// The store keeps its server tool list private; tests inject it through
|
||||
// the reactive field so the derived gates recompute.
|
||||
function setServerTools(defs: OpenAIToolDefinition[]) {
|
||||
(toolsStore as unknown as { _serverTools: OpenAIToolDefinition[] })._serverTools = defs;
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
return render(ChatFormPickerMention, {
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
onSelect: () => {},
|
||||
query: 'main'
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setServerTools([]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
|
||||
localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('ChatFormPickerMention file_glob_search gate', () => {
|
||||
it('explains that file search is unavailable when the server has no tools', async () => {
|
||||
setServerTools([]);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is unavailable on this server (started without --tools)'
|
||||
);
|
||||
});
|
||||
|
||||
it('explains that file search must be enabled when the user disabled it', async () => {
|
||||
setServerTools([FILE_SEARCH_DEF]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// Guards the slash-command dispatch contract: commands dispatch only on
|
||||
// explicit selection (Enter/click in the picker), never mid-typing.
|
||||
// Typing `/model is broken` is prose until the command is picked - the
|
||||
// buffer must survive; only an actual selection consumes the token.
|
||||
|
||||
import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('slash command dispatch', () => {
|
||||
it('does not dispatch or clear the buffer when a space follows the name', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
|
||||
expect(screen.component.getValue()).toBe('/model is broken');
|
||||
expect(screen.component.getCalls()).not.toContain('openModelSelector');
|
||||
expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
|
||||
expect(pickers.isCommandPickerOpen).toBe(true);
|
||||
expect(pickers.commandQuery).toBe('model');
|
||||
});
|
||||
|
||||
it('dispatches /model on explicit selection and consumes the token', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const model = pickers.availableCommands.find((c) => c.name === 'model');
|
||||
|
||||
if (!model) throw new Error('model command missing');
|
||||
|
||||
pickers.handleCommandSelect(model);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(screen.component.getCalls()).toContain('openModelSelector');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the prompt picker search from the token args on selection', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/prompt weather');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
|
||||
expect(pickers.isPromptPickerOpen).toBe(false);
|
||||
|
||||
const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
|
||||
|
||||
if (!prompt) throw new Error('prompt command missing');
|
||||
|
||||
pickers.handleCommandSelect(prompt);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(pickers.isPromptPickerOpen).toBe(true);
|
||||
expect(pickers.promptSearchQuery).toBe('weather');
|
||||
});
|
||||
|
||||
it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cw docs');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs');
|
||||
expect(screen.component.getValue()).toBe('/cwd docs');
|
||||
});
|
||||
|
||||
it('syncs the /cwd token into the picker search while the picker is open', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwd docs/sub');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs/sub');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwdd docs');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { CONVERSATION_TABS_LOCALSTORAGE_KEY, NEW_CHAT_TAB_ID } from '$lib/constants';
|
||||
import { TabsStore } from '$lib/stores/tabs.svelte';
|
||||
|
||||
const gotoMock = vi.mocked(goto);
|
||||
|
||||
function freshStore(persisted?: unknown, validIds: string[] = []) {
|
||||
localStorage.removeItem(CONVERSATION_TABS_LOCALSTORAGE_KEY);
|
||||
|
||||
if (persisted !== undefined) {
|
||||
localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(persisted));
|
||||
}
|
||||
|
||||
const tabs = new TabsStore();
|
||||
|
||||
tabs.init(validIds);
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gotoMock.mockClear();
|
||||
});
|
||||
|
||||
/**
|
||||
* A plain navigation shows the new target in the current tab; only an
|
||||
* explicit new-tab action grows the strip.
|
||||
*/
|
||||
describe('tabsStore route sync', () => {
|
||||
it('creates the first tab, then navigates it in place', () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
tabs.syncWithRoute('b');
|
||||
tabs.syncWithRoute('c');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('activates an open tab instead of replacing the current one', () => {
|
||||
const tabs = freshStore(['a', 'b'], ['a', 'b']);
|
||||
|
||||
tabs.syncWithRoute('b');
|
||||
tabs.syncWithRoute('a');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('turns the new-chat tab into the conversation it creates', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.openInNewTab(NEW_CHAT_TAB_ID, { activate: true });
|
||||
tabs.syncWithRoute(NEW_CHAT_TAB_ID);
|
||||
tabs.syncWithRoute('b');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('waits for init before applying the route', () => {
|
||||
localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(['a', 'b']));
|
||||
|
||||
const tabs = new TabsStore();
|
||||
|
||||
tabs.syncWithRoute('b');
|
||||
tabs.init(['a', 'b']);
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabsStore explicit tabs', () => {
|
||||
it('opens new tabs at the end, in the background, without duplicates', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.openInNewTab('b');
|
||||
await tabs.openInNewTab('c');
|
||||
await tabs.openInNewTab('b');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b', 'c']);
|
||||
expect(gotoMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the active tab to its left neighbour', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.openInNewTab('b');
|
||||
tabs.syncWithRoute('b');
|
||||
await tabs.close('b', true);
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a']);
|
||||
expect(gotoMock).toHaveBeenLastCalledWith('#/chat/a');
|
||||
});
|
||||
|
||||
it('falls back to the new-chat screen when the last tab closes', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.close('a', true);
|
||||
|
||||
expect(gotoMock).toHaveBeenLastCalledWith('#/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabsStore persistence', () => {
|
||||
it('prunes deleted conversations and keeps the new-chat tab', () => {
|
||||
const tabs = freshStore(['a', NEW_CHAT_TAB_ID, 'gone'], ['a']);
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', NEW_CHAT_TAB_ID]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// Guards the lazy-body behaviour of the shared collapsible wrappers.
|
||||
//
|
||||
// bits-ui's Collapsible.Content renders its children unconditionally and only
|
||||
// sets `hidden`, so before this was gated a collapsed tool result kept its whole
|
||||
// body in the DOM and re-rendered it on every streamed token (measured at
|
||||
// ~41ms/section/token for a 200KB result). These tests pin the fix: closed means
|
||||
// not rendered, and opening still mounts the body.
|
||||
|
||||
import CollapsibleLazyBodyHarness from './components/CollapsibleLazyBodyHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const MARKER = 'collapsible-body-marker';
|
||||
|
||||
describe('collapsible wrappers render their body lazily', () => {
|
||||
for (const variant of ['content', 'terminal'] as const) {
|
||||
it(`${variant}: body is absent while closed and present once open`, async () => {
|
||||
const screen = render(CollapsibleLazyBodyHarness, { open: false, variant });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(MARKER);
|
||||
|
||||
await screen.rerender({ open: true, variant });
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(MARKER);
|
||||
|
||||
// And it unmounts again on close, so a collapsed block stops costing
|
||||
// anything during streaming.
|
||||
await screen.rerender({ open: false, variant });
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(MARKER);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { perfState } from './agentic-perf-state.svelte';
|
||||
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
{#if perfState.message}
|
||||
<ChatMessageAgenticContent
|
||||
isLastAssistantMessage
|
||||
isStreaming={perfState.isStreaming}
|
||||
message={perfState.message}
|
||||
toolMessages={perfState.toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let { value: initial = '' }: Props = $props();
|
||||
|
||||
let value = $state(untrack(() => initial));
|
||||
let inputRef: ChatFormInputRich | undefined = $state(undefined);
|
||||
|
||||
export function getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCaretOffset() {
|
||||
return inputRef?.getCaretOffset();
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
inputRef?.setCaretOffset(offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormInputRich bind:this={inputRef} bind:value />
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
useChatFormPickers,
|
||||
type UseChatFormPickersReturn
|
||||
} from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
|
||||
let value = $state('');
|
||||
let caretOffset = $state(0);
|
||||
const calls: string[] = [];
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
focusInput: () => {},
|
||||
getCaretOffset: () => caretOffset,
|
||||
getCwd: () => null,
|
||||
getPickersRef: () => undefined,
|
||||
getServerHome: () => null,
|
||||
getShowModelSelector: () => true,
|
||||
getValue: () => value,
|
||||
hasCwdTools: () => true,
|
||||
hasPrompts: () => true,
|
||||
openModelSelector: () => {
|
||||
calls.push('openModelSelector');
|
||||
},
|
||||
setCaretOffset: (o) => {
|
||||
caretOffset = o;
|
||||
},
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
calls.push(`setValue:${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate the user typing: update the buffer and run the input flow.
|
||||
export function type(text: string) {
|
||||
value = text;
|
||||
caretOffset = text.length;
|
||||
pickers.handleInput();
|
||||
}
|
||||
|
||||
export function getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCalls() {
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function getPickers(): UseChatFormPickersReturn {
|
||||
return pickers;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
let { onSubmit }: { onSubmit?: () => void } = $props();
|
||||
|
||||
let value = $state('');
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<ChatForm bind:value {onSubmit} />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
// Mounts the real ChatMessages list against the real conversations store, so
|
||||
// the harness exercises `displayMessages` (which rebuilds every message's
|
||||
// toolMessages array) rather than a single message subtree.
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<ChatMessages messages={conversationsStore.activeMessages} />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleContentBlock from '$lib/components/app/content/CollapsibleContentBlock.svelte';
|
||||
import CollapsibleTerminalBlock from '$lib/components/app/content/CollapsibleTerminalBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
variant: 'content' | 'terminal';
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open, variant }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === 'content'}
|
||||
<CollapsibleContentBlock {open} title="Block">
|
||||
<span>collapsible-body-marker</span>
|
||||
</CollapsibleContentBlock>
|
||||
{:else}
|
||||
<CollapsibleTerminalBlock {open} title="Block">
|
||||
<span>collapsible-body-marker</span>
|
||||
</CollapsibleTerminalBlock>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DialogMcpServers from '$lib/components/app/dialogs/DialogMcpServers.svelte';
|
||||
import DialogSettingsChat from '$lib/components/app/dialogs/DialogSettingsChat.svelte';
|
||||
|
||||
let { panel }: { panel: 'mcp' | 'settings' } = $props();
|
||||
</script>
|
||||
|
||||
{#if panel === 'settings'}
|
||||
<DialogSettingsChat open />
|
||||
{:else}
|
||||
<DialogMcpServers open />
|
||||
{/if}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
headers?: string;
|
||||
}
|
||||
|
||||
let { headers = '' }: Props = $props();
|
||||
|
||||
let headersState = $state(untrack(() => headers));
|
||||
let lastCapturedHeaders = $state(untrack(() => headers));
|
||||
|
||||
$effect(() => {
|
||||
if (headers !== lastCapturedHeaders) {
|
||||
headersState = headers;
|
||||
lastCapturedHeaders = headers;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Drives McpServerForm with a controlled `headers` string and exposes the
|
||||
latest captured value through `data-captured-headers` so the client test
|
||||
can read it back without a custom binding API.
|
||||
-->
|
||||
<McpServerForm
|
||||
headers={headersState}
|
||||
id="mcp-server-form-test"
|
||||
onHeadersChange={(value) => {
|
||||
headersState = value;
|
||||
}}
|
||||
onUrlChange={() => {}}
|
||||
url="https://example.test/mcp"
|
||||
/>
|
||||
|
||||
<div data-captured-headers={headersState} data-testid="captured-headers" hidden></div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerModelRow from '$lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
option: ModelOption;
|
||||
selected?: boolean;
|
||||
onSelect: (option: ModelOption) => void;
|
||||
}
|
||||
|
||||
let { onSelect, option, selected = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- The row actions mount a tooltip, which needs the provider +layout.svelte supplies. -->
|
||||
<Tooltip.Provider>
|
||||
<ModelsManagerModelRow
|
||||
isFavorite={() => false}
|
||||
onDelete={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
{selected}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
</script>
|
||||
|
||||
<!-- The rows mount tooltips, which need the provider +layout.svelte supplies. -->
|
||||
<Tooltip.Provider>
|
||||
<ModelsManager />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
|
||||
interface Props {
|
||||
currentModel?: () => string | null;
|
||||
}
|
||||
|
||||
let { currentModel = () => null }: Props = $props();
|
||||
|
||||
// the caller seeds modelsStore.models before mounting, so the hook's own
|
||||
// fetch() short-circuits on an already populated list; the hook keeps the getter
|
||||
// and calls it later, so reading the prop here is intended
|
||||
// svelte-ignore state_referenced_locally
|
||||
const selector = useModelsSelector({ currentModel, onOpenChange: () => {} });
|
||||
|
||||
/** Every model id the selector lists, in the order the sections render them. */
|
||||
export function listedIds(): string[] {
|
||||
return [
|
||||
...selector.favoriteItems.map((item) => item.option.id),
|
||||
...selector.loadedItems.map((item) => item.option.id),
|
||||
...selector.groupedFilteredOptions.available.flatMap((group) =>
|
||||
group.items.map((item) => item.option.id)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
export function shownModelId(): string | undefined {
|
||||
return selector.getDisplayOption()?.id;
|
||||
}
|
||||
|
||||
export function isEmpty(): boolean {
|
||||
return selector.isEmpty;
|
||||
}
|
||||
</script>
|
||||
|
||||
<span data-testid="selector-hook"></span>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const items: Item[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: String(i),
|
||||
label: `item ${i}`
|
||||
}));
|
||||
|
||||
let open = $state(false);
|
||||
let scrollTrigger = $state(0);
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
export function openPicker() {
|
||||
open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="height: 5000px;">conversation</div>
|
||||
|
||||
{#if open}
|
||||
<div data-testid="picker-host">
|
||||
<ChatFormPickerList
|
||||
isLoading={false}
|
||||
itemKey={(it) => it.id}
|
||||
{items}
|
||||
{scrollTrigger}
|
||||
searchQuery=""
|
||||
{selectedIndex}
|
||||
showSearchInput={false}
|
||||
>
|
||||
{#snippet item(it, index, isSelected)}
|
||||
<ChatFormPickerListItem dataIndex={index} {isSelected} onclick={() => {}}>
|
||||
{it.label}
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Page from '../../../src/routes/(chat)/+page.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Test wrapper that provides necessary context providers for component testing.
|
||||
This mirrors the providers from +layout.svelte.
|
||||
-->
|
||||
<Tooltip.Provider>
|
||||
<Page />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,17 @@
|
||||
// Shared reactive fixture state for the Tier 1 perf harness.
|
||||
//
|
||||
// The wrapper component reads this module directly rather than taking props,
|
||||
// so the driver can mutate it without depending on how the test renderer
|
||||
// forwards props.
|
||||
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
export const perfState = $state<{
|
||||
message: DatabaseMessage | null;
|
||||
toolMessages: DatabaseMessage[];
|
||||
isStreaming: boolean;
|
||||
}>({
|
||||
isStreaming: true,
|
||||
message: null,
|
||||
toolMessages: []
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// Guards the phone shape of the context panel: a phone shows it in a drawer, while a
|
||||
// wider screen keeps the card anchored above the dial.
|
||||
|
||||
import ContextGaugePopup from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte';
|
||||
import { gaugePopup } from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/gauge-popup.svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
|
||||
describe('context panel on a phone', () => {
|
||||
beforeEach(async () => {
|
||||
gaugePopup.open = false;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
gaugePopup.open = false;
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('shows the panel in a drawer', async () => {
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
|
||||
const screen = render(ContextGaugePopup);
|
||||
|
||||
gaugePopup.open = true;
|
||||
|
||||
await expect.element(screen.getByRole('heading', { name: 'Context' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the anchored card on a wider screen', async () => {
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
|
||||
const screen = render(ContextGaugePopup);
|
||||
|
||||
gaugePopup.open = true;
|
||||
|
||||
await expect.element(screen.getByRole('status')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import type { ExportedConversation } from '$lib/types/database';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSession(id: string): ExportedConversation {
|
||||
return {
|
||||
conv: { currNode: `${id}-msg`, id, lastModified: 0, name: `Chat ${id}` },
|
||||
messages: [
|
||||
{
|
||||
children: [],
|
||||
content: `hello from ${id}`,
|
||||
convId: id,
|
||||
id: `${id}-msg`,
|
||||
parent: null,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
}
|
||||
]
|
||||
} as unknown as ExportedConversation;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
|
||||
await DatabaseService.bulkDeleteConversations(conversations.map((conv) => conv.id));
|
||||
});
|
||||
|
||||
/**
|
||||
* An import leaves a conversation already in the database untouched, so the
|
||||
* caller needs to know what was written to report it instead of echoing the
|
||||
* selection back at the user.
|
||||
*/
|
||||
describe('DatabaseService.importConversations', () => {
|
||||
it('reports the conversations it wrote', async () => {
|
||||
const { imported, skipped } = await DatabaseService.importConversations([
|
||||
makeSession('a'),
|
||||
makeSession('b')
|
||||
]);
|
||||
|
||||
expect(imported.map((conv) => conv.id)).toEqual(['a', 'b']);
|
||||
expect(skipped).toEqual([]);
|
||||
expect(await DatabaseService.getConversationMessages('a')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports an existing conversation as skipped and leaves it untouched', async () => {
|
||||
await DatabaseService.importConversations([makeSession('a')]);
|
||||
await DatabaseService.updateConversation('a', { name: 'Renamed locally' });
|
||||
|
||||
const { imported, skipped } = await DatabaseService.importConversations([makeSession('a')]);
|
||||
|
||||
expect(imported).toEqual([]);
|
||||
expect(skipped.map((conv) => conv.id)).toEqual(['a']);
|
||||
expect((await DatabaseService.getConversation('a'))?.name).toBe('Renamed locally');
|
||||
});
|
||||
|
||||
it('imports the new conversations of a partially known selection', async () => {
|
||||
await DatabaseService.importConversations([makeSession('a')]);
|
||||
|
||||
const { imported, skipped } = await DatabaseService.importConversations([
|
||||
makeSession('a'),
|
||||
makeSession('b')
|
||||
]);
|
||||
|
||||
expect(imported.map((conv) => conv.id)).toEqual(['b']);
|
||||
expect(skipped.map((conv) => conv.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Guards that opening a panel on a phone leaves its corner close alone: the tap that
|
||||
// opened the panel is already inside it, so a focus ring on that close reads as a stray
|
||||
// highlight.
|
||||
|
||||
import DialogPanelWrapper from './components/DialogPanelWrapper.svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
|
||||
describe.each(['settings', 'mcp'] as const)('%s panel on a phone', (panel) => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('leaves the corner close unfocused', async () => {
|
||||
const screen = render(DialogPanelWrapper, { panel });
|
||||
|
||||
await expect.element(screen.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// the panel focuses its first button on open unless it is told not to
|
||||
await expect.poll(() => document.activeElement?.tagName).not.toBe('BUTTON');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// Guards the exec_shell_command title while the call is still being
|
||||
// generated: the command must stream into the terminal title from the
|
||||
// first character (args arrive as partial JSON), with a blinking cursor
|
||||
// while the args are incomplete, and the conversation cwd standing in
|
||||
// for the x-tool-cwd value that only arrives with the result message.
|
||||
|
||||
import ChatMessageToolCallBlockExecShellCommand from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte';
|
||||
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { AgenticSectionType, BuiltInTool, MessageRole, MessageType } from '$lib/enums';
|
||||
import { conversationsStore, settingsStore } from '$lib/stores';
|
||||
import type { AgenticSection, DatabaseMessage } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const CONV_ID = 'conv-1';
|
||||
const TOOL_CALL_ID = 'call-1';
|
||||
const TOOL_NAME = BuiltInTool.SERVER_EXEC_SHELL_COMMAND;
|
||||
const CWD = '/home/user/project';
|
||||
|
||||
function messageWithArgs(args: string): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: CONV_ID,
|
||||
id: 'assistant-1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
|
||||
]),
|
||||
type: MessageType.TEXT
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultMessage(content: string, toolCwd?: string): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content,
|
||||
convId: CONV_ID,
|
||||
id: 'tool-1',
|
||||
parent: null,
|
||||
role: MessageRole.TOOL,
|
||||
timestamp: 0,
|
||||
toolCallId: TOOL_CALL_ID,
|
||||
toolCalls: '',
|
||||
toolCwd,
|
||||
type: MessageType.TEXT
|
||||
};
|
||||
}
|
||||
|
||||
function triggerText(container: Element): string {
|
||||
return container.querySelector('[data-slot="collapsible-trigger"]')?.textContent ?? '';
|
||||
}
|
||||
|
||||
function hasCursor(container: Element): boolean {
|
||||
return container.querySelector('.exec-cursor') !== null;
|
||||
}
|
||||
|
||||
describe('exec_shell_command streaming title', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateMultipleConfig({
|
||||
[SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: false,
|
||||
[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: true
|
||||
});
|
||||
conversationsStore.activeConversation = {
|
||||
currNode: null,
|
||||
cwd: CWD,
|
||||
id: CONV_ID,
|
||||
lastModified: 0,
|
||||
name: 'test'
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
conversationsStore.activeConversation = null;
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
|
||||
it('streams the partial command into the title with a cursor', async () => {
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git sta'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const title = triggerText(container);
|
||||
|
||||
expect(title).toContain('git sta');
|
||||
expect(title).toContain('$');
|
||||
expect(title).toContain(CWD);
|
||||
expect(hasCursor(container)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the cursor once the args JSON is complete', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git sta'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(hasCursor(screen.container)).toBe(true);
|
||||
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: []
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain('git status');
|
||||
expect(hasCursor(screen.container)).toBe(false);
|
||||
});
|
||||
|
||||
it('switches the prompt from the conversation cwd to the persisted one', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain(CWD);
|
||||
|
||||
// Result message lands: its x-tool-cwd takes over the prompt.
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: [toolResultMessage('', '/other/dir')]
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain('/other/dir');
|
||||
expect(triggerText(screen.container)).not.toContain(CWD);
|
||||
});
|
||||
|
||||
it('shows the executing label only after the command finishes streaming', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git sta'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// While the args stream, the title cursor alone signals activity.
|
||||
expect(screen.container.textContent).not.toContain('Executing command...');
|
||||
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: []
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(screen.container.textContent).toContain('Executing command...');
|
||||
});
|
||||
|
||||
it('shows Executing command... while executing before the first output chunk', async () => {
|
||||
// The result message is created empty right before execution, so
|
||||
// the block must not go blank while the process produces nothing.
|
||||
const section: AgenticSection = {
|
||||
content: '',
|
||||
toolArgs: '{"command":"sleep 1"}',
|
||||
toolCallId: TOOL_CALL_ID,
|
||||
toolName: TOOL_NAME,
|
||||
toolResult: '',
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
};
|
||||
const { container } = render(ChatMessageToolCallBlockExecShellCommand, {
|
||||
isExecuting: true,
|
||||
isStreaming: true,
|
||||
open: true,
|
||||
section
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(container.textContent).toContain('Executing command...');
|
||||
});
|
||||
|
||||
it('updates the title when the store mutates toolCalls in place, as the stream does', async () => {
|
||||
// The live flow never swaps the message object: onToolCallsStreaming
|
||||
// writes growing toolCalls strings into the same store row per delta.
|
||||
conversationsStore.activeMessages = [messageWithArgs('')];
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: conversationsStore.activeMessages[0]!,
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const idx = conversationsStore.findMessageIndex('assistant-1');
|
||||
|
||||
for (const args of ['{"command', '{"command":"git', '{"command":"git sta']) {
|
||||
conversationsStore.updateMessageAtIndex(idx, {
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
|
||||
])
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain(args.slice('{"command":"'.length));
|
||||
expect(hasCursor(screen.container)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// Adversarial checks for MarkdownContent's allowHtml mode: the discover README
|
||||
// renders HuggingFace model cards, which are third-party content, so every
|
||||
// payload below must come out neutralized - both right after sanitize and
|
||||
// after an innerHTML re-parse, which is where mutation XSS would surface.
|
||||
import MarkdownContent from '$lib/components/app/content/MarkdownContent/MarkdownContent.svelte';
|
||||
import { SAFE_HTML_CONFIG } from '$lib/components/app/content/MarkdownContent/safe-html-config';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { mount, unmount } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const HANDLER_ATTR = /\son[a-z]+\s*=/i;
|
||||
const JS_URL = /javascript\s*:/i;
|
||||
|
||||
/** Payload execution canary: set by any payload that runs. */
|
||||
function xssFired(): boolean {
|
||||
return (window as { __mdXss?: number }).__mdXss !== undefined;
|
||||
}
|
||||
|
||||
function roundTrip(html: string): string {
|
||||
const el = document.createElement('div');
|
||||
|
||||
el.innerHTML = html;
|
||||
document.body.appendChild(el);
|
||||
const again = el.innerHTML;
|
||||
|
||||
el.remove();
|
||||
|
||||
return again;
|
||||
}
|
||||
|
||||
const PAYLOADS: Record<string, string> = {
|
||||
'details ontoggle': '<details open ontoggle="window.__mdXss=1">x</details>',
|
||||
'dom clobbering': '<p id="content" name="location"><input name="domain"></p>',
|
||||
'form and input': '<form><input autofocus onfocus="window.__mdXss=1"></form>',
|
||||
'iframe javascript src': '<iframe src="javascript:alert(1)"></iframe>',
|
||||
'iframe srcdoc': '<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
|
||||
'img onerror': '<img src=x onerror="window.__mdXss=1">',
|
||||
'link case and entity href': '<a href="JaVaScRiPt:alert(1)">x</a>',
|
||||
'link data:text/html href':
|
||||
'<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>',
|
||||
'link javascript href': '<a href="javascript:alert(1)">x</a>',
|
||||
'link vbscript href': '<a href="vbscript:msgbox(1)">x</a>',
|
||||
'math href': '<math href="javascript:alert(1)"></math>',
|
||||
'mathml annotation-xml integration point':
|
||||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
|
||||
'mathml mtext style mXSS':
|
||||
'<math><mtext><table><mglyph><style><img src=x onerror="window.__mdXss=1"></style></mglyph></table></mtext></math>',
|
||||
noscript: '<noscript><p title="</noscript><img src=x onerror="window.__mdXss=1>">',
|
||||
script: '<script>window.__mdXss=1</script>',
|
||||
'srcset javascript': '<img srcset="javascript:alert(1) 1x, x 2x" src=x>',
|
||||
'style tag': '<style>@import url(javascript:alert(1));</style>',
|
||||
'svg foreignObject smuggling':
|
||||
'<svg><foreignObject><img src=x onerror="window.__mdXss=1"></foreignObject></svg>',
|
||||
'svg set-attribute animation': '<svg><set attributeName="onmouseover" to="alert(1)"/></svg>',
|
||||
'unclosed tag': '<img src="x" onerror="window.__mdXss=1"',
|
||||
'uppercase tags': '<IMG SRC=x ONERROR="window.__mdXss=1">',
|
||||
'video source onerror': '<video><source onerror="window.__mdXss=1" src=x></video>',
|
||||
'xlink href': '<math><mtext xlink:href="javascript:alert(1)"></mtext></math>'
|
||||
};
|
||||
|
||||
describe('SAFE_HTML_CONFIG adversarial battery', () => {
|
||||
it('neutralizes every payload after sanitize and after innerHTML re-parse', () => {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [name, payload] of Object.entries(PAYLOADS)) {
|
||||
const clean = DOMPurify.sanitize(payload, SAFE_HTML_CONFIG) as string;
|
||||
const again = roundTrip(clean);
|
||||
|
||||
if (HANDLER_ATTR.test(clean) || HANDLER_ATTR.test(again)) {
|
||||
failures.push(`${name}: handler attribute survived: ${again}`);
|
||||
}
|
||||
|
||||
if (JS_URL.test(clean) || JS_URL.test(again)) {
|
||||
failures.push(`${name}: javascript: URL survived: ${again}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('scrubs positioned styles that can draw an overlay, keeps KaTeX styles', () => {
|
||||
const clean = DOMPurify.sanitize(
|
||||
'<div style="position:fixed;inset:0;z-index:9999;background:red">x</div><span style="height:0.83em;vertical-align:-0.2em">y</span>',
|
||||
SAFE_HTML_CONFIG
|
||||
) as string;
|
||||
|
||||
expect(clean).not.toMatch(/position\s*:\s*fixed/i);
|
||||
expect(clean).toContain('height:0.83em');
|
||||
expect(clean).toContain('background:red');
|
||||
});
|
||||
|
||||
it('keeps benign model-card markup intact', () => {
|
||||
const clean = DOMPurify.sanitize(
|
||||
'<h2 id="title">Title</h2><p>Text with <a href="https://example.com" target="_blank" rel="noopener">a link</a>, <img src="https://example.com/i.png" alt="i" width="100">, <code>code</code>, <table><tr><td colspan="2">cell</td></tr></table>, <math><semantics><annotation encoding="application/x-tex">x^2</annotation></semantics></math></p>',
|
||||
SAFE_HTML_CONFIG
|
||||
) as string;
|
||||
|
||||
expect(clean).toContain('href="https://example.com"');
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('colspan="2"');
|
||||
expect(clean).toContain('<annotation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownContent end to end', () => {
|
||||
it('renders an allowHtml payload README without executing it', async () => {
|
||||
const evil = [
|
||||
'# Evil card',
|
||||
'',
|
||||
'<img src=x onerror="window.__mdXss=1">',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<script>window.__mdXss=1</script>',
|
||||
'<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
|
||||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
|
||||
'',
|
||||
'normal **markdown** continues'
|
||||
].join('\n');
|
||||
const target = document.createElement('div');
|
||||
|
||||
document.body.appendChild(target);
|
||||
mount(MarkdownContent, { props: { allowHtml: true, content: evil }, target });
|
||||
|
||||
for (let i = 0; i < 100 && !target.textContent?.includes('normal'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(target.textContent).toContain('normal');
|
||||
expect(xssFired()).toBe(false);
|
||||
expect(target.querySelector('script')).toBeNull();
|
||||
expect(target.querySelector('iframe')).toBeNull();
|
||||
|
||||
target.remove();
|
||||
});
|
||||
|
||||
it('escapes raw HTML to literal text in the default mode', async () => {
|
||||
const target = document.createElement('div');
|
||||
|
||||
document.body.appendChild(target);
|
||||
const component = mount(MarkdownContent, {
|
||||
props: {
|
||||
content: '<script>window.__mdXss=1</script><img src=x onerror="window.__mdXss=1">'
|
||||
},
|
||||
target
|
||||
});
|
||||
|
||||
for (let i = 0; i < 100 && !target.textContent?.includes('onerror'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(xssFired()).toBe(false);
|
||||
expect(target.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(target.textContent).toContain('onerror');
|
||||
|
||||
target.remove();
|
||||
|
||||
if (component) unmount(component);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { McpServerForm } from '$lib/components/app/mcp';
|
||||
import { mcpStore } from '$lib/stores/mcp/index.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('mcp server display name', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateConfig('mcpServers', '[]');
|
||||
});
|
||||
|
||||
it('custom display name wins over the url fallback', () => {
|
||||
const server = mcpStore.addServer({
|
||||
displayName: 'My Tools',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
|
||||
expect(mcpStore.getServerLabel(server)).toBe('My Tools');
|
||||
});
|
||||
|
||||
it('without a custom name the url is the label', () => {
|
||||
const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/a' });
|
||||
|
||||
expect(mcpStore.getServerLabel(server)).toBe('https://mcp.example.com/a');
|
||||
});
|
||||
|
||||
it('identical labels get positional suffixes', () => {
|
||||
const a = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const b = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/b'
|
||||
});
|
||||
|
||||
expect(mcpStore.getServerLabel(a)).toBe('GitHub (1)');
|
||||
expect(mcpStore.getServerLabel(b)).toBe('GitHub (2)');
|
||||
});
|
||||
|
||||
it('renaming one twin dissolves the suffixes', () => {
|
||||
const a = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const b = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/b'
|
||||
});
|
||||
|
||||
mcpStore.updateServer(b.id, { displayName: 'GitHub Work' });
|
||||
expect(mcpStore.getServerLabel(a)).toBe('GitHub');
|
||||
expect(mcpStore.getServerLabel(mcpStore.getServerById(b.id)!)).toBe('GitHub Work');
|
||||
});
|
||||
|
||||
it('the form exposes an editable display name field', async () => {
|
||||
let captured = '';
|
||||
|
||||
const screen = await render(McpServerForm, {
|
||||
headers: '',
|
||||
name: '',
|
||||
onHeadersChange: () => {},
|
||||
onNameChange: (v: string) => (captured = v),
|
||||
onUrlChange: () => {},
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const input = screen.getByLabelText('Display name');
|
||||
|
||||
await expect.element(input).toBeVisible();
|
||||
await input.fill('My Custom Server');
|
||||
expect(captured).toBe('My Custom Server');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// MCP icons live on a remote origin, which the browser cannot load under the
|
||||
// UI's cross-origin isolation. When the user enabled the server's CORS proxy the
|
||||
// icons are fetched through it; with the proxy off the origin URL stands in and
|
||||
// the components fall back when it fails. Inline data: icons load under any
|
||||
// policy and are never proxied.
|
||||
|
||||
import { mcpStore } from '$lib/stores/mcp/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { buildProxiedIconUrl } from '$lib/utils/cors-proxy';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// The favicon fallback resolves to the root domain of the server url, so the
|
||||
// icons of https://mcp.example.com/mcp are fetched from https://example.com.
|
||||
const FAVICON = 'https://example.com/favicon.ico';
|
||||
const DATA_ICON = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
|
||||
function mockProxyEnabled(enabled: boolean) {
|
||||
Object.defineProperty(serverStore, 'props', {
|
||||
configurable: true,
|
||||
get: () => ({ cors_proxy_enabled: enabled }) as unknown as typeof serverStore.props
|
||||
});
|
||||
}
|
||||
|
||||
/** Target a proxied icon URL carries, or null when the URL is not proxied. */
|
||||
function proxiedTarget(iconUrl: string | null): string | null {
|
||||
if (!iconUrl) return null;
|
||||
|
||||
const url = new URL(iconUrl, window.location.origin);
|
||||
|
||||
return url.pathname.endsWith('/cors-proxy') ? url.searchParams.get('url') : null;
|
||||
}
|
||||
|
||||
describe('mcp server icon urls', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateConfig('mcpServers', '[]');
|
||||
});
|
||||
|
||||
it('routes the favicon fallback through the proxy when it is enabled', () => {
|
||||
mockProxyEnabled(true);
|
||||
|
||||
const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/mcp' });
|
||||
|
||||
expect(proxiedTarget(mcpStore.getServerFavicon(server.id))).toBe(FAVICON);
|
||||
});
|
||||
|
||||
it('keeps the origin url when the proxy is off', () => {
|
||||
mockProxyEnabled(false);
|
||||
|
||||
const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/mcp' });
|
||||
|
||||
expect(mcpStore.getServerFavicon(server.id)).toBe(FAVICON);
|
||||
});
|
||||
|
||||
it('proxies an http icon url too', () => {
|
||||
expect(proxiedTarget(buildProxiedIconUrl('http://example.com/favicon.ico', true))).toBe(
|
||||
'http://example.com/favicon.ico'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a data url alone whichever way the proxy is set', () => {
|
||||
expect(buildProxiedIconUrl(DATA_ICON, true)).toBe(DATA_ICON);
|
||||
expect(buildProxiedIconUrl(DATA_ICON, false)).toBe(DATA_ICON);
|
||||
expect(buildProxiedIconUrl(null, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const AUTHORIZATION_HEADER = 'Authorization';
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
const BEARER_PLACEHOLDER = 'Paste token here';
|
||||
|
||||
/**
|
||||
* Client-side tests for the McpServerForm bearer UI.
|
||||
*
|
||||
* The dedicated UI only "owns" Authorization headers that already carry a
|
||||
* Bearer scheme (heuristic check on the value). Other Authorization values
|
||||
* stay in the KV section so the user can still edit them verbatim. Storage
|
||||
* always goes through the same custom-headers slot, so a round-trip via this
|
||||
* UI produces exactly one `Authorization: Bearer <token>` entry.
|
||||
*
|
||||
* Equivalent parser coverage lives in `tests/unit/headers.test.ts`.
|
||||
*/
|
||||
describe('McpServerForm - Authorization / bearer UI', () => {
|
||||
function bearerInput(screen: Awaited<ReturnType<typeof render>>) {
|
||||
return screen.locator.getByPlaceholder(BEARER_PLACEHOLDER);
|
||||
}
|
||||
|
||||
function capturedHeaders(screen: Awaited<ReturnType<typeof render>>) {
|
||||
return screen.getByTestId('captured-headers');
|
||||
}
|
||||
|
||||
it('mounts with the bearer input hidden when no auth header is present', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: /server url/i })).toBeVisible();
|
||||
|
||||
await expect.element(bearerInput(screen)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggling Authorization shows the bearer input', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect.element(bearerInput(screen)).toBeVisible();
|
||||
});
|
||||
|
||||
it('typing a token writes the Authorization row with the Bearer prefix prepended', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
const token = 'super-secret';
|
||||
|
||||
await bearerInput(screen).fill(token);
|
||||
|
||||
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${token}` });
|
||||
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', expected);
|
||||
});
|
||||
|
||||
it('pre-existing Bearer header pre-fills the bearer input with the token stripped', async () => {
|
||||
const existing = JSON.stringify({
|
||||
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`,
|
||||
'X-Trace-Id': 'abc'
|
||||
});
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await expect.element(bearerInput(screen)).toBeVisible();
|
||||
await expect.element(bearerInput(screen)).toHaveValue('preexisting');
|
||||
});
|
||||
|
||||
it('non-Bearer Authorization is ignored by the dedicated UI and stays in the KV section', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await expect.element(bearerInput(screen)).not.toBeInTheDocument();
|
||||
|
||||
const headerKeyInput = screen.getByPlaceholder('Header name');
|
||||
|
||||
await expect.element(headerKeyInput).toBeVisible();
|
||||
});
|
||||
|
||||
it('engaging the token UI replaces a non-Bearer Authorization with the Bearer scheme', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic old' });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
await bearerInput(screen).fill('new');
|
||||
|
||||
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}new` });
|
||||
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', expected);
|
||||
});
|
||||
|
||||
it('toggling Authorization off with no token drops the Bearer row but keeps non-Bearer schemes', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
|
||||
});
|
||||
|
||||
it('toggling Authorization off when no Bearer row is present leaves headers untouched', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', existing);
|
||||
});
|
||||
|
||||
it('clearing the bearer input drops the Authorization row', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await bearerInput(screen).fill('');
|
||||
|
||||
await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
|
||||
});
|
||||
|
||||
it('does not surface Bearer Authorization in the KV section even when pre-existing', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
const headerKeyInput = screen.getByPlaceholder('Header name');
|
||||
|
||||
await expect.element(headerKeyInput).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Guards which org a model avatar shows: a row that names neither flag follows the
|
||||
// family grouping (grouped lists leave the base org to the family heading), while a
|
||||
// caller that names one flag keeps control of what the avatar shows.
|
||||
|
||||
import ModelAvatar from '$lib/components/app/models/ModelAvatar.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
// the base-model tag resolves the base org without a Hub lookup
|
||||
const option: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B-GGUF:Q4_K_M',
|
||||
model: 'org/Qwen3-8B-GGUF:Q4_K_M',
|
||||
name: 'Qwen3-8B',
|
||||
tags: ['base_model:Qwen/Qwen3-8B']
|
||||
};
|
||||
/** The main avatar plus the quantizer badge, when the badge differs from it. */
|
||||
const avatars = (container: HTMLElement) => container.querySelectorAll('img').length;
|
||||
|
||||
function setGrouping(grouped: boolean) {
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY, grouped);
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.USE_HUGGING_FACE_HUB, true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setGrouping(false);
|
||||
});
|
||||
|
||||
describe('model avatar orgs', () => {
|
||||
it('shows the base org with the quantizer badge when the lists are flat', async () => {
|
||||
const screen = render(ModelAvatar, { option });
|
||||
|
||||
expect(avatars(screen.container)).toBe(2);
|
||||
});
|
||||
|
||||
it('leaves the base org to the family heading when the lists are grouped', async () => {
|
||||
setGrouping(true);
|
||||
|
||||
const screen = render(ModelAvatar, { option });
|
||||
|
||||
expect(avatars(screen.container)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the base org of a caller that names it, grouped lists or not', async () => {
|
||||
setGrouping(true);
|
||||
|
||||
const screen = render(ModelAvatar, { option, showBaseModelAvatar: true });
|
||||
|
||||
expect(avatars(screen.container)).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the repo org of a caller that names it, flat lists or not', async () => {
|
||||
const screen = render(ModelAvatar, { option, showRepoOrgAvatar: true });
|
||||
|
||||
expect(avatars(screen.container)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Guards the download flows the chips depend on: one POST per click, the chip
|
||||
// reading downloaded once the list reports the model, a resume keeping the
|
||||
// sidecars it started with, a cancel dropping the whole download, and a reload
|
||||
// restoring the paused state of an unfinished one.
|
||||
|
||||
import { ModelAuxSidecar, ModelDraftSidecar, ServerModelStatus, ServerRole } from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ApiModelDataEntry, ApiModelsListResponse } from '$lib/types/api';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const REPO = 'org/model-GGUF';
|
||||
const TAG = `${REPO}:Q4_K_M`;
|
||||
const DRAFTS = [ModelAuxSidecar.MMPROJ, ModelDraftSidecar.DFLASH];
|
||||
|
||||
/** The listing entry a router reports once a tag is downloaded. */
|
||||
function entry(id: string, sidecars: string[] = [], status = ServerModelStatus.DOWNLOADED) {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
sidecars,
|
||||
status: { value: status }
|
||||
} as unknown as ApiModelDataEntry;
|
||||
}
|
||||
|
||||
/** The download endpoints the store talks to; the feed is not live in these tests. */
|
||||
function stubEndpoints() {
|
||||
const post = vi.spyOn(ModelsService, 'downloadModel').mockResolvedValue({ success: true });
|
||||
const cancel = vi.spyOn(ModelsService, 'cancelDownload').mockResolvedValue({ success: true });
|
||||
const unload = vi.spyOn(ModelsService, 'unload').mockResolvedValue({ success: true });
|
||||
|
||||
vi.spyOn(ModelsService, 'watchModelEvents').mockResolvedValue(undefined);
|
||||
vi.spyOn(ModelsService, 'list').mockResolvedValue({
|
||||
data: [],
|
||||
models: [],
|
||||
object: 'list'
|
||||
} as unknown as ApiModelsListResponse);
|
||||
|
||||
return { cancel, post, unload };
|
||||
}
|
||||
|
||||
describe('model download flows', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
modelsStore.routerModels = [];
|
||||
// the store is a singleton: drop what an earlier case tracked
|
||||
modelsStore.status.unsubscribe();
|
||||
});
|
||||
|
||||
it('posts once for two quick clicks on the same chip', async () => {
|
||||
const { post } = stubEndpoints();
|
||||
const first = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
const second = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
|
||||
// the chip reads in flight before the server answers, so the second click
|
||||
// finds it busy instead of posting again
|
||||
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(true);
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledWith(TAG, DRAFTS);
|
||||
});
|
||||
|
||||
it('reads the chip as downloaded once the list reports the model', async () => {
|
||||
stubEndpoints();
|
||||
|
||||
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
|
||||
expect(modelsStore.status.isModelDownloaded(TAG)).toBe(false);
|
||||
|
||||
// the feed's reload lands as a listing the tag now appears in
|
||||
modelsStore.routerModels = [entry(TAG, DRAFTS)];
|
||||
|
||||
expect(modelsStore.status.isModelDownloaded(TAG)).toBe(true);
|
||||
// the sidecar option of that model reads the draft off the same entry
|
||||
expect(modelsStore.status.isModelDownloaded(`${TAG}-dflash`)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not pause a download whose POST is still on its way', async () => {
|
||||
const { post, unload } = stubEndpoints();
|
||||
|
||||
let release: () => void = () => {};
|
||||
|
||||
post.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
const started = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
|
||||
// the chip reads as downloading before the server answers, so a second click
|
||||
// must not stop a model that never started
|
||||
await modelsStore.status.pauseDownload(TAG);
|
||||
|
||||
expect(unload).not.toHaveBeenCalled();
|
||||
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(true);
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(false);
|
||||
|
||||
release();
|
||||
await started;
|
||||
});
|
||||
|
||||
it('resumes with the sidecars the download started with', async () => {
|
||||
const { post } = stubEndpoints();
|
||||
|
||||
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
await modelsStore.status.pauseDownload(TAG);
|
||||
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(true);
|
||||
|
||||
// the resume a chip sends names no kinds of its own
|
||||
await modelsStore.status.downloadModel(TAG);
|
||||
|
||||
expect(post).toHaveBeenLastCalledWith(TAG, DRAFTS);
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels the model and its sidecars as one download', async () => {
|
||||
const { cancel } = stubEndpoints();
|
||||
|
||||
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
await modelsStore.status.cancelDownload(TAG);
|
||||
|
||||
// no sidecar of its own is asked for: the model goes as one unit
|
||||
expect(cancel).toHaveBeenCalledWith(TAG, null);
|
||||
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(false);
|
||||
expect(modelsStore.status.getDownloadEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores the paused state of an unfinished download after a reload', async () => {
|
||||
const { post } = stubEndpoints();
|
||||
|
||||
modelsStore.routerModels = [entry(TAG, DRAFTS, ServerModelStatus.INCOMPLETE)];
|
||||
modelsStore.status.syncPausedDownloads();
|
||||
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(true);
|
||||
expect(modelsStore.status.getDownloadEntries()).toEqual([
|
||||
{ isPaused: true, progress: null, repoWithTag: TAG }
|
||||
]);
|
||||
|
||||
// and its resume keeps the kinds the incomplete listing reports
|
||||
await modelsStore.status.downloadModel(TAG);
|
||||
|
||||
expect(post).toHaveBeenCalledWith(TAG, DRAFTS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Guards the row behaviour of the manager table: a row selects its model with a
|
||||
// click or from the keyboard, and a repo row toggles its quants the same way.
|
||||
|
||||
import ModelsManagerRowWrapper from './components/ModelsManagerRowWrapper.svelte';
|
||||
import ModelsManagerRepoRow from '$lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte';
|
||||
import type { ModelQuantGroup } from '$lib/components/app/models/ModelsManager/utils';
|
||||
import { ModelGroupKind, ServerRole } from '$lib/enums';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const option: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B:Q4_K_M',
|
||||
model: 'org/Qwen3-8B:Q4_K_M',
|
||||
name: 'Qwen3-8B'
|
||||
};
|
||||
const entry: ModelQuantGroup = {
|
||||
base: option,
|
||||
key: 'org/Qwen3-8B',
|
||||
kind: ModelGroupKind.QUANTS,
|
||||
quants: [
|
||||
option,
|
||||
{ ...option, id: 'org/Qwen3-8B:Q8_0', model: 'org/Qwen3-8B:Q8_0', name: 'Qwen3-8B-Q8_0' }
|
||||
]
|
||||
};
|
||||
/** The name the row carries once the model id is split into its badges. */
|
||||
const ROW_NAME = /org\/Qwen3\s+8B/;
|
||||
|
||||
describe('manager model row', () => {
|
||||
let selected: ModelOption[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
selected = [];
|
||||
// the row only offers a load control on a router server
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
});
|
||||
|
||||
function row() {
|
||||
return render(ModelsManagerRowWrapper, {
|
||||
onSelect: (picked: ModelOption) => selected.push(picked),
|
||||
option,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
it('selects the model from the row', async () => {
|
||||
const screen = await row();
|
||||
|
||||
await screen.getByRole('button', { name: ROW_NAME }).click();
|
||||
|
||||
expect(selected).toEqual([option]);
|
||||
});
|
||||
|
||||
it('selects the model from the keyboard', async () => {
|
||||
const screen = await row();
|
||||
const target = screen.getByRole('button', { name: ROW_NAME }).element();
|
||||
|
||||
target.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(selected).toEqual([option]);
|
||||
});
|
||||
|
||||
it('keeps the load and actions controls of the row', async () => {
|
||||
const screen = await row();
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { exact: true, name: 'Model actions' }))
|
||||
.toBeVisible();
|
||||
expect(screen.container.querySelector('[aria-label="Load model"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('manager repo row', () => {
|
||||
it('toggles the quants from the row', async () => {
|
||||
let toggles = 0;
|
||||
|
||||
const screen = await render(ModelsManagerRepoRow, {
|
||||
entry,
|
||||
expanded: true,
|
||||
onToggle: () => (toggles += 1)
|
||||
});
|
||||
|
||||
await screen.getByRole('button', { name: /2 quants available/ }).click();
|
||||
|
||||
expect(toggles).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
// Guards the manager table ordering and filtering: the context column sorts and
|
||||
// filters once a model's context is known, from the option or the Hub record.
|
||||
|
||||
import ModelsManagerWrapper from './components/ModelsManagerWrapper.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import type { ApiModelDataEntry } from '$lib/types';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
function option(model: string, contextLength?: number): ModelOption {
|
||||
return {
|
||||
capabilities: [],
|
||||
contextLength,
|
||||
id: model,
|
||||
model,
|
||||
name: model
|
||||
};
|
||||
}
|
||||
|
||||
const models = [
|
||||
option('org/alpha-8b:Q4_K_M', 8192),
|
||||
option('org/beta-8b:Q4_K_M', 131072),
|
||||
option('org/gamma-8b:Q4_K_M', 32768)
|
||||
];
|
||||
// the router listing carries no context, so a row starts without one
|
||||
const modelsWithoutContext = models.map((model) => ({ ...model, contextLength: undefined }));
|
||||
|
||||
beforeEach(() => {
|
||||
modelsStore.routerModels = [];
|
||||
modelsStore.models = modelsWithoutContext;
|
||||
modelsStore.favoriteModelIds = new Set();
|
||||
settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY] = false;
|
||||
});
|
||||
|
||||
/** Renders the manager and waits for the test models to show. */
|
||||
async function renderWithModels(rows: ModelOption[] = modelsWithoutContext) {
|
||||
const screen = render(ModelsManagerWrapper);
|
||||
|
||||
modelsStore.models = rows;
|
||||
|
||||
await expect.element(screen.getByText(/gamma\s+8B/)).toBeVisible();
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
function rowNames(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('button[aria-pressed]')].map(
|
||||
(row) => row.textContent ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/** The Hub details cache the rows read their context from. */
|
||||
function detailsCache() {
|
||||
return (
|
||||
HuggingFaceService as unknown as {
|
||||
detailsCache: SvelteMap<string, { gguf?: { context_length?: number } } | null>;
|
||||
}
|
||||
).detailsCache;
|
||||
}
|
||||
|
||||
function warmCache() {
|
||||
const cache = detailsCache();
|
||||
|
||||
cache.set('org/alpha-8b', { gguf: { context_length: 8192 } });
|
||||
cache.set('org/beta-8b', { gguf: { context_length: 131072 } });
|
||||
cache.set('org/gamma-8b', { gguf: { context_length: 32768 } });
|
||||
}
|
||||
|
||||
it('sorts by context', async () => {
|
||||
const screen = await renderWithModels(models);
|
||||
|
||||
// lowest first
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const ascending = rowNames(screen.container);
|
||||
|
||||
await screen.getByTitle('Sort by context, highest first').click();
|
||||
|
||||
const descending = rowNames(screen.container);
|
||||
|
||||
expect(ascending).not.toEqual(descending);
|
||||
});
|
||||
|
||||
it('sorts by context with family grouping on', async () => {
|
||||
settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY] = true;
|
||||
|
||||
const screen = await renderWithModels(models);
|
||||
|
||||
// lowest first
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const ascending = rowNames(screen.container);
|
||||
|
||||
await screen.getByTitle('Sort by context, highest first').click();
|
||||
|
||||
const descending = rowNames(screen.container);
|
||||
|
||||
expect(ascending).not.toEqual(descending);
|
||||
});
|
||||
|
||||
it('lists the favorited quants of a repo as flat rows', async () => {
|
||||
// one quant of the beta repo is a favorite, the other stays with the repo
|
||||
modelsStore.favoriteModelIds = new Set(['org/alpha-8b:Q4_K_M', 'org/beta-8b:Q4_K_M']);
|
||||
|
||||
const screen = await renderWithModels([
|
||||
modelsWithoutContext[0],
|
||||
modelsWithoutContext[1],
|
||||
option('org/beta-8b:Q8_0'),
|
||||
modelsWithoutContext[2]
|
||||
]);
|
||||
|
||||
// the favorite quant is a model row of its own, not a repo with subitems
|
||||
expect(screen.container.textContent).not.toContain('2 quants available');
|
||||
|
||||
// the repo appears once in favorites for its favorited quant and once in the
|
||||
// local block for the quant left behind
|
||||
expect(screen.getByText(/beta\s+8B/).elements().length).toBe(2);
|
||||
});
|
||||
|
||||
/** A router listing entry carrying only the meta context. */
|
||||
function entry(model: string, nCtxTrain: number): ApiModelDataEntry {
|
||||
return {
|
||||
created: 0,
|
||||
id: model,
|
||||
in_cache: false,
|
||||
meta: { n_ctx_train: nCtxTrain },
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${model}`,
|
||||
status: { value: ServerModelStatus.UNLOADED }
|
||||
};
|
||||
}
|
||||
|
||||
it('sorts by the meta context of a listing without the router field', async () => {
|
||||
// a listing that skips the router's GGUF read reports the trained context
|
||||
// only as meta.n_ctx_train, so the option mapping falls back to it
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
if (url.includes('/props')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
default_generation_settings: { n_ctx: 0, params: {} },
|
||||
model_alias: 'llama-server',
|
||||
model_path: 'none',
|
||||
role: 'router'
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/server')) {
|
||||
return new Response(
|
||||
JSON.stringify({ git_branch: 'test', git_commit: 'test', mode: 'router', version: 'test' }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/v1\/models|\/models\b/.test(url)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
entry('org/alpha-8b:Q4_K_M', 8192),
|
||||
entry('org/beta-8b:Q4_K_M', 131072),
|
||||
entry('org/gamma-8b:Q4_K_M', 32768)
|
||||
],
|
||||
object: 'list'
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch in the test: ${url}`);
|
||||
});
|
||||
|
||||
const screen = render(ModelsManagerWrapper);
|
||||
|
||||
// the fetch maps the listing into options, the meta context fills in
|
||||
await modelsStore.fetch(true);
|
||||
|
||||
await expect.element(screen.getByText(/gamma\s+8B/)).toBeVisible();
|
||||
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const names = rowNames(screen.container).join(' | ');
|
||||
|
||||
expect(names.indexOf('alpha')).toBeLessThan(names.indexOf('gamma'));
|
||||
expect(names.indexOf('gamma')).toBeLessThan(names.indexOf('beta'));
|
||||
});
|
||||
|
||||
it('re-sorts when the Hub details arrive after the sort was clicked', async () => {
|
||||
const screen = await renderWithModels();
|
||||
|
||||
// the user sorts while the contexts are still unknown
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
// then the rows fetch their Hub records
|
||||
warmCache();
|
||||
|
||||
// the table re-sorts once the cache answers
|
||||
await vi.waitFor(() => {
|
||||
const names = rowNames(screen.container).join(' | ');
|
||||
|
||||
expect(names.indexOf('alpha')).toBeLessThan(names.indexOf('gamma'));
|
||||
expect(names.indexOf('gamma')).toBeLessThan(names.indexOf('beta'));
|
||||
|
||||
return names;
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by search', async () => {
|
||||
const screen = await renderWithModels();
|
||||
|
||||
await screen.getByPlaceholder('Search your models').fill('beta');
|
||||
|
||||
await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText(/beta\s+8B/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('filters by context and sorts from the Hub details cache', async () => {
|
||||
warmCache();
|
||||
|
||||
const screen = await renderWithModels();
|
||||
|
||||
// open the context filter and ask for 32K or more
|
||||
await screen.getByText('Context:').click();
|
||||
await screen.getByText('32K or more').click();
|
||||
|
||||
await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText(/gamma\s+8B/)).toBeVisible();
|
||||
|
||||
// sorting re-runs once the cache answers
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const names = rowNames(screen.container).join(' | ');
|
||||
|
||||
expect(names.indexOf('gamma')).toBeLessThan(names.indexOf('beta'));
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// The manager table is built from the store's option list, so these run in the
|
||||
// browser project: how the quants fold into rows, what context a row reports and
|
||||
// how the tracked downloads become rows of their own.
|
||||
|
||||
import {
|
||||
downloadGroups,
|
||||
groupModelQuants,
|
||||
modelContextLength,
|
||||
splitHiddenQuants
|
||||
} from '$lib/components/app/models/ModelsManager/utils';
|
||||
import { LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import { ModelDraftSidecar, ModelGroupKind, ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import type { ApiModelDataEntry, ModelOption } from '$lib/types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function option(model: string, id = model): ModelOption {
|
||||
return { capabilities: [], id, model, name: model };
|
||||
}
|
||||
|
||||
function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
status: { value: status }
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
modelsStore.routerModels = [];
|
||||
});
|
||||
|
||||
describe('groupModelQuants', () => {
|
||||
it('folds the quants of one repo into a single entry', () => {
|
||||
const groups = groupModelQuants([
|
||||
option('org/Qwen3-8B:Q4_K_M'),
|
||||
option('org/Qwen3-8B:Q8_0'),
|
||||
option('org/Llama-3-8B:Q4_K_M')
|
||||
]);
|
||||
|
||||
expect(groups.map((group) => [group.key, group.kind, group.quants.length])).toEqual([
|
||||
['org/Qwen3-8B', ModelGroupKind.QUANTS, 2],
|
||||
['org/Llama-3-8B', ModelGroupKind.QUANTS, 1]
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the first quant of a repo as the row it renders', () => {
|
||||
const groups = groupModelQuants([option('org/Qwen3-8B:Q4_K_M'), option('org/Qwen3-8B:Q8_0')]);
|
||||
|
||||
expect(groups[0].base.model).toBe('org/Qwen3-8B:Q4_K_M');
|
||||
});
|
||||
|
||||
it('keeps rows of the same id apart as variants', () => {
|
||||
const groups = groupModelQuants([
|
||||
option('org/Qwen3-8B:Q4_K_M', 'first'),
|
||||
option('org/Qwen3-8B:Q4_K_M', 'second')
|
||||
]);
|
||||
|
||||
expect(groups.map((group) => [group.key, group.kind])).toEqual([
|
||||
['org/Qwen3-8B::first', ModelGroupKind.VARIANTS],
|
||||
['org/Qwen3-8B::second', ModelGroupKind.VARIANTS]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelContextLength', () => {
|
||||
it('reports the context of the listing', () => {
|
||||
expect(modelContextLength({ ...option('org/Qwen3-8B:Q4_K_M'), contextLength: 8192 })).toBe(
|
||||
8192
|
||||
);
|
||||
});
|
||||
|
||||
it('reports nothing while no source knows the context', () => {
|
||||
expect(modelContextLength(option('org/Qwen3-8B:Q4_K_M'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadGroups', () => {
|
||||
it('reuses the row of a download the router already lists', () => {
|
||||
const listed = option('org/Qwen3-8B:Q4_K_M');
|
||||
const groups = downloadGroups(
|
||||
[{ isPaused: false, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' }],
|
||||
[listed],
|
||||
() => []
|
||||
);
|
||||
|
||||
expect(groups[0].key).toBe('download::org/Qwen3-8B:Q4_K_M');
|
||||
expect(groups[0].base).toMatchObject(listed);
|
||||
});
|
||||
|
||||
it('stands the download in for a row of its own when nothing is listed', () => {
|
||||
const groups = downloadGroups(
|
||||
[{ isPaused: true, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' }],
|
||||
[],
|
||||
(repo) =>
|
||||
repo === 'org/Qwen3-8B' ? [{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo }] : []
|
||||
);
|
||||
|
||||
// the sub-lines carry the sidecars, so the inline badges stay empty
|
||||
expect(groups[0].base).toMatchObject({
|
||||
backendId: LOCAL_BACKEND_ID,
|
||||
draftSidecars: [],
|
||||
model: 'org/Qwen3-8B:Q4_K_M'
|
||||
});
|
||||
expect(groups[0].sidecarBadges).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: 'org/Qwen3-8B' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one row per tracked download, sidecars included', () => {
|
||||
const main = { isPaused: false, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' };
|
||||
const other = { isPaused: true, progress: null, repoWithTag: 'org/Qwen3-8B:Q8_0' };
|
||||
const groups = downloadGroups([main, other], [], () => []);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.map((group) => group.key)).toEqual([
|
||||
'download::org/Qwen3-8B:Q4_K_M',
|
||||
'download::org/Qwen3-8B:Q8_0'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelsStore.isModelRunning', () => {
|
||||
it('counts a loaded or sleeping model as running', () => {
|
||||
modelsStore.routerModels = [
|
||||
routerEntry('org/loaded', ServerModelStatus.LOADED),
|
||||
routerEntry('org/sleeping', ServerModelStatus.SLEEPING)
|
||||
];
|
||||
|
||||
expect(modelsStore.isModelRunning('org/loaded')).toBe(true);
|
||||
expect(modelsStore.isModelRunning('org/sleeping')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a loading, failed or unknown model as running', () => {
|
||||
modelsStore.routerModels = [
|
||||
routerEntry('org/loading', ServerModelStatus.LOADING),
|
||||
routerEntry('org/failed', ServerModelStatus.FAILED)
|
||||
];
|
||||
|
||||
expect(modelsStore.isModelRunning('org/loading')).toBe(false);
|
||||
expect(modelsStore.isModelRunning('org/failed')).toBe(false);
|
||||
expect(modelsStore.isModelRunning('org/unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitHiddenQuants', () => {
|
||||
const entry = (repo: string, quants: string[]) => ({
|
||||
base: option(quants[0]),
|
||||
key: repo,
|
||||
kind: ModelGroupKind.QUANTS,
|
||||
quants: quants.map((model) => option(model))
|
||||
});
|
||||
|
||||
it('keeps the visible quants of a partly hidden repo in the local block', () => {
|
||||
const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M', 'org/Qwen3-8B:Q8_0']);
|
||||
const { hidden, local } = splitHiddenQuants([repo], (picked) => picked.model.endsWith('Q8_0'));
|
||||
|
||||
expect(hidden.map((group) => [group.key, group.quants.length])).toEqual([
|
||||
['org/Qwen3-8B::hidden', 1]
|
||||
]);
|
||||
expect(local.map((group) => [group.key, group.quants.length])).toEqual([['org/Qwen3-8B', 1]]);
|
||||
expect(local[0].base.model).toBe('org/Qwen3-8B:Q4_K_M');
|
||||
});
|
||||
|
||||
it('moves a repo whose quants are all hidden', () => {
|
||||
const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M', 'org/Qwen3-8B:Q8_0']);
|
||||
const { hidden, local } = splitHiddenQuants([repo], () => true);
|
||||
|
||||
expect(hidden[0].quants).toHaveLength(2);
|
||||
expect(local).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves a repo with no hidden quant alone', () => {
|
||||
const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M']);
|
||||
const { hidden, local } = splitHiddenQuants([repo], () => false);
|
||||
|
||||
expect(hidden).toEqual([]);
|
||||
expect(local[0].key).toBe('org/Qwen3-8B');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// Guards the hidden-model rule of the selector: a hidden model leaves every
|
||||
// section, while the model a conversation already uses still resolves, since it
|
||||
// can be hidden and selected at the same time.
|
||||
|
||||
import ModelsSelectorHookHarness from './components/ModelsSelectorHookHarness.svelte';
|
||||
import { ServerModelStatus, ServerRole } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ApiModelDataEntry, ModelOption } from '$lib/types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
function option(model: string): ModelOption {
|
||||
return { capabilities: [], id: model, model, name: model };
|
||||
}
|
||||
|
||||
function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
status: { value: status }
|
||||
};
|
||||
}
|
||||
|
||||
const VISIBLE = option('org/visible:Q4_K_M');
|
||||
const HIDDEN = option('org/hidden:Q4_K_M');
|
||||
const HIDDEN_LOADED = option('org/hidden-loaded:Q4_K_M');
|
||||
const HIDDEN_FAVORITE = option('org/hidden-favorite:Q4_K_M');
|
||||
|
||||
beforeEach(() => {
|
||||
modelsStore.models = [VISIBLE, HIDDEN, HIDDEN_LOADED, HIDDEN_FAVORITE];
|
||||
modelsStore.favoriteModelIds = new Set([HIDDEN_FAVORITE.model]);
|
||||
modelsStore.hiddenModelIds = new Set([HIDDEN.id, HIDDEN_LOADED.id, HIDDEN_FAVORITE.id]);
|
||||
modelsStore.routerModels = [routerEntry(HIDDEN_LOADED.model, ServerModelStatus.LOADED)];
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
});
|
||||
|
||||
describe('hidden models in the selector', () => {
|
||||
it('lists what is not hidden', () => {
|
||||
const screen = render(ModelsSelectorHookHarness);
|
||||
|
||||
expect(screen.component.listedIds()).toEqual([VISIBLE.id]);
|
||||
});
|
||||
|
||||
it('keeps a hidden favorite and a hidden loaded model out of the sections', () => {
|
||||
const screen = render(ModelsSelectorHookHarness);
|
||||
const listed = screen.component.listedIds();
|
||||
|
||||
expect(listed).not.toContain(HIDDEN_FAVORITE.id);
|
||||
expect(listed).not.toContain(HIDDEN_LOADED.id);
|
||||
});
|
||||
|
||||
it('reports an empty list when everything is hidden', () => {
|
||||
modelsStore.hiddenModelIds = new Set([
|
||||
VISIBLE.id,
|
||||
HIDDEN.id,
|
||||
HIDDEN_LOADED.id,
|
||||
HIDDEN_FAVORITE.id
|
||||
]);
|
||||
|
||||
const screen = render(ModelsSelectorHookHarness);
|
||||
|
||||
expect(screen.component.isEmpty()).toBe(true);
|
||||
});
|
||||
|
||||
it('still resolves the model the conversation uses when it is hidden', () => {
|
||||
const screen = render(ModelsSelectorHookHarness, {
|
||||
currentModel: () => HIDDEN.model
|
||||
});
|
||||
|
||||
expect(screen.component.shownModelId()).toBe(HIDDEN.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Guards the phone model picker: the selector serves a phone a bottom drawer
|
||||
// with the same search and sections the desktop menu shows.
|
||||
|
||||
import ModelsSelector from '$lib/components/app/models/ModelsSelector/ModelsSelector.svelte';
|
||||
import { ServerRole } from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
const option: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B:Q4_K_M',
|
||||
model: 'org/Qwen3-8B:Q4_K_M',
|
||||
name: 'Qwen3-8B'
|
||||
};
|
||||
const other: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B:Q8_0',
|
||||
model: 'org/Qwen3-8B:Q8_0',
|
||||
name: 'Qwen3-8B-Q8_0'
|
||||
};
|
||||
|
||||
describe('model picker drawer on a phone', () => {
|
||||
beforeEach(async () => {
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
// the picker mounts a fetch of its own: serve it the same two models, so
|
||||
// the list it rebuilds does not clear the seeded one mid-test
|
||||
vi.spyOn(ModelsService, 'list').mockResolvedValue({
|
||||
data: [{ id: option.model }, { id: other.model }]
|
||||
} as never);
|
||||
modelsStore.models = [option, other];
|
||||
await modelsStore.selectModelById(option.id);
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
modelsStore.models = [];
|
||||
vi.restoreAllMocks();
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('opens the picker in a drawer', async () => {
|
||||
const screen = render(ModelsSelector);
|
||||
|
||||
await screen.getByRole('button').click();
|
||||
|
||||
await expect.element(screen.getByPlaceholder('Search models...')).toBeVisible();
|
||||
await expect.element(screen.getByText('Local models')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// Guards the draft badges a model row shows: a registered model records its draft
|
||||
// in its `--model-draft` args, which know the file's quant, and the listing reports
|
||||
// the kinds every cached model of the repo has on disk, which do not. The row shows
|
||||
// each kind once, and two quants of one repo keep their own drafts apart.
|
||||
|
||||
import { ModelAuxSidecar, ModelDraftSidecar, ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ApiModelDataEntry } from '$lib/types/api';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const REPO = 'ggml-org/Qwen3.8-Flash-Next-GGUF';
|
||||
|
||||
/** Draft file of a cached model, as a preset records it in `--model-draft`. */
|
||||
function draftPath(quant: string): string {
|
||||
return `/cache/models--ggml-org--Qwen3.8-Flash-Next-GGUF/snapshots/${'a'.repeat(40)}/mtp-Qwen3.8-Flash-Next-${quant}.gguf`;
|
||||
}
|
||||
|
||||
/** A router entry; `args` stand in for the args of a loaded instance. */
|
||||
function entry(
|
||||
id: string,
|
||||
sidecars: string[] = [],
|
||||
args: string[] = [],
|
||||
status = ServerModelStatus.UNLOADED
|
||||
) {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
sidecars,
|
||||
status: { args, value: status }
|
||||
} as unknown as ApiModelDataEntry;
|
||||
}
|
||||
|
||||
describe('draft sidecar badges', () => {
|
||||
beforeEach(() => {
|
||||
modelsStore.routerModels = [];
|
||||
});
|
||||
|
||||
it('shows one badge for a draft the args and the listing both report', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(
|
||||
`${REPO}:Q8_0`,
|
||||
[ModelAuxSidecar.MMPROJ, ModelDraftSidecar.MTP],
|
||||
['--model-draft', draftPath('Q8_0')]
|
||||
)
|
||||
];
|
||||
|
||||
// the args badge wins: it knows the quant the listing does not
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q8_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives each quant of a repo the draft it pulls itself', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(`${REPO}:IQ4_NL`, [], ['--model-draft', draftPath('Q4_0')]),
|
||||
entry(`${REPO}:Q8_0`, [], ['--model-draft', draftPath('Q8_0')])
|
||||
];
|
||||
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:IQ4_NL`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q4_0', repo: REPO }
|
||||
]);
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q8_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows one badge for a kind two cached quants report', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(`${REPO}:IQ4_NL`, [ModelDraftSidecar.MTP]),
|
||||
entry(`${REPO}:Q8_0`, [ModelDraftSidecar.MTP])
|
||||
];
|
||||
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:IQ4_NL`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: null, repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the drafts of a download on its way as pending', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(
|
||||
`${REPO}:Q4_0`,
|
||||
[ModelAuxSidecar.MMPROJ, ModelDraftSidecar.MTP],
|
||||
[],
|
||||
ServerModelStatus.DOWNLOADING
|
||||
)
|
||||
];
|
||||
|
||||
// the kinds are not on disk yet, so the row reads them as in flight
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q4_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, pending: true, quant: null, repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the on-disk badge of a kind a download also pulls', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(`${REPO}:IQ4_NL`, [ModelDraftSidecar.MTP], [], ServerModelStatus.INCOMPLETE),
|
||||
entry(`${REPO}:Q8_0`, [], ['--model-draft', draftPath('Q8_0')])
|
||||
];
|
||||
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q8_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: REPO }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import TestWrapper from './components/TestWrapper.svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render page without throwing', async () => {
|
||||
// Basic smoke test - page should render without throwing errors.
|
||||
// API calls are mocked in vitest-setup-client.ts.
|
||||
await render(TestWrapper);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Regression test: opening a chat-form picker must not scroll the
|
||||
// conversation to the top. Root cause: the list's scroll effect fired
|
||||
// scrollIntoView on the initial mount, before the popover was positioned,
|
||||
// so the browser scrolled every scrollable ancestor to reveal the row.
|
||||
|
||||
import PickerListScrollHarness from './components/PickerListScrollHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('ChatFormPickerList mount scroll', () => {
|
||||
it('does not scroll documentElement when the picker mounts', async () => {
|
||||
const screen = render(PickerListScrollHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
document.documentElement.scrollTop = document.documentElement.scrollHeight;
|
||||
await tick();
|
||||
const before = document.documentElement.scrollTop;
|
||||
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
screen.component.openPicker();
|
||||
await tick();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await tick();
|
||||
|
||||
const after = document.documentElement.scrollTop;
|
||||
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Guards the phone shape of the reasoning control: a phone picks the effort in a drawer,
|
||||
// since a dropdown of small rows is not a touch target.
|
||||
|
||||
import ChatFormActionReasoning from '$lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte';
|
||||
import { ServerRole } from '$lib/enums';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
|
||||
describe('reasoning control on a phone', () => {
|
||||
beforeEach(async () => {
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('offers the effort levels in a drawer', async () => {
|
||||
const screen = render(ChatFormActionReasoning);
|
||||
|
||||
await screen.getByRole('button', { name: 'Reasoning effort' }).click();
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: /^Off$/ })).toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: /^Max/ })).toBeVisible();
|
||||
});
|
||||
|
||||
it('closes the drawer once a level is picked', async () => {
|
||||
const screen = render(ChatFormActionReasoning);
|
||||
|
||||
await screen.getByRole('button', { name: 'Reasoning effort' }).click();
|
||||
await screen.getByRole('button', { name: 'Low' }).click();
|
||||
|
||||
await expect.poll(() => screen.getByRole('button', { name: 'Low' }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SANDBOX_TOOL_NAME } from '$lib/constants';
|
||||
import { SandboxService } from '$lib/services/sandbox.service';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const run = (code: string, timeoutMs?: number) =>
|
||||
SandboxService.executeTool(SANDBOX_TOOL_NAME, {
|
||||
code,
|
||||
...(timeoutMs !== undefined ? { timeout_ms: timeoutMs } : {})
|
||||
});
|
||||
|
||||
describe('sandbox service', () => {
|
||||
beforeEach(async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.config = {
|
||||
...settingsStore.config,
|
||||
symbolicMathEnabled: true
|
||||
};
|
||||
});
|
||||
|
||||
it('executes plain JavaScript', async () => {
|
||||
const reply = await run('return 1 + 1;');
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> 2');
|
||||
});
|
||||
|
||||
it('exposes nerdamer for symbolic computation', async () => {
|
||||
const reply = await run("return nerdamer.diff('sin(x)/x', 'x').toString();");
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('cos(x)');
|
||||
});
|
||||
|
||||
it('computes exact rational arithmetic', async () => {
|
||||
const reply = await run("return nerdamer('1/3 + 1/6').toString();");
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> 1/2');
|
||||
});
|
||||
|
||||
it('proves a polynomial identity symbolically', async () => {
|
||||
const reply = await run(
|
||||
"return nerdamer('expand((1+x*y)^3 - (1 + 3*x*y + 3*x^2*y^2 + x^3*y^3))').toString();"
|
||||
);
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> 0');
|
||||
});
|
||||
|
||||
it('blocks network egress in the worker via CSP', async () => {
|
||||
const reply = await run(
|
||||
"try { await fetch('https://example.com/'); return 'leaked'; } catch { return 'blocked'; }"
|
||||
);
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> blocked');
|
||||
});
|
||||
|
||||
it('enforces the timeout on runaway code', async () => {
|
||||
const reply = await run('while (true) {}', 500);
|
||||
|
||||
expect(reply.isError).toBe(true);
|
||||
expect(reply.content).toContain('timed out');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Guards the rename of `showModelOrgNameInTrigger` onto `showModelOrgName`.
|
||||
// The persisted config always holds the legacy key, so a value it carries moves
|
||||
// over, while a config that already set the new key keeps its own value. The
|
||||
// legacy key is removed so it does not stay orphaned in localStorage.
|
||||
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const MODEL_ORG_NAME_KEY_MIGRATION_ID = 'model-org-name-key-v1';
|
||||
|
||||
async function seedConfig(stored: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
|
||||
const migration = MigrationService.getMigrations().find(
|
||||
(m) => m.id === MODEL_ORG_NAME_KEY_MIGRATION_ID
|
||||
);
|
||||
|
||||
await migration?.run();
|
||||
settingsStore.initialize();
|
||||
}
|
||||
|
||||
function persisted(): Record<string, unknown> {
|
||||
return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
}
|
||||
|
||||
describe('showModelOrgNameInTrigger rename', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
MigrationService.resetState();
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('carries an enabled trigger over to the selector-wide key', async () => {
|
||||
await seedConfig({ showModelOrgNameInTrigger: true });
|
||||
expect(settingsStore.config.showModelOrgName).toBe(true);
|
||||
});
|
||||
|
||||
it('carries a disabled trigger over to the selector-wide key', async () => {
|
||||
await seedConfig({ showModelOrgNameInTrigger: false });
|
||||
expect(settingsStore.config.showModelOrgName).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an explicit user preference over the legacy key', async () => {
|
||||
await seedConfig({ showModelOrgName: false, showModelOrgNameInTrigger: true });
|
||||
expect(settingsStore.config.showModelOrgName).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the legacy key from the persisted config', async () => {
|
||||
await seedConfig({ showModelOrgNameInTrigger: true });
|
||||
expect(persisted().showModelOrgNameInTrigger).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the selector-wide key on its default when nothing is stored', async () => {
|
||||
await seedConfig({});
|
||||
expect(settingsStore.config.showModelOrgName).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY, SETTING_CONFIG_DEFAULT } from '$lib/constants';
|
||||
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import type { SettingsConfigType } from '$lib/types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
type Primitive = string | number | boolean;
|
||||
|
||||
const KEYS = Object.keys(SETTING_CONFIG_DEFAULT).filter(
|
||||
(k) => ['string', 'number', 'boolean'].includes(typeof SETTING_CONFIG_DEFAULT[k]) && k !== 'theme'
|
||||
);
|
||||
|
||||
function divergent(key: string, base: Primitive): Primitive {
|
||||
if (typeof base === 'boolean') return !base;
|
||||
|
||||
if (typeof base === 'number') return base + 7;
|
||||
|
||||
return `user-${key}`;
|
||||
}
|
||||
|
||||
function baselineFor(key: string, base: Primitive): Primitive {
|
||||
if (typeof base === 'boolean') return !base;
|
||||
|
||||
if (typeof base === 'number') return base + 42;
|
||||
|
||||
return `admin-${key}`;
|
||||
}
|
||||
|
||||
function mockProps(uiSettings: Record<string, Primitive>) {
|
||||
Object.defineProperty(serverStore, 'props', {
|
||||
configurable: true,
|
||||
get: () =>
|
||||
({
|
||||
default_generation_settings: { params: { temperature: 0.8 } },
|
||||
ui_settings: uiSettings
|
||||
}) as unknown as typeof serverStore.props
|
||||
});
|
||||
}
|
||||
|
||||
const setUser = (key: string, value: Primitive) =>
|
||||
settingsStore.updateConfig(key as keyof SettingsConfigType, value as never);
|
||||
const current = (key: string) => (settingsStore.config as Record<string, unknown>)[key];
|
||||
|
||||
describe('registry-wide invariants', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
it('I1: no load ever modifies a stored user value, for any key of any type', () => {
|
||||
settingsStore.initialize();
|
||||
const userValues: Record<string, Primitive> = {};
|
||||
|
||||
for (const key of KEYS) {
|
||||
userValues[key] = divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
setUser(key, userValues[key]);
|
||||
}
|
||||
|
||||
// simulated F5 + adverse admin baseline on every key, synced twice
|
||||
settingsStore.initialize();
|
||||
const adverse: Record<string, Primitive> = {};
|
||||
|
||||
for (const key of KEYS)
|
||||
adverse[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
mockProps(adverse);
|
||||
settingsStore.syncWithServerDefaults();
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
for (const key of KEYS) {
|
||||
expect(current(key), key).toBe(userValues[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it('first visit: the baseline applies for every key, false and 0 included', () => {
|
||||
settingsStore.initialize();
|
||||
const baseline: Record<string, Primitive> = {};
|
||||
|
||||
for (const key of KEYS)
|
||||
baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
mockProps(baseline);
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
for (const key of KEYS) {
|
||||
if (ParameterSyncService.canSyncParameter(key)) continue;
|
||||
|
||||
expect(current(key), key).toBe(baseline[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it('I3: Reset returns every key to baseline when defined, factory default otherwise', () => {
|
||||
settingsStore.initialize();
|
||||
for (const key of KEYS) setUser(key, divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive));
|
||||
|
||||
const baseline: Record<string, Primitive> = {};
|
||||
|
||||
KEYS.filter((_, i) => i % 2 === 0).forEach((key) => {
|
||||
baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
});
|
||||
mockProps(baseline);
|
||||
|
||||
settingsStore.forceSyncWithServerDefaults();
|
||||
|
||||
for (const key of KEYS) {
|
||||
if (key in baseline) {
|
||||
expect(current(key), key).toBe(baseline[key]);
|
||||
} else if (ParameterSyncService.canSyncParameter(key)) {
|
||||
expect(current(key), key).toBe('');
|
||||
} else {
|
||||
expect(current(key), key).toBe(SETTING_CONFIG_DEFAULT[key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Guards the unfolding of `renderContentAsRawText` back onto the two
|
||||
// per-surface render keys. The single toggle carried user content and
|
||||
// thinking at once, so only the user key is restored from it and thinking
|
||||
// returns to its own default. The toggle is removed from the persisted
|
||||
// config so it does not stay orphaned in localStorage.
|
||||
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
|
||||
|
||||
async function seedConfig(stored: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
|
||||
const migration = MigrationService.getMigrations().find((m) => m.id === RENDER_KEYS_MIGRATION_ID);
|
||||
|
||||
await migration?.run();
|
||||
settingsStore.initialize();
|
||||
}
|
||||
|
||||
function persisted(): Record<string, unknown> {
|
||||
return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
}
|
||||
|
||||
describe('renderContentAsRawText unfolding', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
MigrationService.resetState();
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('maps raw text to user content as plain text', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true });
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(false);
|
||||
});
|
||||
|
||||
it('maps markdown to user content as markdown', async () => {
|
||||
await seedConfig({ renderContentAsRawText: false });
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves thinking on its own default', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true });
|
||||
expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an explicit user preference over the toggle', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true, renderUserContentAsMarkdown: true });
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the toggle from the persisted config', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true });
|
||||
expect(persisted().renderContentAsRawText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves both surfaces on markdown when nothing is stored', async () => {
|
||||
await seedConfig({});
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
|
||||
expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// A file tool call must show what it streamed even when it carries no
|
||||
// path: the path only names the file in the block title, so a call that
|
||||
// streams its body first (or a same-named tool with other arg names) used
|
||||
// to render an empty block.
|
||||
|
||||
import ChatMessageToolCallBlockEditFile from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte';
|
||||
import ChatMessageToolCallBlockWriteFile from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const WRITE_FILE_BODY = 'body streamed without a path';
|
||||
const EDIT_FILE_NEW_TEXT = 'replacement text';
|
||||
|
||||
function makeSection(toolName: BuiltInTool, toolArgs: string): AgenticSection {
|
||||
return {
|
||||
content: '',
|
||||
toolArgs,
|
||||
toolName,
|
||||
toolResult: undefined,
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
};
|
||||
}
|
||||
|
||||
describe('file tool calls render their body without a path', () => {
|
||||
it('write_file shows the content it streamed', async () => {
|
||||
render(ChatMessageToolCallBlockWriteFile, {
|
||||
isStreaming: true,
|
||||
open: true,
|
||||
section: makeSection(BuiltInTool.SERVER_WRITE_FILE, `{"content":"${WRITE_FILE_BODY}"`)
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(WRITE_FILE_BODY);
|
||||
});
|
||||
|
||||
it('edit_file shows the diff it streamed', async () => {
|
||||
render(ChatMessageToolCallBlockEditFile, {
|
||||
isStreaming: true,
|
||||
open: true,
|
||||
section: makeSection(
|
||||
BuiltInTool.SERVER_EDIT_FILE,
|
||||
`{"edits":[{"old_text":"stale text","new_text":"${EDIT_FILE_NEW_TEXT}"}]}`
|
||||
)
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(EDIT_FILE_NEW_TEXT);
|
||||
expect(document.body.textContent).toContain('stale text');
|
||||
});
|
||||
|
||||
it('write_file falls back to the raw args when no known content key is present', async () => {
|
||||
render(ChatMessageToolCallBlockWriteFile, {
|
||||
isStreaming: false,
|
||||
open: true,
|
||||
section: makeSection(BuiltInTool.SERVER_WRITE_FILE, '{"contents":"x"}')
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain('contents');
|
||||
});
|
||||
|
||||
it('write_file renders an empty code block for a finished empty write', async () => {
|
||||
// content of '' used to fall through to the raw-args fallback
|
||||
const section = makeSection(BuiltInTool.SERVER_WRITE_FILE, '{"content":""}');
|
||||
|
||||
section.toolResult = '{"result":"File written","bytes":0}';
|
||||
|
||||
render(ChatMessageToolCallBlockWriteFile, { isStreaming: false, open: true, section });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.querySelector('pre code.hljs')).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain('"content"');
|
||||
expect(document.body.textContent).toMatch(/0\s*bytes/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// Guards the two Settings > Display toggles that drive the default expansion of
|
||||
// a tool call block.
|
||||
//
|
||||
// "Show tool call in progress" mirrors "Show thought in progress": a call is
|
||||
// expanded by default only while it is in flight and folds back on its own once
|
||||
// the result lands. "Always show tool call content" is the one that keeps
|
||||
// finished calls open and wins on its own. A manual toggle always overrides the
|
||||
// default, in both directions.
|
||||
|
||||
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const TOOL_NAME = 'custom_tool';
|
||||
const TOOL_CALL_ID = 'call-1';
|
||||
// Both markers sit inside the collapsible body, which CollapsibleContentBlock
|
||||
// only mounts while open - so their presence is exactly "the block is expanded".
|
||||
const PENDING_BODY = 'Waiting for result...';
|
||||
const RESULT_BODY = 'marker-tool-output';
|
||||
const TRIGGER_SELECTOR = '[data-slot="collapsible-trigger"]';
|
||||
|
||||
function assistantMessage(): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: 'assistant-1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: '{"path":"a.txt"}', name: TOOL_NAME }, id: TOOL_CALL_ID }
|
||||
]),
|
||||
type: MessageType.TEXT
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultMessage(): DatabaseMessage {
|
||||
return {
|
||||
...assistantMessage(),
|
||||
content: RESULT_BODY,
|
||||
id: 'tool-1',
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: TOOL_CALL_ID
|
||||
};
|
||||
}
|
||||
|
||||
function setSettings({
|
||||
alwaysShow,
|
||||
inProgress
|
||||
}: {
|
||||
alwaysShow: boolean;
|
||||
inProgress: boolean;
|
||||
}): void {
|
||||
settingsStore.updateMultipleConfig({
|
||||
[SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: alwaysShow,
|
||||
[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: inProgress
|
||||
});
|
||||
}
|
||||
|
||||
function textOf(container: Element): string {
|
||||
return container.textContent ?? '';
|
||||
}
|
||||
|
||||
async function collapseBlock(screen: { container: Element }): Promise<void> {
|
||||
const trigger = screen.container.querySelector(TRIGGER_SELECTOR);
|
||||
|
||||
if (!(trigger instanceof HTMLButtonElement)) throw new Error('trigger not found');
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe('Show tool call in progress', () => {
|
||||
beforeEach(() => {
|
||||
setSettings({ alwaysShow: false, inProgress: false });
|
||||
});
|
||||
|
||||
it('expands a call that is still executing when the setting is on', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(container)).toContain(PENDING_BODY);
|
||||
});
|
||||
|
||||
it('keeps the call collapsed while executing when the setting is off', async () => {
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// The header is still there, only the body stays hidden.
|
||||
expect(textOf(container)).toContain(TOOL_NAME);
|
||||
expect(textOf(container)).not.toContain(PENDING_BODY);
|
||||
});
|
||||
|
||||
it('folds the call back when its result arrives', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// Open while it waits for the result.
|
||||
expect(textOf(screen.container)).toContain(PENDING_BODY);
|
||||
|
||||
// Same block, now finished: it must close on its own, no toggle given.
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: [toolResultMessage()]
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).toContain(TOOL_NAME);
|
||||
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
|
||||
expect(textOf(screen.container)).not.toContain(RESULT_BODY);
|
||||
});
|
||||
|
||||
it('keeps the manual choice once the result arrives', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// Collapsed by hand while running, so the result must not reopen it.
|
||||
await collapseBlock(screen);
|
||||
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: [toolResultMessage()]
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).toContain(TOOL_NAME);
|
||||
expect(textOf(screen.container)).not.toContain(RESULT_BODY);
|
||||
});
|
||||
|
||||
it('still expands a finished call when "always show" is on', async () => {
|
||||
setSettings({ alwaysShow: true, inProgress: false });
|
||||
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: [toolResultMessage()]
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(container)).toContain(RESULT_BODY);
|
||||
});
|
||||
|
||||
it('lets a manual collapse win over the in-progress default', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).toContain(PENDING_BODY);
|
||||
|
||||
await collapseBlock(screen);
|
||||
|
||||
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
|
||||
});
|
||||
|
||||
it('lets a manual expand win over the collapsed default', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
|
||||
|
||||
await collapseBlock(screen);
|
||||
|
||||
expect(textOf(screen.container)).toContain(PENDING_BODY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function mockProps(
|
||||
uiSettings: Record<string, string | number | boolean>,
|
||||
params: Record<string, number> = { temperature: 0.8 }
|
||||
) {
|
||||
Object.defineProperty(serverStore, 'props', {
|
||||
configurable: true,
|
||||
get: () =>
|
||||
({
|
||||
default_generation_settings: { params },
|
||||
ui_settings: uiSettings
|
||||
}) as unknown as typeof serverStore.props
|
||||
});
|
||||
}
|
||||
|
||||
describe('server ui_settings application semantics', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
it('applies the admin defaults once for a new user', () => {
|
||||
settingsStore.initialize();
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('applies the admin defaults when /props carries no sampling defaults', () => {
|
||||
settingsStore.initialize();
|
||||
// router mode: the main instance answers /props with empty params
|
||||
mockProps({ theme: 'dark' }, {});
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('never reapplies on later loads: the user config diverges freely', () => {
|
||||
settingsStore.initialize();
|
||||
settingsStore.updateConfig('theme', 'light');
|
||||
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||
|
||||
// simulated F5: config now exists in localStorage
|
||||
settingsStore.initialize();
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('light');
|
||||
expect(settingsStore.config.apiKey).toBe('sk-user-key');
|
||||
const stored = JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
|
||||
expect(stored.apiKey).toBe('sk-user-key');
|
||||
});
|
||||
|
||||
it('keeps a value the user sets before the baseline is reachable', () => {
|
||||
settingsStore.initialize();
|
||||
// the splash is the only way in when the server runs with --api-key,
|
||||
// so the first user write lands before the first successful /props
|
||||
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||
mockProps({ apiKey: 'admin-placeholder', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.apiKey).toBe('sk-user-key');
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('Reset to Default reapplies the full baseline, api key included', () => {
|
||||
settingsStore.initialize();
|
||||
settingsStore.updateConfig('theme', 'light');
|
||||
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.forceSyncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
expect(settingsStore.config.apiKey).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncable scope (spec section 4)', () => {
|
||||
it('sampling params keep their live server twin, ui settings carry none', async () => {
|
||||
const { ParameterSyncService } = await import('$lib/services/parameter-sync.service');
|
||||
|
||||
expect(ParameterSyncService.canSyncParameter('temperature')).toBe(true);
|
||||
expect(ParameterSyncService.canSyncParameter('samplers')).toBe(true);
|
||||
expect(ParameterSyncService.canSyncParameter('theme')).toBe(false);
|
||||
expect(ParameterSyncService.canSyncParameter('systemMessage')).toBe(false);
|
||||
expect(ParameterSyncService.canSyncParameter('apiKey')).toBe(false);
|
||||
expect(ParameterSyncService.canSyncParameter('customCss')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Pins the contract that makes streaming cheap: updateMessageAtIndex mutates the
|
||||
// existing message object instead of replacing it.
|
||||
//
|
||||
// Replacing it (`{ ...old, ...updates }`) changes the array slot, which
|
||||
// invalidates every consumer that merely walks the list - ChatMessages'
|
||||
// `displayMessages` rebuilds entries for EVERY message in the conversation. That
|
||||
// made per-token cost scale with conversation length (1.26ms at 1 prior message
|
||||
// -> 3.07ms at 40). Mutating in place keeps it flat.
|
||||
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeMessage(id: string): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text'
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
describe('conversationsStore.updateMessageAtIndex', () => {
|
||||
it('mutates in place, preserving object identity', () => {
|
||||
conversationsStore.activeMessages = [makeMessage('a'), makeMessage('b')];
|
||||
const before = conversationsStore.activeMessages[1];
|
||||
|
||||
conversationsStore.updateMessageAtIndex(1, { content: 'hello' });
|
||||
|
||||
expect(conversationsStore.activeMessages[1].content).toBe('hello');
|
||||
expect(conversationsStore.activeMessages[1]).toBe(before);
|
||||
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
|
||||
it('leaves other messages and unrelated fields untouched', () => {
|
||||
conversationsStore.activeMessages = [makeMessage('a'), makeMessage('b')];
|
||||
const untouched = conversationsStore.activeMessages[0];
|
||||
|
||||
conversationsStore.updateMessageAtIndex(1, { content: 'x', model: 'm1' });
|
||||
|
||||
expect(conversationsStore.activeMessages[0]).toBe(untouched);
|
||||
expect(conversationsStore.activeMessages[0].content).toBe('');
|
||||
expect(conversationsStore.activeMessages[1].model).toBe('m1');
|
||||
expect(conversationsStore.activeMessages[1].id).toBe('b');
|
||||
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
|
||||
it('is a no-op for an index of -1 or out of range', () => {
|
||||
conversationsStore.activeMessages = [makeMessage('a')];
|
||||
|
||||
expect(() => conversationsStore.updateMessageAtIndex(-1, { content: 'x' })).not.toThrow();
|
||||
expect(() => conversationsStore.updateMessageAtIndex(9, { content: 'x' })).not.toThrow();
|
||||
expect(conversationsStore.activeMessages[0].content).toBe('');
|
||||
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user