ui: Linting & Formatting scripts (#26819)
This commit is contained in:
@@ -10,15 +10,15 @@
|
||||
//
|
||||
// Run: npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
|
||||
|
||||
import { describe, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { perfState } from './components/agentic-perf-state.svelte';
|
||||
import AgenticPerfWrapper from './components/AgenticPerfWrapper.svelte';
|
||||
import ChatMessagesPerfWrapper from './components/ChatMessagesPerfWrapper.svelte';
|
||||
import { perfState } from './components/agentic-perf-state.svelte';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
// --- fixture construction -------------------------------------------------
|
||||
|
||||
@@ -40,24 +40,28 @@ interface FixtureOpts {
|
||||
}
|
||||
|
||||
const DEFAULTS: FixtureOpts = {
|
||||
priorToolCalls: 0,
|
||||
toolResultBytes: 1024,
|
||||
editFileEdits: 0,
|
||||
openCodeFence: false,
|
||||
paragraphEvery: 0
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -65,14 +69,14 @@ let msgSeq = 0;
|
||||
|
||||
function baseMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
|
||||
return {
|
||||
id: `m${msgSeq++}`,
|
||||
convId: 'perf-conv',
|
||||
type: 'text',
|
||||
timestamp: 0,
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
parent: null,
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'perf-conv',
|
||||
id: `m${msgSeq++}`,
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
@@ -86,48 +90,50 @@ function buildFixture(opts: FixtureOpts): {
|
||||
|
||||
for (let i = 0; i < opts.priorToolCalls; i++) {
|
||||
const id = `call_${i}`;
|
||||
|
||||
toolCalls.push({
|
||||
id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'exec_shell_command',
|
||||
arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` })
|
||||
}
|
||||
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,
|
||||
content: `${blob(opts.toolResultBytes, `t${i}`)}\n[exit code: 0]`
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < opts.editFileEdits; i++) {
|
||||
const id = `edit_${i}`;
|
||||
|
||||
toolCalls.push({
|
||||
id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'edit_file',
|
||||
arguments: JSON.stringify({
|
||||
path: `/src/file_${i}.ts`,
|
||||
edits: [{ old_text: diffLines(400, 'old'), new_text: diffLines(400, 'new') }]
|
||||
})
|
||||
}
|
||||
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,
|
||||
content: JSON.stringify({ result: 'ok', edits_applied: 1 })
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const message = baseMessage({
|
||||
toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined,
|
||||
content: ''
|
||||
content: '',
|
||||
toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined
|
||||
});
|
||||
|
||||
return { message, toolMessages };
|
||||
@@ -174,18 +180,21 @@ async function measure(label: string, partial: Partial<FixtureOpts>, tokens = 60
|
||||
await tick();
|
||||
|
||||
const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
|
||||
let accumulated = opts.openCodeFence ? '```notalanguage\n' : '';
|
||||
const durations: number[] = [];
|
||||
|
||||
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();
|
||||
@@ -211,10 +220,10 @@ async function measure(label: string, partial: Partial<FixtureOpts>, tokens = 60
|
||||
|
||||
results.push({
|
||||
label,
|
||||
tokens,
|
||||
max: durations[durations.length - 1],
|
||||
mean: total / durations.length,
|
||||
p95: durations[Math.floor(durations.length * 0.95)],
|
||||
max: durations[durations.length - 1],
|
||||
tokens,
|
||||
total,
|
||||
wall
|
||||
});
|
||||
@@ -223,7 +232,6 @@ async function measure(label: string, partial: Partial<FixtureOpts>, tokens = 60
|
||||
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 = [
|
||||
'',
|
||||
@@ -268,56 +276,63 @@ async function measureConversation(
|
||||
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({
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: `Message ${i}`,
|
||||
role: MessageRole.ASSISTANT,
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id,
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'exec_shell_command',
|
||||
arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` })
|
||||
}
|
||||
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,
|
||||
content: `${blob(1024, `r${i}`)}\n[exit code: 0]`
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
history.push(
|
||||
baseMessage({
|
||||
role: isAssistant ? MessageRole.ASSISTANT : MessageRole.USER,
|
||||
content: `Message ${i}: ${blob(512, `m${i}`)}`
|
||||
content: `Message ${i}: ${blob(512, `m${i}`)}`,
|
||||
role: isAssistant ? MessageRole.ASSISTANT : MessageRole.USER
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const streaming = baseMessage({ role: MessageRole.ASSISTANT, content: '' });
|
||||
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();
|
||||
|
||||
@@ -325,6 +340,7 @@ async function measureConversation(
|
||||
accumulated += CHUNK;
|
||||
|
||||
const t0 = performance.now();
|
||||
|
||||
// The real path: chat.svelte.ts -> conversations.svelte.ts.
|
||||
conversationsStore.updateMessageAtIndex(idx, { content: accumulated });
|
||||
await tick();
|
||||
@@ -346,10 +362,10 @@ async function measureConversation(
|
||||
|
||||
results.push({
|
||||
label,
|
||||
tokens,
|
||||
max: durations[durations.length - 1],
|
||||
mean: total / durations.length,
|
||||
p95: durations[Math.floor(durations.length * 0.95)],
|
||||
max: durations[durations.length - 1],
|
||||
tokens,
|
||||
total,
|
||||
wall
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { validateApiKey } from '$lib/utils/api-key-validation';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
|
||||
import { settingsStore } from '$lib/stores/settings.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;
|
||||
}
|
||||
@@ -35,6 +36,7 @@ describe('api key validation surfaces the splash', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
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,
|
||||
@@ -13,15 +13,12 @@ const EXIF5 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU
|
||||
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;
|
||||
|
||||
@@ -30,6 +27,7 @@ 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;
|
||||
@@ -40,9 +38,11 @@ function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
||||
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],
|
||||
@@ -50,6 +50,7 @@ async function quadrantColors(dataUrl: string): Promise<Rgb[]> {
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75]
|
||||
];
|
||||
|
||||
return points.map(([fx, fy]) => {
|
||||
const d = ctx.getImageData(
|
||||
Math.floor(canvas.width * fx),
|
||||
@@ -57,12 +58,14 @@ async function quadrantColors(dataUrl: string): Promise<Rgb[]> {
|
||||
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);
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
// serialization must fold those back into `\n` so the emitted value never
|
||||
// diverges from what is on screen.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.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('contenteditable not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -22,10 +24,13 @@ function fireInput(root: HTMLElement) {
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -33,10 +38,12 @@ function setCaret(node: Node, offset: number) {
|
||||
describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.textContent = 'second line';
|
||||
root.appendChild(div);
|
||||
fireInput(root);
|
||||
@@ -47,12 +54,15 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
|
||||
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { 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);
|
||||
@@ -64,9 +74,11 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
|
||||
it('serializes a <br> as a newline', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'here' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
root.appendChild(document.createElement('br'));
|
||||
root.appendChild(document.createTextNode('second line'));
|
||||
fireInput(root);
|
||||
@@ -77,9 +89,11 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
|
||||
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
root.appendChild(document.createElement('br'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
@@ -89,11 +103,14 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
|
||||
it('serializes one newline per empty-line <div><br></div>', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { 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);
|
||||
}
|
||||
@@ -105,10 +122,12 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
|
||||
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.replaceChildren(div);
|
||||
fireInput(root);
|
||||
@@ -120,12 +139,14 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
|
||||
it('maps the caret across block boundaries in both directions', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { 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);
|
||||
@@ -133,6 +154,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
expect(screen.component.getValue()).toBe('abc\ndef');
|
||||
|
||||
const divText = div.firstChild;
|
||||
|
||||
if (!divText) throw new Error('div text missing');
|
||||
|
||||
setCaret(divText, 2);
|
||||
@@ -140,6 +162,7 @@ describe('ChatFormContenteditable browser newline shapes', () => {
|
||||
|
||||
screen.component.setCaretOffset(6);
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(selection?.anchorNode).toBe(divText);
|
||||
expect(selection?.anchorOffset).toBe(2);
|
||||
|
||||
|
||||
@@ -3,16 +3,18 @@
|
||||
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
||||
// keyboard trap), matching the plain textarea.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormContenteditableHarness from './components/ChatFormContenteditableHarness.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('contenteditable not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -23,26 +25,32 @@ function type(root: HTMLElement, text: string, inputType = 'insertText') {
|
||||
|
||||
function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('ChatFormContenteditable undo/redo', () => {
|
||||
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { 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, { key: 'z', ctrlKey: true });
|
||||
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, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
|
||||
|
||||
await tick();
|
||||
expect(redoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
@@ -50,60 +58,68 @@ describe('ChatFormContenteditable undo/redo', () => {
|
||||
|
||||
it('redoes with Ctrl+Y as well', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { 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, { key: 'y', ctrlKey: true });
|
||||
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(ChatFormContenteditableHarness, { 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, { key: 'z', ctrlKey: true });
|
||||
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(ChatFormContenteditableHarness, { 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, { key: 'z', ctrlKey: true });
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd');
|
||||
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
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(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { key: 'z', ctrlKey: true });
|
||||
const event = keydown(root, { ctrlKey: true, key: 'z' });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
@@ -112,18 +128,20 @@ describe('ChatFormContenteditable undo/redo', () => {
|
||||
|
||||
it('abandons the redo branch after a fresh edit', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, 'd');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', ctrlKey: true });
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', ctrlKey: true, shiftKey: true });
|
||||
keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abce');
|
||||
});
|
||||
@@ -132,6 +150,7 @@ describe('ChatFormContenteditable undo/redo', () => {
|
||||
describe('ChatFormContenteditable Tab key', () => {
|
||||
it('does not trap Tab (focus can leave the editable)', async () => {
|
||||
const screen = render(ChatFormContenteditableHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
@@ -3,47 +3,58 @@
|
||||
// contributes its full `[name](file://...)` link) and pasting such
|
||||
// markdown re-renders the badges.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
|
||||
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.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('contenteditable 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, { clipboardData: data, bubbles: true, cancelable: true });
|
||||
return { event, data };
|
||||
|
||||
const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
|
||||
|
||||
return { data, event };
|
||||
}
|
||||
|
||||
describe('ChatFormContenteditable clipboard', () => {
|
||||
it('copy exposes the markdown source of the selection', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { event, data } = clipboardEvent('copy');
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
@@ -52,17 +63,22 @@ describe('ChatFormContenteditable clipboard', () => {
|
||||
|
||||
it('cut exposes the markdown source and removes the slice', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { 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 { event, data } = clipboardEvent('cut');
|
||||
const { data, event } = clipboardEvent('cut');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
@@ -73,9 +89,11 @@ describe('ChatFormContenteditable clipboard', () => {
|
||||
|
||||
it('paste of markdown mention links re-renders badges', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'hello ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
@@ -83,11 +101,13 @@ describe('ChatFormContenteditable clipboard', () => {
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -95,17 +115,19 @@ describe('ChatFormContenteditable clipboard', () => {
|
||||
|
||||
it('paste without mention links keeps the DOM untouched', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { 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();
|
||||
|
||||
@@ -119,10 +141,12 @@ describe('ChatFormContenteditable clipboard', () => {
|
||||
describe('ChatFormContenteditable code spans', () => {
|
||||
it('renders inline code from the initial value', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'run `npm test` now' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="inline"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
});
|
||||
@@ -130,10 +154,12 @@ describe('ChatFormContenteditable code spans', () => {
|
||||
it('renders a fenced code block with a language', async () => {
|
||||
const source = 'before\n```js\nconst a = 1;\n```\nafter';
|
||||
const { container } = render(ChatFormContenteditable, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="block"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
|
||||
});
|
||||
@@ -141,12 +167,15 @@ describe('ChatFormContenteditable code spans', () => {
|
||||
it('copy exposes the markdown source of a selection spanning code', async () => {
|
||||
const source = 'run `npm test` now';
|
||||
const { container } = render(ChatFormContenteditable, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { event, data } = clipboardEvent('copy');
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
@@ -155,9 +184,11 @@ describe('ChatFormContenteditable code spans', () => {
|
||||
|
||||
it('paste of a code span renders the styled element', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'run ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
@@ -165,11 +196,13 @@ describe('ChatFormContenteditable code spans', () => {
|
||||
});
|
||||
|
||||
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="inline"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
expect(root.textContent).toContain('now');
|
||||
@@ -178,10 +211,12 @@ describe('ChatFormContenteditable code spans', () => {
|
||||
it('highlights a fenced block content and stays byte-exact', async () => {
|
||||
const source = '```js\nconst a = 1;\n```';
|
||||
const { container } = render(ChatFormContenteditable, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="block"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
|
||||
expect(code!.textContent).toBe(source);
|
||||
@@ -189,9 +224,11 @@ describe('ChatFormContenteditable code spans', () => {
|
||||
|
||||
it('does not highlight inline code', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: 'run `const` now' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -202,7 +239,9 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
function blockIn(root: HTMLElement): HTMLElement {
|
||||
const el = root.querySelector(BLOCK_SELECTOR);
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
@@ -210,11 +249,15 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
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);
|
||||
@@ -223,21 +266,26 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
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(ChatFormContenteditable, { 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 { event, data } = clipboardEvent('copy');
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
|
||||
@@ -245,9 +293,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('escapes a trailing code block with ArrowDown and types after it', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
@@ -269,9 +319,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('escapes a leading code block with ArrowUp and types before it', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
@@ -292,9 +344,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('escapes a leading code block with ArrowLeft from its first character', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
@@ -305,9 +359,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('removes the transient leading hatch when the caret moves back into the block', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
@@ -323,15 +379,18 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('extends the selection out of the block with Shift+ArrowDown', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { 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);
|
||||
@@ -339,9 +398,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('line-separates text typed right after the closing fence', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
@@ -359,9 +420,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('does not double the newline when Shift+Enter already added one', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
@@ -377,9 +440,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
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
|
||||
@@ -392,10 +457,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: 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
|
||||
);
|
||||
@@ -406,9 +472,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
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
|
||||
@@ -421,10 +489,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { inputType: 'insertLineBreak', bubbles: 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
|
||||
);
|
||||
@@ -435,12 +504,15 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
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);
|
||||
});
|
||||
@@ -449,6 +521,7 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
@@ -462,9 +535,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('lets Backspace at the text start move into the block without a source fight', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
@@ -486,9 +561,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
|
||||
it('lets forward Delete eat the text after a block normally', async () => {
|
||||
const { container } = render(ChatFormContenteditable, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
@@ -507,9 +584,11 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\nhello'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
|
||||
});
|
||||
@@ -518,23 +597,28 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
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(ChatFormContenteditable, { 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);
|
||||
});
|
||||
@@ -543,11 +627,13 @@ describe('ChatFormContenteditable code block escape hatches', () => {
|
||||
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);
|
||||
});
|
||||
@@ -559,17 +645,20 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
it('adds a line instead of submitting on plain Enter inside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE,
|
||||
onKeydown
|
||||
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);
|
||||
});
|
||||
@@ -583,6 +672,7 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
|
||||
const code = root.querySelector('code[data-code-token="block"]');
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||
});
|
||||
@@ -590,17 +680,20 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: '```js\nconst a = 1;',
|
||||
onKeydown
|
||||
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);
|
||||
});
|
||||
@@ -615,12 +708,14 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE + '\nafter',
|
||||
onKeydown
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE + '\nafter'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
@@ -636,12 +731,14 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
it('forwards plain Enter on the trailing hatch line after a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE,
|
||||
onKeydown
|
||||
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) => {
|
||||
@@ -657,15 +754,18 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: BLOCK_SOURCE,
|
||||
onKeydown
|
||||
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);
|
||||
});
|
||||
@@ -673,7 +773,7 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ key: 'Enter', ctrlKey: true })
|
||||
expect.objectContaining({ ctrlKey: true, key: 'Enter' })
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
|
||||
});
|
||||
@@ -681,14 +781,17 @@ describe('ChatFormContenteditable Enter in code blocks', () => {
|
||||
it('forwards Enter inside an inline code span', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormContenteditable, {
|
||||
value: 'run `npm test` now',
|
||||
onKeydown
|
||||
onKeydown,
|
||||
value: 'run `npm test` now'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
const code = root.querySelector('code[data-code-token="inline"]')!;
|
||||
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(code.firstChild!, 3);
|
||||
range.collapse(true);
|
||||
|
||||
@@ -5,17 +5,19 @@
|
||||
// The textarea path is covered here end-to-end (the contenteditable
|
||||
// consumes the same case locally; see chat-form-contenteditable).
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import ChatFormTestWrapper from './components/ChatFormTestWrapper.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;
|
||||
}
|
||||
|
||||
@@ -27,9 +29,11 @@ describe('ChatForm Enter in code blocks', () => {
|
||||
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();
|
||||
@@ -44,9 +48,11 @@ describe('ChatForm Enter in code blocks', () => {
|
||||
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();
|
||||
@@ -63,9 +69,11 @@ describe('ChatForm Enter in code blocks', () => {
|
||||
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();
|
||||
@@ -81,9 +89,11 @@ describe('ChatForm Enter in code blocks', () => {
|
||||
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();
|
||||
@@ -97,9 +107,11 @@ describe('ChatForm Enter in code blocks', () => {
|
||||
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();
|
||||
|
||||
@@ -3,20 +3,19 @@
|
||||
// it, the picker still opens but explains why instead of firing searches
|
||||
// that would only fail with "Search failed".
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
import ChatFormMentionPicker from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormMentionPicker.svelte';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
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 = {
|
||||
type: 'function',
|
||||
function: { name: BuiltInTool.FILE_GLOB_SEARCH, description: '', parameters: {} }
|
||||
function: { description: '', name: BuiltInTool.FILE_GLOB_SEARCH, parameters: {} },
|
||||
type: 'function'
|
||||
};
|
||||
|
||||
const FILE_SEARCH_KEY = `builtin:${BuiltInTool.FILE_GLOB_SEARCH}`;
|
||||
|
||||
// The store keeps its builtin tool list private; tests inject it through
|
||||
@@ -28,9 +27,9 @@ function setBuiltinTools(defs: OpenAIToolDefinition[]) {
|
||||
function renderPicker() {
|
||||
return render(ChatFormMentionPicker, {
|
||||
isOpen: true,
|
||||
query: 'main',
|
||||
onClose: () => {},
|
||||
onSelect: () => {}
|
||||
onSelect: () => {},
|
||||
query: 'main'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,20 +3,22 @@
|
||||
// Typing `/model is broken` is prose until the command is picked - the
|
||||
// buffer must survive; only an actual selection consumes the token.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
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);
|
||||
@@ -26,6 +28,7 @@ describe('slash command dispatch', () => {
|
||||
|
||||
it('dispatches /model on explicit selection and consumes the token', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
@@ -33,7 +36,9 @@ describe('slash command dispatch', () => {
|
||||
|
||||
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();
|
||||
|
||||
@@ -44,16 +49,20 @@ describe('slash command dispatch', () => {
|
||||
|
||||
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();
|
||||
|
||||
@@ -64,16 +73,20 @@ describe('slash command dispatch', () => {
|
||||
|
||||
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();
|
||||
|
||||
@@ -84,10 +97,12 @@ describe('slash command dispatch', () => {
|
||||
|
||||
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');
|
||||
@@ -104,10 +119,12 @@ describe('slash command dispatch', () => {
|
||||
|
||||
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');
|
||||
|
||||
@@ -6,29 +6,30 @@
|
||||
// ~41ms/section/token for a 200KB result). These tests pin the fix: closed means
|
||||
// not rendered, and opening still mounts the body.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
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, { variant, open: false });
|
||||
const screen = render(CollapsibleLazyBodyHarness, { open: false, variant });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(MARKER);
|
||||
|
||||
await screen.rerender({ variant, open: true });
|
||||
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({ variant, open: false });
|
||||
await screen.rerender({ open: false, variant });
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(MARKER);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import ChatFormContenteditable from '$lib/components/app/chat/ChatForm/ChatFormContenteditable.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
|
||||
@@ -9,25 +9,25 @@
|
||||
const calls: string[] = [];
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
getValue: () => value,
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
calls.push(`setValue:${v}`);
|
||||
},
|
||||
getCaretOffset: () => caretOffset,
|
||||
setCaretOffset: (o) => {
|
||||
caretOffset = o;
|
||||
},
|
||||
focusInput: () => {},
|
||||
getShowModelSelector: () => true,
|
||||
hasPrompts: () => true,
|
||||
hasCwdTools: () => true,
|
||||
getCaretOffset: () => caretOffset,
|
||||
getCwd: () => null,
|
||||
getPickersRef: () => undefined,
|
||||
getServerHome: () => null,
|
||||
getShowModelSelector: () => true,
|
||||
getValue: () => value,
|
||||
hasCwdTools: () => true,
|
||||
hasPrompts: () => true,
|
||||
openModelSelector: () => {
|
||||
calls.push('openModelSelector');
|
||||
},
|
||||
getPickersRef: () => undefined
|
||||
setCaretOffset: (o) => {
|
||||
caretOffset = o;
|
||||
},
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
calls.push(`setValue:${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate the user typing: update the buffer and run the input flow.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
let { onSubmit }: { onSubmit?: () => void } = $props();
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// 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 * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { variant, open }: Props = $props();
|
||||
let { open, variant }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === 'content'}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
headers?: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import Page from '../../../src/routes/(chat)/+page.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
|
||||
@@ -11,7 +11,7 @@ export const perfState = $state<{
|
||||
toolMessages: DatabaseMessage[];
|
||||
isStreaming: boolean;
|
||||
}>({
|
||||
isStreaming: true,
|
||||
message: null,
|
||||
toolMessages: [],
|
||||
isStreaming: true
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
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: { id, currNode: `${id}-msg`, lastModified: 0, name: `Chat ${id}` },
|
||||
conv: { currNode: `${id}-msg`, id, lastModified: 0, name: `Chat ${id}` },
|
||||
messages: [
|
||||
{
|
||||
id: `${id}-msg`,
|
||||
convId: id,
|
||||
type: MessageType.TEXT,
|
||||
timestamp: 0,
|
||||
role: MessageRole.USER,
|
||||
children: [],
|
||||
content: `hello from ${id}`,
|
||||
convId: id,
|
||||
id: `${id}-msg`,
|
||||
parent: null,
|
||||
children: []
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
}
|
||||
]
|
||||
} as unknown as ExportedConversation;
|
||||
@@ -23,6 +23,7 @@ function makeSession(id: string): ExportedConversation {
|
||||
|
||||
afterEach(async () => {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
|
||||
await DatabaseService.bulkDeleteConversations(conversations.map((conv) => conv.id));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { McpServerForm } from '$lib/components/app/mcp';
|
||||
import { mcpStore } from '$lib/stores/mcp.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('mcp server display name', () => {
|
||||
beforeEach(() => {
|
||||
@@ -11,44 +11,48 @@ describe('mcp server display name', () => {
|
||||
|
||||
it('custom display name wins over the url fallback', () => {
|
||||
const server = mcpStore.addServer({
|
||||
displayName: 'My Tools',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/a',
|
||||
displayName: 'My Tools'
|
||||
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',
|
||||
displayName: 'GitHub'
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const b = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/b',
|
||||
displayName: 'GitHub'
|
||||
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',
|
||||
displayName: 'GitHub'
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const b = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/b',
|
||||
displayName: 'GitHub'
|
||||
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');
|
||||
@@ -56,16 +60,17 @@ describe('mcp server display name', () => {
|
||||
|
||||
it('the form exposes an editable display name field', async () => {
|
||||
let captured = '';
|
||||
|
||||
const screen = await render(McpServerForm, {
|
||||
url: 'https://mcp.example.com/a',
|
||||
headers: '',
|
||||
name: '',
|
||||
onUrlChange: () => {},
|
||||
onHeadersChange: () => {},
|
||||
onNameChange: (v: string) => (captured = v)
|
||||
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');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
|
||||
|
||||
const AUTHORIZATION_HEADER = 'Authorization';
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
@@ -48,9 +48,11 @@ describe('McpServerForm - Authorization / bearer UI', () => {
|
||||
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);
|
||||
@@ -58,10 +60,9 @@ describe('McpServerForm - Authorization / bearer UI', () => {
|
||||
|
||||
it('pre-existing Bearer header pre-fills the bearer input with the token stripped', async () => {
|
||||
const existing = JSON.stringify({
|
||||
'X-Trace-Id': 'abc',
|
||||
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`
|
||||
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`,
|
||||
'X-Trace-Id': 'abc'
|
||||
});
|
||||
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await expect.element(bearerInput(screen)).toBeVisible();
|
||||
@@ -70,24 +71,24 @@ describe('McpServerForm - Authorization / bearer UI', () => {
|
||||
|
||||
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);
|
||||
@@ -126,8 +127,8 @@ describe('McpServerForm - Authorization / bearer UI', () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
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 () => {
|
||||
|
||||
@@ -3,19 +3,21 @@
|
||||
// scrollIntoView on the initial mount, before the popover was positioned,
|
||||
// so the browser scrolled every scrollable ancestor to reveal the row.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import { tick } from 'svelte';
|
||||
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();
|
||||
@@ -24,6 +26,7 @@ describe('ChatFormPickerList mount scroll', () => {
|
||||
await tick();
|
||||
|
||||
const after = document.documentElement.scrollTop;
|
||||
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { SandboxService } from '$lib/services/sandbox.service';
|
||||
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, {
|
||||
@@ -11,6 +11,7 @@ const run = (code: string, timeoutMs?: number) =>
|
||||
describe('sandbox service', () => {
|
||||
beforeEach(async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
|
||||
settingsStore.config = {
|
||||
...settingsStore.config,
|
||||
symbolicMathEnabled: true
|
||||
@@ -19,18 +20,21 @@ describe('sandbox service', () => {
|
||||
|
||||
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');
|
||||
});
|
||||
@@ -39,6 +43,7 @@ describe('sandbox service', () => {
|
||||
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');
|
||||
});
|
||||
@@ -47,12 +52,14 @@ describe('sandbox service', () => {
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// winning when the legacy keys disagree. Legacy keys are removed from the
|
||||
// persisted config so they do not stay orphaned in localStorage.
|
||||
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { settingsStore, config } from '$lib/stores/settings.svelte';
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function seedConfig(stored: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
@@ -39,7 +39,7 @@ describe('renderContentAsRawText migration', () => {
|
||||
});
|
||||
|
||||
it('lets any explicit raw-text preference win when the legacy keys disagree', () => {
|
||||
seedConfig({ renderUserContentAsMarkdown: true, renderThinkingAsMarkdown: false });
|
||||
seedConfig({ renderThinkingAsMarkdown: false, renderUserContentAsMarkdown: true });
|
||||
expect(config().renderContentAsRawText).toBe(true);
|
||||
});
|
||||
|
||||
@@ -53,6 +53,7 @@ describe('renderContentAsRawText migration', () => {
|
||||
expect(config().renderContentAsRawText).toBe(false);
|
||||
|
||||
const stored = persisted();
|
||||
|
||||
expect(stored.renderUserContentAsMarkdown).toBeUndefined();
|
||||
expect(stored.renderThinkingAsMarkdown).toBeUndefined();
|
||||
expect(stored.renderUserContentAsRawText).toBeUndefined();
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { settingsStore, config } from '$lib/stores/settings.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
||||
import { SETTING_CONFIG_DEFAULT } from '$lib/constants/settings-registry';
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
|
||||
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import type { SettingsConfigType } from '$lib/types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
type Primitive = string | number | boolean;
|
||||
|
||||
@@ -14,13 +14,17 @@ const KEYS = Object.keys(SETTING_CONFIG_DEFAULT).filter(
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
@@ -37,7 +41,6 @@ function mockProps(uiSettings: Record<string, Primitive>) {
|
||||
|
||||
const setUser = (key: string, value: Primitive) =>
|
||||
settingsStore.updateConfig(key as keyof SettingsConfigType, value as never);
|
||||
|
||||
const current = (key: string) => (config() as Record<string, unknown>)[key];
|
||||
|
||||
describe('registry-wide invariants', () => {
|
||||
@@ -48,6 +51,7 @@ describe('registry-wide invariants', () => {
|
||||
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]);
|
||||
@@ -56,6 +60,7 @@ describe('registry-wide invariants', () => {
|
||||
// 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);
|
||||
@@ -70,6 +75,7 @@ describe('registry-wide invariants', () => {
|
||||
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);
|
||||
@@ -78,6 +84,7 @@ describe('registry-wide invariants', () => {
|
||||
|
||||
for (const key of KEYS) {
|
||||
if (ParameterSyncService.canSyncParameter(key)) continue;
|
||||
|
||||
expect(current(key), key).toBe(baseline[key]);
|
||||
}
|
||||
});
|
||||
@@ -87,6 +94,7 @@ describe('registry-wide invariants', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { settingsStore, config } from '$lib/stores/settings.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants/storage';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { config, settingsStore } from '$lib/stores/settings.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function mockProps(uiSettings: Record<string, string | number | boolean>) {
|
||||
Object.defineProperty(serverStore, 'props', {
|
||||
@@ -21,7 +21,7 @@ describe('server ui_settings application semantics', () => {
|
||||
|
||||
it('applies the admin defaults once for a new user', () => {
|
||||
settingsStore.initialize();
|
||||
mockProps({ theme: 'dark', apiKey: '' });
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
@@ -35,7 +35,7 @@ describe('server ui_settings application semantics', () => {
|
||||
|
||||
// simulated F5: config now exists in localStorage
|
||||
settingsStore.initialize();
|
||||
mockProps({ theme: 'dark', apiKey: '' });
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
settingsStore.syncWithServerDefaults();
|
||||
@@ -43,6 +43,7 @@ describe('server ui_settings application semantics', () => {
|
||||
expect(config().theme).toBe('light');
|
||||
expect(config().apiKey).toBe('sk-user-key');
|
||||
const stored = JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
|
||||
expect(stored.apiKey).toBe('sk-user-key');
|
||||
});
|
||||
|
||||
@@ -50,7 +51,7 @@ describe('server ui_settings application semantics', () => {
|
||||
settingsStore.initialize();
|
||||
settingsStore.updateConfig('theme', 'light');
|
||||
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||
mockProps({ theme: 'dark', apiKey: '' });
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.forceSyncWithServerDefaults();
|
||||
|
||||
|
||||
@@ -7,21 +7,21 @@
|
||||
// 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 { describe, it, expect } from 'vitest';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { conversationsStore } from '$lib/stores/conversations.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeMessage(id: string): DatabaseMessage {
|
||||
return {
|
||||
id,
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
timestamp: 0,
|
||||
role: MessageRole.ASSISTANT,
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent: null,
|
||||
children: []
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text'
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ test.describe('PWA Service Worker', () => {
|
||||
setTimeout(() => reject(new Error('Service worker registration failed: timeout')), 15000)
|
||||
)
|
||||
]);
|
||||
|
||||
// @ts-expect-error registration is of type unknown
|
||||
return registration.active?.scriptURL;
|
||||
});
|
||||
@@ -30,6 +31,7 @@ test.describe('PWA Service Worker', () => {
|
||||
|
||||
const swActive = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
return reg.active?.scriptURL ?? null;
|
||||
});
|
||||
|
||||
@@ -67,6 +69,7 @@ test.describe('PWA Service Worker', () => {
|
||||
await offlinePage.goto('/');
|
||||
|
||||
const bodyText = await offlinePage.locator('body').textContent();
|
||||
|
||||
expect(bodyText).toBeTruthy();
|
||||
|
||||
await context.close();
|
||||
@@ -74,9 +77,11 @@ test.describe('PWA Service Worker', () => {
|
||||
|
||||
test('version.json is accessible and contains version', async ({ page }) => {
|
||||
const versionResponse = await page.request.get('/_app/version.json');
|
||||
|
||||
expect(versionResponse.ok()).toBeTruthy();
|
||||
|
||||
const versionData = await versionResponse.json();
|
||||
|
||||
expect(versionData).toHaveProperty('version');
|
||||
expect(typeof versionData.version).toBe('string');
|
||||
expect(versionData.version.length).toBeGreaterThan(0);
|
||||
@@ -84,9 +89,11 @@ test.describe('PWA Service Worker', () => {
|
||||
|
||||
test('manifest.webmanifest is accessible and valid', async ({ page }) => {
|
||||
const response = await page.request.get('/manifest.webmanifest');
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const manifest = await response.json();
|
||||
|
||||
expect(manifest).toHaveProperty('name', 'llama-ui');
|
||||
expect(manifest).toHaveProperty('short_name', 'llama-ui');
|
||||
expect(manifest).toHaveProperty('start_url', './');
|
||||
@@ -97,6 +104,7 @@ test.describe('PWA Service Worker', () => {
|
||||
|
||||
test('index.html contains content-hashed bundle references', async ({ page }) => {
|
||||
const response = await page.request.get('/');
|
||||
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
@@ -3,86 +3,86 @@
|
||||
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatMessage',
|
||||
component: ChatMessage,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
},
|
||||
title: 'Components/ChatScreen/ChatMessage'
|
||||
});
|
||||
|
||||
// Mock messages for different scenarios
|
||||
const userMessage: DatabaseMessage = {
|
||||
id: '1',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 5,
|
||||
role: 'user',
|
||||
children: [],
|
||||
content: 'What is the meaning of life, the universe, and everything?',
|
||||
convId: 'conv-1',
|
||||
id: '1',
|
||||
parent: '',
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
children: []
|
||||
timestamp: Date.now() - 1000 * 60 * 5,
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
const assistantMessage: DatabaseMessage = {
|
||||
id: '2',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 3,
|
||||
role: 'assistant',
|
||||
children: [],
|
||||
content:
|
||||
'The answer to the ultimate question of life, the universe, and everything is **42**.\n\nThis comes from Douglas Adams\' "The Hitchhiker\'s Guide to the Galaxy," where a supercomputer named Deep Thought calculated this answer over 7.5 million years. However, the question itself was never properly formulated, which is why the answer seems meaningless without context.',
|
||||
convId: 'conv-1',
|
||||
id: '2',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
children: []
|
||||
timestamp: Date.now() - 1000 * 60 * 3,
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
const assistantWithReasoning: DatabaseMessage = {
|
||||
id: '3',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 2,
|
||||
role: 'assistant',
|
||||
children: [],
|
||||
content: "Here's the concise answer, now that I've thought it through carefully for you.",
|
||||
convId: 'conv-1',
|
||||
id: '3',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking:
|
||||
"Let's consider the user's question step by step:\\n\\n1. Identify the core problem\\n2. Evaluate relevant information\\n3. Formulate a clear answer\\n\\nFollowing this process ensures the final response stays focused and accurate.",
|
||||
children: []
|
||||
timestamp: Date.now() - 1000 * 60 * 2,
|
||||
type: 'message'
|
||||
};
|
||||
const rawOutputMessage: DatabaseMessage = {
|
||||
id: '6',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60,
|
||||
role: 'assistant',
|
||||
children: [],
|
||||
content:
|
||||
'<|channel|>analysis<|message|>User greeted me. Initiating overcomplicated analysis: Is this a trap? No, just a normal hello. Respond calmly, act like a helpful assistant, and do not start explaining quantum physics again. Confidence 0.73. Engaging socially acceptable greeting protocol...<|end|>Hello there! How can I help you today?',
|
||||
convId: 'conv-1',
|
||||
id: '6',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
children: []
|
||||
timestamp: Date.now() - 1000 * 60,
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
let processingMessage = $state({
|
||||
id: '4',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: 0, // No timestamp = processing
|
||||
role: 'assistant',
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: '4',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
children: []
|
||||
timestamp: 0, // No timestamp = processing
|
||||
type: 'message'
|
||||
});
|
||||
|
||||
let streamingMessage = $state({
|
||||
id: '5',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: 0, // No timestamp = streaming
|
||||
role: 'assistant',
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: '5',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
children: []
|
||||
timestamp: 0, // No timestamp = streaming
|
||||
type: 'message'
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -93,6 +93,7 @@
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
@@ -105,6 +106,7 @@
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
@@ -117,6 +119,7 @@
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
@@ -129,6 +132,7 @@
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', true);
|
||||
}}
|
||||
/>
|
||||
@@ -141,16 +145,18 @@
|
||||
asChild
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
// Phase 1: Stream reasoning content in chunks
|
||||
let reasoningText =
|
||||
'I need to think about this carefully. Let me break down the problem:\n\n1. The user is asking for help with something complex\n2. I should provide a thorough and helpful response\n3. I need to consider multiple approaches\n4. The best solution would be to explain step by step\n\nThis approach will ensure clarity and understanding.';
|
||||
|
||||
let reasoningChunk = 'I';
|
||||
let i = 0;
|
||||
|
||||
while (i < reasoningText.length) {
|
||||
const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters
|
||||
const chunk = reasoningText.slice(i, i + chunkSize);
|
||||
|
||||
reasoningChunk += chunk;
|
||||
|
||||
// Update the reactive state directly
|
||||
@@ -164,11 +170,13 @@
|
||||
"Based on my analysis, here's the solution:\n\n**Step 1:** First, we need to understand the requirements clearly.\n\n**Step 2:** Then we can implement the solution systematically.\n\n**Step 3:** Finally, we test and validate the results.\n\nThis approach ensures we cover all aspects of the problem effectively.";
|
||||
|
||||
let contentChunk = '';
|
||||
|
||||
i = 0;
|
||||
|
||||
while (i < regularText.length) {
|
||||
const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters
|
||||
const chunk = regularText.slice(i, i + chunkSize);
|
||||
|
||||
contentChunk += chunk;
|
||||
|
||||
// Update the reactive state directly
|
||||
@@ -193,6 +201,7 @@
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
// Import the chat store to simulate loading state
|
||||
const { chatStore } = await import('$lib/stores/chat.svelte');
|
||||
|
||||
@@ -1,42 +1,42 @@
|
||||
<script module lang="ts">
|
||||
import jpgAsset from './fixtures/assets/1.jpg?url';
|
||||
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
||||
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
import jpgAsset from './fixtures/assets/1.jpg?url';
|
||||
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
||||
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatScreenForm',
|
||||
component: ChatScreenForm,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
},
|
||||
title: 'Components/ChatScreen/ChatScreenForm'
|
||||
});
|
||||
|
||||
let fileAttachments = $state([
|
||||
{
|
||||
file: new File([''], '1.jpg', { type: 'image/jpeg' }),
|
||||
id: '1',
|
||||
name: '1.jpg',
|
||||
type: 'image/jpeg',
|
||||
size: 44891,
|
||||
preview: jpgAsset,
|
||||
file: new File([''], '1.jpg', { type: 'image/jpeg' })
|
||||
size: 44891,
|
||||
type: 'image/jpeg'
|
||||
},
|
||||
{
|
||||
file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' }),
|
||||
id: '2',
|
||||
name: 'hf-logo.svg',
|
||||
type: 'image/svg+xml',
|
||||
size: 1234,
|
||||
preview: svgAsset,
|
||||
file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
|
||||
size: 1234,
|
||||
type: 'image/svg+xml'
|
||||
},
|
||||
{
|
||||
file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' }),
|
||||
id: '3',
|
||||
name: 'example.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 351048,
|
||||
file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
|
||||
type: 'application/pdf'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
@@ -62,6 +62,7 @@
|
||||
await expect(textarea).toHaveValue(text);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
|
||||
await expect(fileInput).not.toHaveAttribute('accept');
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1,59 +1,60 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { expect } from 'storybook/test';
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
import { AI_TUTORIAL_MD } from './fixtures/ai-tutorial.js';
|
||||
import { API_DOCS_MD } from './fixtures/api-docs.js';
|
||||
import { BLOG_POST_MD } from './fixtures/blog-post.js';
|
||||
import { DATA_ANALYSIS_MD } from './fixtures/data-analysis.js';
|
||||
import { README_MD } from './fixtures/readme.js';
|
||||
import { MATH_FORMULAS_MD } from './fixtures/math-formulas.js';
|
||||
import { EMPTY_MD } from './fixtures/empty.js';
|
||||
import { MATH_FORMULAS_MD } from './fixtures/math-formulas.js';
|
||||
import { README_MD } from './fixtures/readme.js';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/MarkdownContent',
|
||||
component: MarkdownContent,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
},
|
||||
title: 'Components/MarkdownContent'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Empty" args={{ content: EMPTY_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }} />
|
||||
<Story name="Empty" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: EMPTY_MD }} />
|
||||
|
||||
<Story
|
||||
name="AI Tutorial"
|
||||
args={{ content: AI_TUTORIAL_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: AI_TUTORIAL_MD }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="API Documentation"
|
||||
args={{ content: API_DOCS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: API_DOCS_MD }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Technical Blog"
|
||||
args={{ content: BLOG_POST_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: BLOG_POST_MD }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Data Analysis"
|
||||
args={{ content: DATA_ANALYSIS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: DATA_ANALYSIS_MD }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="README file"
|
||||
args={{ content: README_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: README_MD }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Math Formulas"
|
||||
args={{ content: MATH_FORMULAS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: MATH_FORMULAS_MD }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="URL Links"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
content: `# URL Links Test
|
||||
|
||||
Here are some example URLs that should open in new tabs:
|
||||
@@ -65,11 +66,11 @@ Here are some example URLs that should open in new tabs:
|
||||
|
||||
You can also test inline links like https://example.com or https://docs.python.org.
|
||||
|
||||
All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
|
||||
All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`
|
||||
}}
|
||||
play={async (context) => {
|
||||
const { canvasElement } = context;
|
||||
|
||||
// Wait for component to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
@@ -97,22 +98,26 @@ All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attr
|
||||
const hugginFaceLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://huggingface.co'
|
||||
);
|
||||
|
||||
expect(hugginFaceLink).toBeTruthy();
|
||||
expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage');
|
||||
|
||||
const githubLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp'
|
||||
);
|
||||
|
||||
expect(githubLink).toBeTruthy();
|
||||
expect(githubLink?.textContent).toBe('GitHub Repository');
|
||||
|
||||
const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com');
|
||||
|
||||
expect(openaiLink).toBeTruthy();
|
||||
expect(openaiLink?.textContent).toBe('OpenAI Website');
|
||||
|
||||
const googleLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://www.google.com'
|
||||
);
|
||||
|
||||
expect(googleLink).toBeTruthy();
|
||||
expect(googleLink?.textContent).toBe('Google Search');
|
||||
|
||||
@@ -120,11 +125,13 @@ All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attr
|
||||
const exampleLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://example.com'
|
||||
);
|
||||
|
||||
expect(exampleLink).toBeTruthy();
|
||||
|
||||
const pythonDocsLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://docs.python.org'
|
||||
);
|
||||
|
||||
expect(pythonDocsLink).toBeTruthy();
|
||||
|
||||
console.log(`✅ URL Links test passed - Found ${links.length} links with proper attributes`);
|
||||
|
||||
@@ -3,39 +3,39 @@
|
||||
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
|
||||
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ModelsSelector',
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
},
|
||||
title: 'Components/ModelsSelector'
|
||||
});
|
||||
|
||||
const mockModel = (id: string, name: string, orgName?: string, tags?: string[]): ModelOption => ({
|
||||
id,
|
||||
name,
|
||||
model: orgName ? `${orgName}/${name}` : name,
|
||||
capabilities: [],
|
||||
id,
|
||||
model: orgName ? `${orgName}/${name}` : name,
|
||||
name,
|
||||
parsedId: {
|
||||
raw: orgName ? `${orgName}/${name}` : name,
|
||||
orgName: orgName ?? null,
|
||||
modelName: name,
|
||||
params: null,
|
||||
activatedParams: null,
|
||||
modelName: name,
|
||||
orgName: orgName ?? null,
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: orgName ? `${orgName}/${name}` : name,
|
||||
tags: tags ?? []
|
||||
},
|
||||
tags
|
||||
});
|
||||
|
||||
const mockRouterEntry = (modelName: string, status: ServerModelStatus): ApiModelDataEntry => ({
|
||||
created: Date.now(),
|
||||
id: modelName,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
created: Date.now(),
|
||||
in_cache: true,
|
||||
path: `/models/${modelName}`,
|
||||
status: { value: status }
|
||||
});
|
||||
@@ -60,57 +60,58 @@
|
||||
mockModelsStore();
|
||||
|
||||
const loadedModels: ModelItem[] = [
|
||||
{ option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta'), flatIndex: 0 },
|
||||
{ option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai'), flatIndex: 1 }
|
||||
{ flatIndex: 0, option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta') },
|
||||
{ flatIndex: 1, option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai') }
|
||||
];
|
||||
|
||||
const favoriteModels: ModelItem[] = [
|
||||
{ option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen'), flatIndex: 2 },
|
||||
{ option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta'), flatIndex: 3 }
|
||||
{ flatIndex: 2, option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen') },
|
||||
{ flatIndex: 3, option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta') }
|
||||
];
|
||||
|
||||
const availableModels: ModelItem[] = [
|
||||
{
|
||||
option: mockModel('deepseek-coder-6.7b', 'DeepSeek-Coder-6.7B', 'deepseek', ['coding']),
|
||||
flatIndex: 4
|
||||
flatIndex: 4,
|
||||
option: mockModel('deepseek-coder-6.7b', 'DeepSeek-Coder-6.7B', 'deepseek', ['coding'])
|
||||
},
|
||||
{ option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google'), flatIndex: 5 },
|
||||
{ option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft'), flatIndex: 6 },
|
||||
{ option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']), flatIndex: 7 },
|
||||
{ option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel'), flatIndex: 8 }
|
||||
{ flatIndex: 5, option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google') },
|
||||
{ flatIndex: 6, option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft') },
|
||||
{ flatIndex: 7, option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']) },
|
||||
{ flatIndex: 8, option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel') }
|
||||
];
|
||||
|
||||
const groupedOptions: GroupedModelOptions = {
|
||||
loaded: loadedModels,
|
||||
favorites: favoriteModels,
|
||||
available: [
|
||||
{
|
||||
orgName: 'deepseek',
|
||||
items: [availableModels[0]]
|
||||
items: [availableModels[0]],
|
||||
orgName: 'deepseek'
|
||||
},
|
||||
{
|
||||
orgName: 'google',
|
||||
items: [availableModels[1]]
|
||||
items: [availableModels[1]],
|
||||
orgName: 'google'
|
||||
},
|
||||
{
|
||||
orgName: 'microsoft',
|
||||
items: [availableModels[2]]
|
||||
items: [availableModels[2]],
|
||||
orgName: 'microsoft'
|
||||
},
|
||||
{
|
||||
orgName: 'codellama',
|
||||
items: [availableModels[3]]
|
||||
items: [availableModels[3]],
|
||||
orgName: 'codellama'
|
||||
},
|
||||
{
|
||||
orgName: 'intel',
|
||||
items: [availableModels[4]]
|
||||
items: [availableModels[4]],
|
||||
orgName: 'intel'
|
||||
}
|
||||
]
|
||||
],
|
||||
favorites: favoriteModels,
|
||||
loaded: loadedModels
|
||||
};
|
||||
|
||||
function handleSelect(modelId: string) {
|
||||
const opt = [...loadedModels, ...favoriteModels, ...availableModels].find(
|
||||
(m) => m.option.id === modelId
|
||||
);
|
||||
|
||||
if (opt) {
|
||||
selectedModel = opt.option.model;
|
||||
activeId = modelId;
|
||||
@@ -134,9 +135,9 @@
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
groups={{
|
||||
loaded: [loadedModels[0]],
|
||||
available: [],
|
||||
favorites: [],
|
||||
available: []
|
||||
loaded: [loadedModels[0]]
|
||||
}}
|
||||
currentModel={null}
|
||||
activeId={null}
|
||||
@@ -150,9 +151,9 @@
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
groups={{
|
||||
loaded: [],
|
||||
available: [],
|
||||
favorites: favoriteModels,
|
||||
available: []
|
||||
loaded: []
|
||||
}}
|
||||
currentModel={null}
|
||||
activeId={null}
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/PwaRefreshAlert',
|
||||
component: PwaRefreshAlert,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
},
|
||||
title: 'Components/PwaRefreshAlert'
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -17,12 +17,15 @@
|
||||
args={{ needRefresh: true, updateServiceWorker: () => console.log('reload') }}
|
||||
play={async ({ canvas }) => {
|
||||
const title = canvas.getByText('Update available');
|
||||
|
||||
await expect(title).toBeInTheDocument();
|
||||
|
||||
const description = canvas.getByText(/A new version is available/);
|
||||
|
||||
await expect(description).toBeInTheDocument();
|
||||
|
||||
const button = canvas.getByRole('button', { name: 'Reload' });
|
||||
|
||||
await expect(button).toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
@@ -32,6 +35,7 @@
|
||||
args={{ needRefresh: false, updateServiceWorker: () => console.log('reload') }}
|
||||
play={async ({ canvas }) => {
|
||||
const title = canvas.queryByText('Update available');
|
||||
|
||||
await expect(title).not.toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
@@ -44,14 +48,17 @@
|
||||
}}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const button = canvas.getByRole('button', { name: 'Reload' });
|
||||
|
||||
await expect(button).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
const title = canvas.queryByText('Update available');
|
||||
|
||||
await expect(title).not.toBeInTheDocument();
|
||||
|
||||
const reloadBtn = canvas.queryByRole('button', { name: 'Reload' });
|
||||
|
||||
await expect(reloadBtn).not.toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
import { screen } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/SidebarNavigation',
|
||||
component: SidebarNavigation,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
},
|
||||
title: 'Components/SidebarNavigation'
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -17,34 +17,34 @@
|
||||
// Mock conversations for the sidebar
|
||||
const mockConversations: DatabaseConversation[] = [
|
||||
{
|
||||
currNode: 'msg-1',
|
||||
id: 'conv-1',
|
||||
name: 'Getting Started with AI',
|
||||
lastModified: Date.now() - 1000 * 60 * 5, // 5 minutes ago
|
||||
currNode: 'msg-1'
|
||||
name: 'Getting Started with AI'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-2',
|
||||
id: 'conv-2',
|
||||
name: 'Python Programming Help',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
|
||||
currNode: 'msg-2'
|
||||
name: 'Python Programming Help'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-3',
|
||||
id: 'conv-3',
|
||||
name: 'Creative Writing Ideas',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
|
||||
currNode: 'msg-3'
|
||||
name: 'Creative Writing Ideas'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-4',
|
||||
id: 'conv-4',
|
||||
name: 'This is a very long conversation title that should be truncated properly when displayed',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 3, // 3 days ago
|
||||
currNode: 'msg-4'
|
||||
name: 'This is a very long conversation title that should be truncated properly when displayed'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-5',
|
||||
id: 'conv-5',
|
||||
name: 'Math Problem Solving',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 7, // 1 week ago
|
||||
currNode: 'msg-5'
|
||||
name: 'Math Problem Solving'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
@@ -81,8 +81,10 @@
|
||||
|
||||
// Expand sidebar first, then click Search in the expanded button list
|
||||
const logoTrigger = screen.getByRole('button', { name: /expand navigation/i });
|
||||
|
||||
await userEvent.click(logoTrigger);
|
||||
const searchTrigger = screen.getByText('Search');
|
||||
|
||||
userEvent.click(searchTrigger);
|
||||
}}
|
||||
>
|
||||
@@ -97,6 +99,7 @@
|
||||
play={async () => {
|
||||
// Mock empty conversations store
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
conversationsStore.conversations = [];
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { Copy } from '@lucide/svelte';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ActionIcon/Accessibility',
|
||||
component: ActionIcon,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ActionIcon/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,30 +4,30 @@
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatMessageStatistics/Accessibility',
|
||||
component: ChatMessageStatistics,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ChatMessageStatistics/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="ViewButtonsSingleTabStop"
|
||||
args={{
|
||||
promptTokens: 100,
|
||||
promptMs: 500,
|
||||
predictedTokens: 200,
|
||||
predictedMs: 1000,
|
||||
agenticTimings: {
|
||||
turns: 1,
|
||||
llm: { predicted_ms: 1000, predicted_n: 200, prompt_ms: 500, prompt_n: 100 },
|
||||
toolCallsCount: 1,
|
||||
toolsMs: 500,
|
||||
llm: { predicted_n: 200, predicted_ms: 1000, prompt_n: 100, prompt_ms: 500 }
|
||||
turns: 1
|
||||
},
|
||||
hideSummary: false,
|
||||
isLive: false
|
||||
isLive: false,
|
||||
predictedMs: 1000,
|
||||
predictedTokens: 200,
|
||||
promptMs: 500,
|
||||
promptTokens: 100
|
||||
}}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const reading = await canvas.findByRole('button', { name: 'Reading' });
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
|
||||
import { expect, screen, waitFor } from 'storybook/test';
|
||||
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
|
||||
import { expect, screen, waitFor } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatScreenForm/Accessibility',
|
||||
component: ChatScreenForm,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ChatScreen/ChatScreenForm/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const textarea = await canvas.findByRole('textbox');
|
||||
|
||||
await userEvent.clear(textarea);
|
||||
await userEvent.type(textarea, 'What is the meaning of life?');
|
||||
|
||||
|
||||
@@ -4,12 +4,12 @@
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/HorizontalScrollCarousel/Accessibility',
|
||||
component: HorizontalScrollCarousel,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
tags: ['!dev'],
|
||||
title: 'Components/HorizontalScrollCarousel/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,20 +4,20 @@
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const mockForkedConversation: DatabaseConversation = {
|
||||
id: 'conv-2',
|
||||
name: 'Forked Conversation',
|
||||
lastModified: Date.now(),
|
||||
currNode: 'msg-2',
|
||||
forkedFromConversationId: 'conv-1'
|
||||
forkedFromConversationId: 'conv-1',
|
||||
id: 'conv-2',
|
||||
lastModified: Date.now(),
|
||||
name: 'Forked Conversation'
|
||||
};
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/SidebarNavigationConversationItem/Accessibility',
|
||||
component: SidebarNavigationConversationItem,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
tags: ['!dev'],
|
||||
title: 'Components/SidebarNavigationConversationItem/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
|
||||
/**
|
||||
* Mock server properties for Storybook testing
|
||||
@@ -8,16 +8,17 @@ import { modelsStore } from '$lib/stores/models.svelte';
|
||||
export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
|
||||
// Reset any pointer-events from previous tests (dropdown cleanup)
|
||||
const body = document.querySelector('body');
|
||||
|
||||
if (body) body.style.pointerEvents = '';
|
||||
|
||||
// Directly set the props for testing purposes
|
||||
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
|
||||
model_path: props.model_path || 'test-model',
|
||||
modalities: {
|
||||
vision: props.modalities?.vision ?? false,
|
||||
audio: props.modalities?.audio ?? false,
|
||||
video: props.modalities?.video ?? false
|
||||
video: props.modalities?.video ?? false,
|
||||
vision: props.modalities?.vision ?? false
|
||||
},
|
||||
model_path: props.model_path || 'test-model',
|
||||
...props
|
||||
} as ApiLlamaCppServerProps;
|
||||
|
||||
@@ -41,8 +42,8 @@ export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
|
||||
(modelsStore as any).models = [
|
||||
{
|
||||
id: 'test-model',
|
||||
name: 'Test Model',
|
||||
model: 'test-model'
|
||||
model: 'test-model',
|
||||
name: 'Test Model'
|
||||
}
|
||||
];
|
||||
|
||||
@@ -56,12 +57,12 @@ export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
|
||||
*/
|
||||
export function resetServerStore(): void {
|
||||
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
|
||||
model_path: '',
|
||||
modalities: {
|
||||
vision: false,
|
||||
audio: false,
|
||||
video: false
|
||||
}
|
||||
video: false,
|
||||
vision: false
|
||||
},
|
||||
model_path: ''
|
||||
} as ApiLlamaCppServerProps;
|
||||
(serverStore as unknown as { error: string }).error = '';
|
||||
(serverStore as unknown as { loading: boolean }).loading = false;
|
||||
@@ -71,16 +72,16 @@ export function resetServerStore(): void {
|
||||
* Common mock configurations for Storybook stories
|
||||
*/
|
||||
export const mockConfigs = {
|
||||
visionOnly: {
|
||||
modalities: { vision: true, audio: false }
|
||||
},
|
||||
audioOnly: {
|
||||
modalities: { vision: false, audio: true }
|
||||
modalities: { audio: true, vision: false }
|
||||
},
|
||||
bothModalities: {
|
||||
modalities: { vision: true, audio: true }
|
||||
modalities: { audio: true, vision: true }
|
||||
},
|
||||
noModalities: {
|
||||
modalities: { vision: false, audio: false, video: false }
|
||||
modalities: { audio: false, video: false, vision: false }
|
||||
},
|
||||
visionOnly: {
|
||||
modalities: { audio: false, vision: true }
|
||||
}
|
||||
} as const;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isAbortError } from '$lib/utils/abort';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('isAbortError', () => {
|
||||
it('returns false for null, undefined and non-error values', () => {
|
||||
@@ -12,11 +12,13 @@ describe('isAbortError', () => {
|
||||
|
||||
it('returns true for DOMException with AbortError name', () => {
|
||||
const err = new DOMException('Operation was aborted', 'AbortError');
|
||||
|
||||
expect(isAbortError(err)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for plain Error with AbortError name', () => {
|
||||
const err = new Error('aborted');
|
||||
|
||||
err.name = 'AbortError';
|
||||
expect(isAbortError(err)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -6,57 +6,52 @@
|
||||
//
|
||||
// Run: npx vitest bench --project=unit tests/unit/agentic-hotpath.bench.ts
|
||||
|
||||
import { bench, describe } from 'vitest';
|
||||
|
||||
import { classifyToolResult, parseToolResultWithImages } from '$lib/utils/agentic';
|
||||
import { detectIncompleteCodeBlock, highlightCode } from '$lib/utils/code';
|
||||
import { computeLineDiff } from '$lib/utils/compute-line-diff';
|
||||
import { preprocessLaTeX } from '$lib/utils/latex-protection';
|
||||
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
|
||||
import { extractSearchQuery, extractSearchResults } from '$lib/utils/search-results';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { remark } from 'remark';
|
||||
import remarkBreaks from 'remark-breaks';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import remarkMath from 'remark-math';
|
||||
import remarkRehype from 'remark-rehype';
|
||||
import rehypeKatex from 'rehype-katex';
|
||||
import rehypeHighlight from 'rehype-highlight';
|
||||
import rehypeStringify from 'rehype-stringify';
|
||||
import { all as lowlightAll } from 'lowlight';
|
||||
|
||||
import { computeLineDiff } from '$lib/utils/compute-line-diff';
|
||||
import { extractSearchResults, extractSearchQuery } from '$lib/utils/search-results';
|
||||
import { parsePartialJsonArgs } from '$lib/utils/parse-partial-json-args';
|
||||
import { highlightCode, detectIncompleteCodeBlock } from '$lib/utils/code';
|
||||
import { classifyToolResult, parseToolResultWithImages } from '$lib/utils/agentic';
|
||||
import { preprocessLaTeX } from '$lib/utils/latex-protection';
|
||||
import { bench, describe } from 'vitest';
|
||||
|
||||
// --- fixtures -------------------------------------------------------------
|
||||
|
||||
function lines(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');
|
||||
}
|
||||
|
||||
const SHELL_OUTPUT_1KB = lines(12, 'out');
|
||||
const SHELL_OUTPUT_200KB = lines(2600, 'out');
|
||||
const SHELL_OUTPUT_2MB = lines(26000, 'out');
|
||||
|
||||
// Realistic exec_shell_command result: the exit-code marker is the final line,
|
||||
// which is what the un-anchored EXIT_CODE regex has to scan the whole blob for.
|
||||
const SHELL_2MB_WITH_EXIT = `${SHELL_OUTPUT_2MB}\n[exit code: 0]`;
|
||||
|
||||
const EDIT_OLD_400 = lines(400, 'old');
|
||||
const EDIT_NEW_400 = lines(400, 'new');
|
||||
const EDIT_OLD_50 = lines(50, 'old');
|
||||
const EDIT_NEW_50 = lines(50, 'new');
|
||||
|
||||
const WRITE_FILE_ARGS = JSON.stringify({
|
||||
path: '/src/lib/thing.ts',
|
||||
content: lines(1500, 'src')
|
||||
content: lines(1500, 'src'),
|
||||
path: '/src/lib/thing.ts'
|
||||
});
|
||||
|
||||
const MARKDOWN_50KB = Array.from(
|
||||
{ length: 400 },
|
||||
(_, i) =>
|
||||
`## Section ${i}\n\nSome **bold** prose with a [link](https://example.com) and \`inline\` code.\n\n- bullet one\n- bullet two\n\n\`\`\`ts\nconst x${i} = ${i};\n\`\`\`\n`
|
||||
).join('\n');
|
||||
|
||||
const CODE_BLOCK_5KB = lines(60, 'code');
|
||||
|
||||
// --- A: computeLineDiff (O(m*n) LCS, allocates a full matrix) --------------
|
||||
@@ -79,7 +74,9 @@ describe('computeLineDiff', () => {
|
||||
function buildProcessor() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let proc: any = remark().use(remarkGfm);
|
||||
|
||||
proc = proc.use(remarkMath).use(remarkBreaks).use(remarkRehype).use(rehypeKatex);
|
||||
|
||||
return proc.use(rehypeHighlight, { languages: lowlightAll }).use(rehypeStringify, {
|
||||
allowDangerousHtml: true
|
||||
});
|
||||
@@ -109,7 +106,6 @@ describe('markdown parse scaling (whole-string reparse per frame)', () => {
|
||||
{ length: Math.ceil((kb * 1024) / 64) },
|
||||
(_, i) => `The quick brown fox jumps over the lazy dog. Sentence ${i}.`
|
||||
).join(' ');
|
||||
|
||||
const MD_3KB = prose(3);
|
||||
const MD_11KB = prose(11);
|
||||
const MD_26KB = prose(26);
|
||||
@@ -139,7 +135,6 @@ describe('other whole-string passes per frame', () => {
|
||||
{ length: Math.ceil((kb * 1024) / 64) },
|
||||
(_, i) => `The quick brown fox jumps over the lazy dog. Sentence ${i}.`
|
||||
).join(' ');
|
||||
|
||||
const MD_3KB = prose(3);
|
||||
const MD_11KB = prose(11);
|
||||
const MD_26KB = prose(26);
|
||||
|
||||
@@ -1,34 +1,34 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
|
||||
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
id: overrides.id ?? 'ast-1',
|
||||
convId: 'conv-1',
|
||||
type: 'text',
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: overrides.content ?? '',
|
||||
parent: null,
|
||||
children: [],
|
||||
content: overrides.content ?? '',
|
||||
convId: 'conv-1',
|
||||
id: overrides.id ?? 'ast-1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: Date.now(),
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
id: overrides.id ?? 'tool-1',
|
||||
convId: 'conv-1',
|
||||
type: 'text',
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.TOOL,
|
||||
content: overrides.content ?? 'tool result',
|
||||
parent: null,
|
||||
children: [],
|
||||
content: overrides.content ?? 'tool result',
|
||||
convId: 'conv-1',
|
||||
id: overrides.id ?? 'tool-1',
|
||||
parent: null,
|
||||
role: MessageRole.TOOL,
|
||||
timestamp: Date.now(),
|
||||
toolCallId: overrides.toolCallId ?? 'call_1',
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
@@ -37,12 +37,14 @@ describe('deriveAgenticSections', () => {
|
||||
it('returns empty array for assistant with no content', () => {
|
||||
const msg = makeAssistant({ content: '' });
|
||||
const sections = deriveAgenticSections(msg);
|
||||
|
||||
expect(sections).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns text section for simple assistant message', () => {
|
||||
const msg = makeAssistant({ content: 'Hello world' });
|
||||
const sections = deriveAgenticSections(msg);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[0].content).toBe('Hello world');
|
||||
@@ -54,6 +56,7 @@ describe('deriveAgenticSections', () => {
|
||||
reasoningContent: 'Let me think...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg);
|
||||
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
||||
expect(sections[0].content).toBe('Let me think...');
|
||||
@@ -65,17 +68,18 @@ describe('deriveAgenticSections', () => {
|
||||
content: 'Let me check.',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{"q":"test"}', name: 'search' },
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"test"}' }
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const toolResult = makeToolMsg({
|
||||
toolCallId: 'call_1',
|
||||
content: 'Found 3 results'
|
||||
content: 'Found 3 results',
|
||||
toolCallId: 'call_1'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [toolResult]);
|
||||
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
@@ -86,10 +90,11 @@ describe('deriveAgenticSections', () => {
|
||||
it('single turn: pending tool call without result', () => {
|
||||
const msg = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'bash' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
|
||||
expect(sections[0].toolName).toBe('bash');
|
||||
@@ -107,10 +112,11 @@ describe('deriveAgenticSections', () => {
|
||||
const partialArgs = '{"path":"/Users/fifa2026.html","content":"<!DOCTYPE h';
|
||||
const msg = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'write_file', arguments: partialArgs } }
|
||||
{ function: { arguments: partialArgs, name: 'write_file' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL_PENDING);
|
||||
expect(sections[0].type).not.toBe(AgenticSectionType.TOOL_CALL_STREAMING);
|
||||
@@ -120,24 +126,24 @@ describe('deriveAgenticSections', () => {
|
||||
|
||||
it('multi-turn: two assistant turns grouped as one session', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
id: 'ast-1',
|
||||
content: 'Turn 1 text',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{"q":"foo"}', name: 'search' },
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"foo"}' }
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' });
|
||||
const tool1 = makeToolMsg({ content: 'result 1', id: 'tool-1', toolCallId: 'call_1' });
|
||||
const assistant2 = makeAssistant({
|
||||
id: 'ast-2',
|
||||
content: 'Final answer based on results.'
|
||||
content: 'Final answer based on results.',
|
||||
id: 'ast-2'
|
||||
});
|
||||
|
||||
// toolMessages contains both tool result and continuation assistant
|
||||
const sections = deriveAgenticSections(assistant1, [tool1, assistant2]);
|
||||
|
||||
expect(sections).toHaveLength(3);
|
||||
// Turn 1
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
@@ -152,40 +158,40 @@ describe('deriveAgenticSections', () => {
|
||||
|
||||
it('multi-turn: three turns with tool calls', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
id: 'ast-1',
|
||||
content: '',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{}', name: 'list_files' },
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'list_files', arguments: '{}' }
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' });
|
||||
const tool1 = makeToolMsg({ content: 'file1 file2', id: 'tool-1', toolCallId: 'call_1' });
|
||||
const assistant2 = makeAssistant({
|
||||
id: 'ast-2',
|
||||
content: 'Reading file1...',
|
||||
id: 'ast-2',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{"path":"file1"}', name: 'read_file' },
|
||||
id: 'call_2',
|
||||
type: 'function',
|
||||
function: { name: 'read_file', arguments: '{"path":"file1"}' }
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool2 = makeToolMsg({
|
||||
content: 'contents of file1',
|
||||
id: 'tool-2',
|
||||
toolCallId: 'call_2',
|
||||
content: 'contents of file1'
|
||||
toolCallId: 'call_2'
|
||||
});
|
||||
const assistant3 = makeAssistant({
|
||||
id: 'ast-3',
|
||||
content: 'Here is the analysis.',
|
||||
id: 'ast-3',
|
||||
reasoningContent: 'The file contains...'
|
||||
});
|
||||
|
||||
const sections = deriveAgenticSections(assistant1, [tool1, assistant2, tool2, assistant3]);
|
||||
|
||||
// Turn 1: tool_call (no text since content is empty)
|
||||
// Turn 2: text + tool_call
|
||||
// Turn 3: reasoning + text
|
||||
@@ -206,6 +212,7 @@ describe('deriveAgenticSections', () => {
|
||||
reasoningContent: 'Let me think about this...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING_PENDING);
|
||||
expect(sections[0].content).toBe('Let me think about this...');
|
||||
@@ -217,6 +224,7 @@ describe('deriveAgenticSections', () => {
|
||||
reasoningContent: 'Let me think...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
|
||||
expect(sections).toHaveLength(2);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
||||
@@ -227,6 +235,7 @@ describe('deriveAgenticSections', () => {
|
||||
reasoningContent: 'Let me think...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], false);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
||||
});
|
||||
@@ -234,17 +243,16 @@ describe('deriveAgenticSections', () => {
|
||||
it('multi-turn: streaming tool calls on last turn', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'search' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' });
|
||||
const assistant2 = makeAssistant({ id: 'ast-2', content: '' });
|
||||
|
||||
const tool1 = makeToolMsg({ content: 'result', toolCallId: 'call_1' });
|
||||
const assistant2 = makeAssistant({ content: '', id: 'ast-2' });
|
||||
const streamingToolCalls: ApiChatCompletionToolCall[] = [
|
||||
{ id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } }
|
||||
{ function: { arguments: '{"pa', name: 'write_file' }, id: 'call_2', type: 'function' }
|
||||
];
|
||||
|
||||
const sections = deriveAgenticSections(assistant1, [tool1, assistant2], streamingToolCalls);
|
||||
|
||||
// Turn 1: tool_call
|
||||
// Turn 2 (streaming): streaming tool call
|
||||
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL)).toBe(true);
|
||||
@@ -255,26 +263,30 @@ describe('deriveAgenticSections', () => {
|
||||
describe('hasAgenticContent', () => {
|
||||
it('returns false for plain assistant', () => {
|
||||
const msg = makeAssistant({ content: 'Just text' });
|
||||
|
||||
expect(hasAgenticContent(msg)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true when message has toolCalls', () => {
|
||||
const msg = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
|
||||
expect(hasAgenticContent(msg)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when toolMessages are provided', () => {
|
||||
const msg = makeAssistant();
|
||||
const tool = makeToolMsg();
|
||||
|
||||
expect(hasAgenticContent(msg, [tool])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty toolCalls JSON', () => {
|
||||
const msg = makeAssistant({ toolCalls: '[]' });
|
||||
|
||||
expect(hasAgenticContent(msg)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for legacy marker stripping (used in migration).
|
||||
@@ -25,7 +25,6 @@ const COMPLETE_BLOCK =
|
||||
'<<<TOOL_ARGS_END>>>\n' +
|
||||
'file1.txt\nfile2.txt\n' +
|
||||
'<<<AGENTIC_TOOL_CALL_END>>>\n';
|
||||
|
||||
// Partial block: streaming was cut before END arrived.
|
||||
const OPEN_BLOCK =
|
||||
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
|
||||
@@ -39,6 +38,7 @@ describe('legacy agentic marker stripping (for migration)', () => {
|
||||
it('strips a complete tool call block, leaving surrounding text', () => {
|
||||
const input = 'Before.' + COMPLETE_BLOCK + 'After.';
|
||||
const result = stripLegacyContextMarkers(input);
|
||||
|
||||
expect(result).not.toContain('<<<');
|
||||
expect(result).toContain('Before.');
|
||||
expect(result).toContain('After.');
|
||||
@@ -47,6 +47,7 @@ describe('legacy agentic marker stripping (for migration)', () => {
|
||||
it('strips multiple complete tool call blocks', () => {
|
||||
const input = 'A' + COMPLETE_BLOCK + 'B' + COMPLETE_BLOCK + 'C';
|
||||
const result = stripLegacyContextMarkers(input);
|
||||
|
||||
expect(result).not.toContain('<<<');
|
||||
expect(result).toContain('A');
|
||||
expect(result).toContain('B');
|
||||
@@ -56,17 +57,20 @@ describe('legacy agentic marker stripping (for migration)', () => {
|
||||
it('strips an open/partial tool call block (no END marker)', () => {
|
||||
const input = 'Lead text.' + OPEN_BLOCK;
|
||||
const result = stripLegacyContextMarkers(input);
|
||||
|
||||
expect(result).toBe('Lead text.');
|
||||
expect(result).not.toContain('<<<');
|
||||
});
|
||||
|
||||
it('does not alter content with no markers', () => {
|
||||
const input = 'Just a normal assistant response.';
|
||||
|
||||
expect(stripLegacyContextMarkers(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('strips reasoning block independently', () => {
|
||||
const input = '<<<reasoning_content_start>>>think hard<<<reasoning_content_end>>>Answer.';
|
||||
|
||||
expect(stripLegacyContextMarkers(input)).toBe('Answer.');
|
||||
});
|
||||
|
||||
@@ -75,6 +79,7 @@ describe('legacy agentic marker stripping (for migration)', () => {
|
||||
'<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
|
||||
'Some text.' +
|
||||
COMPLETE_BLOCK;
|
||||
|
||||
expect(stripLegacyContextMarkers(input)).not.toContain('<<<');
|
||||
expect(stripLegacyContextMarkers(input)).toContain('Some text.');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { REASONING_TAGS } from '$lib/constants';
|
||||
import { buildAssistantRawOutput, type AgenticSection } from '$lib/utils/agentic';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import { type AgenticSection, buildAssistantRawOutput } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSection(
|
||||
overrides: Partial<AgenticSection> & { type: AgenticSectionType }
|
||||
@@ -18,26 +18,29 @@ describe('buildAssistantRawOutput', () => {
|
||||
});
|
||||
|
||||
it('formats a reasoning section with a single newline between tags and content', () => {
|
||||
const sections = [makeSection({ type: AgenticSectionType.REASONING, content: 'thinking...' })];
|
||||
const sections = [makeSection({ content: 'thinking...', type: AgenticSectionType.REASONING })];
|
||||
|
||||
expect(buildAssistantRawOutput(sections)).toBe(
|
||||
`${REASONING_TAGS.START}\nthinking...${REASONING_TAGS.END}`
|
||||
);
|
||||
});
|
||||
|
||||
it('formats a text section as-is', () => {
|
||||
const sections = [makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' })];
|
||||
const sections = [makeSection({ content: 'Hello', type: AgenticSectionType.TEXT })];
|
||||
|
||||
expect(buildAssistantRawOutput(sections)).toBe('Hello');
|
||||
});
|
||||
|
||||
it('formats a tool call with JSON args and no result label', () => {
|
||||
const sections = [
|
||||
makeSection({
|
||||
type: AgenticSectionType.TOOL_CALL,
|
||||
toolName: 'read_file',
|
||||
toolArgs: JSON.stringify({ path: '/tmp/file.txt' }),
|
||||
toolResult: 'file contents'
|
||||
toolName: 'read_file',
|
||||
toolResult: 'file contents',
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
})
|
||||
];
|
||||
|
||||
expect(buildAssistantRawOutput(sections)).toBe(
|
||||
[
|
||||
'{',
|
||||
@@ -55,21 +58,23 @@ describe('buildAssistantRawOutput', () => {
|
||||
|
||||
it('joins multiple sections with double newlines', () => {
|
||||
const sections = [
|
||||
makeSection({ type: AgenticSectionType.TEXT, content: 'Hello' }),
|
||||
makeSection({ type: AgenticSectionType.TOOL_CALL, toolName: 'noop' })
|
||||
makeSection({ content: 'Hello', type: AgenticSectionType.TEXT }),
|
||||
makeSection({ toolName: 'noop', type: AgenticSectionType.TOOL_CALL })
|
||||
];
|
||||
|
||||
expect(buildAssistantRawOutput(sections)).toBe('Hello\n\n{\n "name": "noop"\n}');
|
||||
});
|
||||
|
||||
it('falls back to raw string args when JSON parsing fails', () => {
|
||||
const sections = [
|
||||
makeSection({
|
||||
type: AgenticSectionType.TOOL_CALL,
|
||||
toolName: 'broken',
|
||||
toolArgs: '{not json',
|
||||
toolResult: 'result'
|
||||
toolName: 'broken',
|
||||
toolResult: 'result',
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
})
|
||||
];
|
||||
|
||||
expect(buildAssistantRawOutput(sections)).toBe(
|
||||
['{', ' "name": "broken",', ' "arguments": "{not json"', '}', '', '', 'result'].join('\n')
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyToolResult } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('classifyToolResult', () => {
|
||||
describe('text', () => {
|
||||
@@ -43,6 +43,7 @@ describe('classifyToolResult', () => {
|
||||
|
||||
it('classifies a deeply nested JSON payload', () => {
|
||||
const nested = JSON.stringify({ items: [{ id: 1, tags: ['a', 'b'] }] }, null, 2);
|
||||
|
||||
expect(classifyToolResult(nested)).toBe('json');
|
||||
});
|
||||
|
||||
@@ -52,6 +53,7 @@ describe('classifyToolResult', () => {
|
||||
// `classifyToolResult` only inspects the top-level shape, not
|
||||
// every nested line marker.
|
||||
const jsonWithLink = '{"docs": "see [docs](https://example.com) for more"}';
|
||||
|
||||
expect(classifyToolResult(jsonWithLink)).toBe('json');
|
||||
});
|
||||
});
|
||||
@@ -91,11 +93,13 @@ describe('classifyToolResult', () => {
|
||||
|
||||
it('classifies a markdown table', () => {
|
||||
const table = '| a | b |\n| - | - |\n| 1 | 2 |';
|
||||
|
||||
expect(classifyToolResult(table)).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies a markdown table with alignment markers', () => {
|
||||
const table = '| left | center | right |\n| :--- | :---: | ---: |\n| a | b | c |';
|
||||
|
||||
expect(classifyToolResult(table)).toBe('markdown');
|
||||
});
|
||||
|
||||
@@ -116,6 +120,7 @@ describe('classifyToolResult', () => {
|
||||
'| ----- | ----- |',
|
||||
'| a | b |'
|
||||
].join('\n');
|
||||
|
||||
expect(classifyToolResult(md)).toBe('markdown');
|
||||
});
|
||||
});
|
||||
@@ -124,6 +129,7 @@ describe('classifyToolResult', () => {
|
||||
it('prefers JSON over markdown when both signals are present', () => {
|
||||
// Starts with `[`, parses as JSON - markdown check is skipped.
|
||||
const arr = '[1, 2, "# not-a-heading", "**not-bold**"]';
|
||||
|
||||
expect(classifyToolResult(arr)).toBe('json');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,57 +1,61 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import {
|
||||
formatMessageForClipboard,
|
||||
parseClipboardContent,
|
||||
hasClipboardAttachments
|
||||
hasClipboardAttachments,
|
||||
parseClipboardContent
|
||||
} from '$lib/utils/clipboard';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('formatMessageForClipboard', () => {
|
||||
it('returns plain content when no extras', () => {
|
||||
const result = formatMessageForClipboard('Hello world', undefined);
|
||||
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns plain content when extras is empty array', () => {
|
||||
const result = formatMessageForClipboard('Hello world', []);
|
||||
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('handles empty string content', () => {
|
||||
const result = formatMessageForClipboard('', undefined);
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('returns plain content when extras has only non-text attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.IMAGE as const,
|
||||
base64Url: 'data:image/png;base64,...',
|
||||
name: 'image.png',
|
||||
base64Url: 'data:image/png;base64,...'
|
||||
type: AttachmentType.IMAGE as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello world', extras);
|
||||
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('filters non-text attachments and keeps only text ones', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.IMAGE as const,
|
||||
base64Url: 'data:image/png;base64,...',
|
||||
name: 'image.png',
|
||||
base64Url: 'data:image/png;base64,...'
|
||||
type: AttachmentType.IMAGE as const
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'Text content',
|
||||
name: 'file.txt',
|
||||
content: 'Text content'
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
type: AttachmentType.PDF as const,
|
||||
name: 'doc.pdf',
|
||||
base64Data: 'data:application/pdf;base64,...',
|
||||
content: 'PDF content',
|
||||
processedAsImages: false
|
||||
name: 'doc.pdf',
|
||||
processedAsImages: false,
|
||||
type: AttachmentType.PDF as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello', extras);
|
||||
@@ -64,14 +68,14 @@ describe('formatMessageForClipboard', () => {
|
||||
it('formats message with text attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'File 1 content',
|
||||
name: 'file1.txt',
|
||||
content: 'File 1 content'
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'File 2 content',
|
||||
name: 'file2.txt',
|
||||
content: 'File 2 content'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello world', extras);
|
||||
@@ -87,9 +91,9 @@ describe('formatMessageForClipboard', () => {
|
||||
const content = 'Hello "world" with\nnewline';
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'Test content',
|
||||
name: 'test.txt',
|
||||
content: 'Test content'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard(content, extras);
|
||||
@@ -98,15 +102,16 @@ describe('formatMessageForClipboard', () => {
|
||||
expect(result.startsWith('"')).toBe(true);
|
||||
// The content should be properly escaped
|
||||
const parsed = JSON.parse(result.split('\n')[0]);
|
||||
|
||||
expect(parsed).toBe(content);
|
||||
});
|
||||
|
||||
it('converts legacy context type to TEXT type', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.LEGACY_CONTEXT as const,
|
||||
content: 'Legacy content',
|
||||
name: 'legacy.txt',
|
||||
content: 'Legacy content'
|
||||
type: AttachmentType.LEGACY_CONTEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello', extras);
|
||||
@@ -118,9 +123,9 @@ describe('formatMessageForClipboard', () => {
|
||||
it('handles attachment content with special characters', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;',
|
||||
name: 'code.js',
|
||||
content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('Check this code', extras);
|
||||
@@ -134,9 +139,9 @@ describe('formatMessageForClipboard', () => {
|
||||
it('handles unicode characters in content and attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: '日本語テスト 🎉 émojis',
|
||||
name: 'unicode.txt',
|
||||
content: '日本語テスト 🎉 émojis'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('Привет мир 👋', extras);
|
||||
@@ -149,14 +154,14 @@ describe('formatMessageForClipboard', () => {
|
||||
it('formats as plain text when asPlainText is true', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'File 1 content',
|
||||
name: 'file1.txt',
|
||||
content: 'File 1 content'
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'File 2 content',
|
||||
name: 'file2.txt',
|
||||
content: 'File 2 content'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello world', extras, true);
|
||||
@@ -166,15 +171,16 @@ describe('formatMessageForClipboard', () => {
|
||||
|
||||
it('returns plain content when asPlainText is true but no attachments', () => {
|
||||
const result = formatMessageForClipboard('Hello world', [], true);
|
||||
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('plain text mode does not use JSON format', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'Test content',
|
||||
name: 'test.txt',
|
||||
content: 'Test content'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello', extras, true);
|
||||
@@ -216,7 +222,6 @@ describe('parseClipboardContent', () => {
|
||||
|
||||
it('returns original text when JSON array is malformed', () => {
|
||||
const input = '"Hello"\n[invalid json';
|
||||
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('"Hello"\n[invalid json');
|
||||
@@ -229,7 +234,6 @@ describe('parseClipboardContent', () => {
|
||||
{"type":"TEXT","name":"file1.txt","content":"File 1 content"},
|
||||
{"type":"TEXT","name":"file2.txt","content":"File 2 content"}
|
||||
]`;
|
||||
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello world');
|
||||
@@ -245,7 +249,6 @@ describe('parseClipboardContent', () => {
|
||||
[
|
||||
{"type":"TEXT","name":"file.txt","content":"test"}
|
||||
]`;
|
||||
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello "world" with quotes');
|
||||
@@ -257,7 +260,6 @@ describe('parseClipboardContent', () => {
|
||||
[
|
||||
{"type":"TEXT","name":"file.txt","content":"test"}
|
||||
]`;
|
||||
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello\nworld');
|
||||
@@ -266,7 +268,6 @@ describe('parseClipboardContent', () => {
|
||||
|
||||
it('returns message only when no array follows', () => {
|
||||
const input = '"Just a quoted string"';
|
||||
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Just a quoted string');
|
||||
@@ -281,7 +282,6 @@ describe('parseClipboardContent', () => {
|
||||
{"name":"missing-type.txt","content":"missing"},
|
||||
{"type":"TEXT","content":"missing name"}
|
||||
]`;
|
||||
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello');
|
||||
@@ -291,7 +291,6 @@ describe('parseClipboardContent', () => {
|
||||
|
||||
it('handles empty attachments array', () => {
|
||||
const input = '"Hello"\n[]';
|
||||
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello');
|
||||
@@ -302,17 +301,16 @@ describe('parseClipboardContent', () => {
|
||||
const originalContent = 'Hello "world" with\nspecial characters';
|
||||
const originalExtras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'Content with\nnewlines and "quotes"',
|
||||
name: 'file1.txt',
|
||||
content: 'Content with\nnewlines and "quotes"'
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'Another file',
|
||||
name: 'file2.txt',
|
||||
content: 'Another file'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
|
||||
const formatted = formatMessageForClipboard(originalContent, originalExtras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
@@ -360,9 +358,9 @@ describe('roundtrip edge cases', () => {
|
||||
it('preserves empty message with attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'Content only',
|
||||
name: 'file.txt',
|
||||
content: 'Content only'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('', extras);
|
||||
@@ -376,9 +374,9 @@ describe('roundtrip edge cases', () => {
|
||||
it('preserves attachment with empty content', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: '',
|
||||
name: 'empty.txt',
|
||||
content: ''
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('Message', extras);
|
||||
@@ -393,9 +391,9 @@ describe('roundtrip edge cases', () => {
|
||||
const content = 'Path: C:\\\\Users\\\\test\\\\file.txt';
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: 'D:\\\\Data\\\\file',
|
||||
name: 'path.txt',
|
||||
content: 'D:\\\\Data\\\\file'
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard(content, extras);
|
||||
@@ -409,9 +407,9 @@ describe('roundtrip edge cases', () => {
|
||||
const content = 'Line1\t\tTabbed\n Spaced\r\nCRLF';
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
content: '\t\t\n\n ',
|
||||
name: 'whitespace.txt',
|
||||
content: '\t\t\n\n '
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard(content, extras);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('trimCodePadding', () => {
|
||||
it('removes a single leading newline', () => {
|
||||
@@ -60,44 +60,52 @@ describe('highlightCode', () => {
|
||||
|
||||
it('does not produce a leading newline in the highlighted html', () => {
|
||||
const html = highlightCode('\nfunction multiply(a, b) {\n return a * b;\n}\n', 'javascript');
|
||||
|
||||
expect(html.startsWith('\n')).toBe(false);
|
||||
expect(html.startsWith(' ')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not produce a trailing newline in the highlighted html', () => {
|
||||
const html = highlightCode('\nfunction foo() {}\n', 'javascript');
|
||||
|
||||
expect(html.endsWith('\n')).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves internal blank lines in highlighted code', () => {
|
||||
const html = highlightCode('\nfunction foo() {\n\n return 1;\n}\n', 'javascript');
|
||||
|
||||
expect(html).toContain('\n\n');
|
||||
});
|
||||
|
||||
it('produces the same body for framed and unframed input', () => {
|
||||
const trimmed = highlightCode('function foo() {}', 'javascript');
|
||||
const framed = highlightCode('\nfunction foo() {}\n', 'javascript');
|
||||
|
||||
expect(framed).toBe(trimmed);
|
||||
});
|
||||
|
||||
it('auto-detects an unknown language by default', () => {
|
||||
const html = highlightCode('const answer = 42;', 'not-a-language');
|
||||
|
||||
expect(html).toContain('hljs-');
|
||||
});
|
||||
|
||||
it('escapes instead of auto-detecting when autoDetect is false', () => {
|
||||
const html = highlightCode('const answer = 42;', 'not-a-language', false);
|
||||
|
||||
expect(html).not.toContain('hljs-');
|
||||
expect(html).toBe('const answer = 42;');
|
||||
});
|
||||
|
||||
it('still highlights a known language when autoDetect is false', () => {
|
||||
const html = highlightCode('const answer = 42;', 'javascript', false);
|
||||
|
||||
expect(html).toContain('hljs-');
|
||||
});
|
||||
|
||||
it('escapes html metacharacters when falling back to plain text', () => {
|
||||
const html = highlightCode('<script>a && b</script>', 'not-a-language', false);
|
||||
|
||||
expect(html).toBe('<script>a && b</script>');
|
||||
});
|
||||
});
|
||||
@@ -105,6 +113,7 @@ describe('highlightCode', () => {
|
||||
describe('splitGluedClosingCodeFences', () => {
|
||||
it('splits text glued to a closing fence onto its own line', () => {
|
||||
const input = "```ts\nlet foo = 'bar';\n```create this file on [Desktop](file:///a/b/)";
|
||||
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(
|
||||
"```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)"
|
||||
);
|
||||
@@ -112,6 +121,7 @@ describe('splitGluedClosingCodeFences', () => {
|
||||
|
||||
it('leaves a well-formed code block untouched', () => {
|
||||
const input = "```ts\nlet foo = 'bar';\n```\ncreate this file on [Desktop](file:///a/b/)";
|
||||
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||
});
|
||||
|
||||
@@ -121,11 +131,13 @@ describe('splitGluedClosingCodeFences', () => {
|
||||
|
||||
it('keeps nested markdown fences inside a block intact', () => {
|
||||
const input = '```md\n# Example\n```python\nprint(1)\n```\n```';
|
||||
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||
});
|
||||
|
||||
it('splits every glued closing fence when several blocks are present', () => {
|
||||
const input = '```ts\na\n```first words\n\n```js\nb\n```second words';
|
||||
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(
|
||||
'```ts\na\n```\nfirst words\n\n```js\nb\n```\nsecond words'
|
||||
);
|
||||
@@ -133,6 +145,7 @@ describe('splitGluedClosingCodeFences', () => {
|
||||
|
||||
it('leaves a still-open fence untouched', () => {
|
||||
const input = '```ts\nlet foo = 1;';
|
||||
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findCommandToken, takeCommandDismissSnapshot } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('findCommandToken', () => {
|
||||
it('returns null when the value does not start with a slash', () => {
|
||||
@@ -9,31 +9,31 @@ describe('findCommandToken', () => {
|
||||
});
|
||||
|
||||
it('parses a bare slash', () => {
|
||||
expect(findCommandToken('/')).toEqual({ name: '', args: '', end: 1 });
|
||||
expect(findCommandToken('/')).toEqual({ args: '', end: 1, name: '' });
|
||||
});
|
||||
|
||||
it('parses a command name with no args', () => {
|
||||
expect(findCommandToken('/prompt')).toEqual({ name: 'prompt', args: '', end: 7 });
|
||||
expect(findCommandToken('/prompt')).toEqual({ args: '', end: 7, name: 'prompt' });
|
||||
});
|
||||
|
||||
it('parses a command name followed by a space', () => {
|
||||
expect(findCommandToken('/prompt ')).toEqual({ name: 'prompt', args: '', end: 8 });
|
||||
expect(findCommandToken('/prompt ')).toEqual({ args: '', end: 8, name: 'prompt' });
|
||||
});
|
||||
|
||||
it('parses args after the command name', () => {
|
||||
expect(findCommandToken('/prompt rev')).toEqual({ name: 'prompt', args: 'rev', end: 11 });
|
||||
expect(findCommandToken('/prompt rev')).toEqual({ args: 'rev', end: 11, name: 'prompt' });
|
||||
});
|
||||
|
||||
it('parses multi-word args', () => {
|
||||
expect(findCommandToken('/prompt review code ')).toEqual({
|
||||
name: 'prompt',
|
||||
args: ' review code ',
|
||||
end: 22
|
||||
end: 22,
|
||||
name: 'prompt'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats the whole run as the name when there is no space', () => {
|
||||
expect(findCommandToken('/promptx')).toEqual({ name: 'promptx', args: '', end: 8 });
|
||||
expect(findCommandToken('/promptx')).toEqual({ args: '', end: 8, name: 'promptx' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,8 +44,8 @@ describe('takeCommandDismissSnapshot', () => {
|
||||
|
||||
it('captures the name and args', () => {
|
||||
expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({
|
||||
name: 'prompt',
|
||||
args: 'rev'
|
||||
args: 'rev',
|
||||
name: 'prompt'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DiffLineKind } from '$lib/enums';
|
||||
import { computeLineDiff, renderUnifiedDiff, type DiffLine } from '$lib/utils';
|
||||
import { computeLineDiff, type DiffLine, renderUnifiedDiff } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('computeLineDiff', () => {
|
||||
it('returns empty for two empty inputs', () => {
|
||||
@@ -9,34 +9,35 @@ describe('computeLineDiff', () => {
|
||||
|
||||
it('marks every line as removed for an empty new text', () => {
|
||||
expect(computeLineDiff('a\nb\nc', '')).toEqual([
|
||||
{ kind: 'remove', text: 'a', oldLine: 1 },
|
||||
{ kind: 'remove', text: 'b', oldLine: 2 },
|
||||
{ kind: 'remove', text: 'c', oldLine: 3 }
|
||||
{ kind: 'remove', oldLine: 1, text: 'a' },
|
||||
{ kind: 'remove', oldLine: 2, text: 'b' },
|
||||
{ kind: 'remove', oldLine: 3, text: 'c' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks every line as added for an empty old text', () => {
|
||||
expect(computeLineDiff('', 'a\nb')).toEqual([
|
||||
{ kind: 'add', text: 'a', newLine: 1 },
|
||||
{ kind: 'add', text: 'b', newLine: 2 }
|
||||
{ kind: 'add', newLine: 1, text: 'a' },
|
||||
{ kind: 'add', newLine: 2, text: 'b' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects a single-line replace', () => {
|
||||
expect(computeLineDiff('old', 'new')).toEqual([
|
||||
{ kind: 'add', text: 'new', newLine: 1 },
|
||||
{ kind: 'remove', text: 'old', oldLine: 1 }
|
||||
{ kind: 'add', newLine: 1, text: 'new' },
|
||||
{ kind: 'remove', oldLine: 1, text: 'old' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves interleaved context around additions', () => {
|
||||
const oldText = ['a', 'b', 'c'].join('\n');
|
||||
const newText = ['a', 'b', 'B', 'c'].join('\n');
|
||||
|
||||
expect(computeLineDiff(oldText, newText)).toEqual([
|
||||
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
|
||||
{ kind: 'add', text: 'B', newLine: 3 },
|
||||
{ kind: 'context', text: 'c', oldLine: 3, newLine: 4 }
|
||||
{ kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: 'context', newLine: 2, oldLine: 2, text: 'b' },
|
||||
{ kind: 'add', newLine: 3, text: 'B' },
|
||||
{ kind: 'context', newLine: 4, oldLine: 3, text: 'c' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -45,47 +46,50 @@ describe('computeLineDiff', () => {
|
||||
// show context flanking the changed line at its natural position.
|
||||
const oldText = ['a', 'b', 'c', 'd'].join('\n');
|
||||
const newText = ['a', 'b', 'X', 'd'].join('\n');
|
||||
|
||||
expect(computeLineDiff(oldText, newText)).toEqual([
|
||||
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'context', text: 'b', oldLine: 2, newLine: 2 },
|
||||
{ kind: 'add', text: 'X', newLine: 3 },
|
||||
{ kind: 'remove', text: 'c', oldLine: 3 },
|
||||
{ kind: 'context', text: 'd', oldLine: 4, newLine: 4 }
|
||||
{ kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: 'context', newLine: 2, oldLine: 2, text: 'b' },
|
||||
{ kind: 'add', newLine: 3, text: 'X' },
|
||||
{ kind: 'remove', oldLine: 3, text: 'c' },
|
||||
{ kind: 'context', newLine: 4, oldLine: 4, text: 'd' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves interleaved context around removals', () => {
|
||||
const oldText = ['a', 'b', 'c', 'd'].join('\n');
|
||||
const newText = ['a', 'c', 'd'].join('\n');
|
||||
|
||||
expect(computeLineDiff(oldText, newText)).toEqual([
|
||||
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'remove', text: 'b', oldLine: 2 },
|
||||
{ kind: 'context', text: 'c', oldLine: 3, newLine: 2 },
|
||||
{ kind: 'context', text: 'd', oldLine: 4, newLine: 3 }
|
||||
{ kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: 'remove', oldLine: 2, text: 'b' },
|
||||
{ kind: 'context', newLine: 2, oldLine: 3, text: 'c' },
|
||||
{ kind: 'context', newLine: 3, oldLine: 4, text: 'd' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles purely identical inputs', () => {
|
||||
const text = 'x\ny\nz';
|
||||
const result = computeLineDiff(text, text);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ kind: 'context', text: 'x', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'context', text: 'y', oldLine: 2, newLine: 2 },
|
||||
{ kind: 'context', text: 'z', oldLine: 3, newLine: 3 }
|
||||
{ kind: 'context', newLine: 1, oldLine: 1, text: 'x' },
|
||||
{ kind: 'context', newLine: 2, oldLine: 2, text: 'y' },
|
||||
{ kind: 'context', newLine: 3, oldLine: 3, text: 'z' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips a trailing newline on the old/new inputs', () => {
|
||||
expect(computeLineDiff('a\n', 'a\nb\n')).toEqual([
|
||||
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'add', text: 'b', newLine: 2 }
|
||||
{ kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: 'add', newLine: 2, text: 'b' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('normalizes trailing CR on each line', () => {
|
||||
expect(computeLineDiff('a\r\nb\r\n', 'a\nb')).toEqual([
|
||||
{ kind: 'context', text: 'a', oldLine: 1, newLine: 1 },
|
||||
{ kind: 'context', text: 'b', oldLine: 2, newLine: 2 }
|
||||
{ kind: 'context', newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: 'context', newLine: 2, oldLine: 2, text: 'b' }
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -99,11 +103,13 @@ describe('computeLineDiff', () => {
|
||||
// remove) carry no number on that side.
|
||||
let lastOld = 0;
|
||||
let lastNew = 0;
|
||||
|
||||
for (const line of diff) {
|
||||
if (line.oldLine !== undefined) {
|
||||
expect(line.oldLine).toBeGreaterThan(lastOld);
|
||||
lastOld = line.oldLine;
|
||||
}
|
||||
|
||||
if (line.newLine !== undefined) {
|
||||
expect(line.newLine).toBeGreaterThan(lastNew);
|
||||
lastNew = line.newLine;
|
||||
@@ -123,14 +129,16 @@ describe('renderUnifiedDiff', () => {
|
||||
{ kind: DiffLineKind.ADD, text: 'plus' },
|
||||
{ kind: DiffLineKind.REMOVE, text: 'minus' }
|
||||
];
|
||||
|
||||
expect(renderUnifiedDiff(lines)).toBe(' ctx\n+plus\n-minus');
|
||||
});
|
||||
|
||||
it('ignores oldLine/newLine metadata when emitting prefixes', () => {
|
||||
const lines: DiffLine[] = [
|
||||
{ kind: DiffLineKind.CONTEXT, text: 'a', oldLine: 1, newLine: 1 },
|
||||
{ kind: DiffLineKind.ADD, text: 'b', newLine: 2 }
|
||||
{ kind: DiffLineKind.CONTEXT, newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: DiffLineKind.ADD, newLine: 2, text: 'b' }
|
||||
];
|
||||
|
||||
expect(renderUnifiedDiff(lines)).toBe(' a\n+b');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tokenizeContent', () => {
|
||||
it('tokenizes a plain text buffer with no badges', () => {
|
||||
@@ -36,6 +36,7 @@ describe('tokenizeContent', () => {
|
||||
it('recognizes badges whose path contains spaces (macOS screenshots)', () => {
|
||||
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
|
||||
const source = `[Screenshot 2026-07-28 at 17.21.50.png](file://${path}) `;
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
@@ -46,6 +47,7 @@ describe('tokenizeContent', () => {
|
||||
const path =
|
||||
'/var/folders/78/j28m7pn57wb34bfjwlskh62h0000gn/T/TemporaryItems/NSIRD_screencaptureui_GD0A2R/Screenshot 2026-07-28 at 17.23.28.png';
|
||||
const source = `[Screenshot 2026-07-28 at 17.23.28.png](file://${path}) `;
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.23.28.png', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
@@ -55,6 +57,7 @@ describe('tokenizeContent', () => {
|
||||
it('keeps text around a badge with spaces in the path', () => {
|
||||
const path = '/Users/allozaur/Desktop/Screenshot 2026-07-28 at 17.21.50.png';
|
||||
const source = `see [Screenshot 2026-07-28 at 17.21.50.png](file://${path}) done`;
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'text', text: 'see ' },
|
||||
{ kind: 'badge', name: 'Screenshot 2026-07-28 at 17.21.50.png', path },
|
||||
@@ -65,6 +68,7 @@ describe('tokenizeContent', () => {
|
||||
it('recognizes badges whose path contains a close parenthesis (macOS duplicate files)', () => {
|
||||
const path = '/Users/foo/Screenshot (1).png';
|
||||
const source = `[Screenshot (1).png](file://${path}) `;
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'Screenshot (1).png', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
@@ -74,6 +78,7 @@ describe('tokenizeContent', () => {
|
||||
it('recognizes badges whose folder name is wrapped in parentheses', () => {
|
||||
const path = '/Users/foo/Project (Stuff)/main.rs';
|
||||
const source = `[main.rs](file://${path}) `;
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'main.rs', path },
|
||||
{ kind: 'text', text: ' ' }
|
||||
@@ -82,6 +87,7 @@ describe('tokenizeContent', () => {
|
||||
|
||||
it('recognizes adjacent badges back-to-back with no separator', () => {
|
||||
const source = '[a](file:///p)[b](file:///q)';
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'badge', name: 'a', path: '/p' },
|
||||
{ kind: 'badge', name: 'b', path: '/q' }
|
||||
@@ -98,6 +104,7 @@ describe('tokenizeContent', () => {
|
||||
|
||||
it('tokenizes a fenced code block without a language', () => {
|
||||
const source = 'before\n```\nconst a = 1;\n```\nafter';
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'text', text: 'before\n' },
|
||||
{ kind: 'codeBlock', text: '```\nconst a = 1;\n```' },
|
||||
@@ -107,6 +114,7 @@ describe('tokenizeContent', () => {
|
||||
|
||||
it('tokenizes a fenced code block with a language', () => {
|
||||
const source = '```js\nconst a = 1;\n```';
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'codeBlock', text: '```js\nconst a = 1;\n```' }
|
||||
]);
|
||||
@@ -185,6 +193,7 @@ describe('isOffsetInCodeBlock', () => {
|
||||
|
||||
it('is true inside a still-open block while it is being typed', () => {
|
||||
const open = '```js\nconst a = 1;';
|
||||
|
||||
expect(isOffsetInCodeBlock(open, open.length)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -198,6 +207,7 @@ describe('isOffsetInCodeBlock', () => {
|
||||
it('toggles per fence across multiple blocks', () => {
|
||||
const two = BLOCK + '\ntext\n' + BLOCK;
|
||||
const secondBlock = two.lastIndexOf(BLOCK);
|
||||
|
||||
expect(isOffsetInCodeBlock(two, secondBlock - 2)).toBe(false);
|
||||
expect(isOffsetInCodeBlock(two, secondBlock + 6)).toBe(true);
|
||||
expect(isOffsetInCodeBlock(two, two.length)).toBe(false);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { badgeAwareWordJump, leadingBadgeEdgeOffset } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Layout of `hello [docs](file:///a/b) world foo`:
|
||||
// "hello" 0-4, " " 5, badge 6-24 (length 19), " " 25, "world" 26-30, " " 31, "foo" 32-34
|
||||
@@ -42,6 +42,7 @@ describe('badgeAwareWordJump', () => {
|
||||
|
||||
it('treats a leading badge as one word in both directions', () => {
|
||||
const source = `${BADGE} rest`;
|
||||
|
||||
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(BADGE.length);
|
||||
expect(badgeAwareWordJump(source, BADGE.length, 'backward')).toBe(0);
|
||||
});
|
||||
@@ -49,6 +50,7 @@ describe('badgeAwareWordJump', () => {
|
||||
it('treats adjacent badges as separate words', () => {
|
||||
// each badge is 14 chars: "[a](file:///x)" / "[b](file:///y)"
|
||||
const source = '[a](file:///x)[b](file:///y)';
|
||||
|
||||
expect(badgeAwareWordJump(source, 0, 'forward')).toBe(14);
|
||||
expect(badgeAwareWordJump(source, 14, 'forward')).toBe(28);
|
||||
expect(badgeAwareWordJump(source, 28, 'backward')).toBe(14);
|
||||
@@ -58,6 +60,7 @@ describe('badgeAwareWordJump', () => {
|
||||
it('jumps over a badge following punctuation', () => {
|
||||
// "foo," 0-3, " " 4, badge 5-23 (end 24), " bar" 24-27
|
||||
const source = `foo, ${BADGE} bar`;
|
||||
|
||||
expect(badgeAwareWordJump(source, 0, 'forward')).toBeNull();
|
||||
expect(badgeAwareWordJump(source, 3, 'forward')).toBe(24);
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyContinueIntent } from '$lib/utils/agentic';
|
||||
import { ContinueIntentKind, MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { classifyContinueIntent } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the Continue button intent classifier.
|
||||
@@ -21,23 +21,25 @@ import type { DatabaseMessage } from '$lib/types/database';
|
||||
*/
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
function makeMsg(role: MessageRole, opts: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
nextId++;
|
||||
|
||||
return {
|
||||
id: `msg-${nextId}`,
|
||||
convId: 'conv-1',
|
||||
type: MessageType.TEXT,
|
||||
timestamp: nextId,
|
||||
role,
|
||||
content: '',
|
||||
parent: null,
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: `msg-${nextId}`,
|
||||
parent: null,
|
||||
role,
|
||||
timestamp: nextId,
|
||||
type: MessageType.TEXT,
|
||||
...opts
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, name: string, args: string = '{}'): string {
|
||||
return JSON.stringify([{ id, type: 'function', function: { name, arguments: args } }]);
|
||||
return JSON.stringify([{ function: { arguments: args, name }, id, type: 'function' }]);
|
||||
}
|
||||
|
||||
describe('classifyContinueIntent', () => {
|
||||
@@ -46,7 +48,6 @@ describe('classifyContinueIntent', () => {
|
||||
makeMsg(MessageRole.USER, { content: 'hello' }),
|
||||
makeMsg(MessageRole.ASSISTANT, { content: 'hi there' })
|
||||
];
|
||||
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
@@ -71,7 +72,6 @@ describe('classifyContinueIntent', () => {
|
||||
toolCalls: toolCall('call_1', 'bash_tool', '{"command":"ls"}')
|
||||
})
|
||||
];
|
||||
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.RERUN_TURN, truncateAfter: 0 });
|
||||
@@ -86,7 +86,6 @@ describe('classifyContinueIntent', () => {
|
||||
}),
|
||||
makeMsg(MessageRole.TOOL, { content: 'file1\nfile2', toolCallId: 'call_1' })
|
||||
];
|
||||
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 2 });
|
||||
@@ -98,14 +97,13 @@ describe('classifyContinueIntent', () => {
|
||||
makeMsg(MessageRole.ASSISTANT, {
|
||||
content: '',
|
||||
toolCalls: JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'a', arguments: '{}' } },
|
||||
{ id: 'call_2', type: 'function', function: { name: 'b', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'a' }, id: 'call_1', type: 'function' },
|
||||
{ function: { arguments: '{}', name: 'b' }, id: 'call_2', type: 'function' }
|
||||
])
|
||||
}),
|
||||
makeMsg(MessageRole.TOOL, { content: 'r1', toolCallId: 'call_1' }),
|
||||
makeMsg(MessageRole.TOOL, { content: 'r2', toolCallId: 'call_2' })
|
||||
];
|
||||
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 3 });
|
||||
@@ -122,7 +120,6 @@ describe('classifyContinueIntent', () => {
|
||||
makeMsg(MessageRole.USER, { content: 'wait' }),
|
||||
makeMsg(MessageRole.TOOL, { content: 'late', toolCallId: 'call_1' })
|
||||
];
|
||||
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
// truncateAfter must point at the contiguous tool block, not jump over
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
import { zipSync, strToU8 } from 'fflate';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { NEWLINE } from '$lib/constants';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { ExportedConversation } from '$lib/types/database';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import { beforeAll, describe, expect, it } from 'vitest';
|
||||
|
||||
let conversationsStore: typeof import('$lib/stores/conversations.svelte').conversationsStore;
|
||||
|
||||
@@ -12,12 +12,12 @@ let conversationsStore: typeof import('$lib/stores/conversations.svelte').conver
|
||||
beforeAll(async () => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
@@ -25,6 +25,7 @@ beforeAll(async () => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
|
||||
({ conversationsStore } = await import('$lib/stores/conversations.svelte'));
|
||||
@@ -32,17 +33,17 @@ beforeAll(async () => {
|
||||
|
||||
function makeSession(id: string): ExportedConversation {
|
||||
return {
|
||||
conv: { id, currNode: `${id}-msg`, lastModified: 0, name: `Chat ${id}` },
|
||||
conv: { currNode: `${id}-msg`, id, lastModified: 0, name: `Chat ${id}` },
|
||||
messages: [
|
||||
{
|
||||
id: `${id}-msg`,
|
||||
convId: id,
|
||||
type: MessageType.TEXT,
|
||||
timestamp: 0,
|
||||
role: MessageRole.USER,
|
||||
children: [],
|
||||
content: `hello from ${id}`,
|
||||
convId: id,
|
||||
id: `${id}-msg`,
|
||||
parent: null,
|
||||
children: []
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
}
|
||||
]
|
||||
} as unknown as ExportedConversation;
|
||||
@@ -56,7 +57,6 @@ function makeSession(id: string): ExportedConversation {
|
||||
describe('conversationsStore.parseImportFile', () => {
|
||||
it('imports a JSONL export whose name has no meaningful extension', async () => {
|
||||
const jsonl = conversationsStore.serializeSessionToJsonl(makeSession('a'));
|
||||
|
||||
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export'));
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
@@ -68,7 +68,6 @@ describe('conversationsStore.parseImportFile', () => {
|
||||
const jsonl = [makeSession('a'), makeSession('b')]
|
||||
.map((session) => conversationsStore.serializeSessionToJsonl(session))
|
||||
.join(NEWLINE);
|
||||
|
||||
const sessions = await conversationsStore.parseImportFile(new File([jsonl], 'export.txt'));
|
||||
|
||||
expect(sessions.map((session) => session.conv.id)).toEqual(['a', 'b']);
|
||||
@@ -80,7 +79,6 @@ describe('conversationsStore.parseImportFile', () => {
|
||||
'b.jsonl': strToU8(conversationsStore.serializeSessionToJsonl(makeSession('b'))),
|
||||
'notes.txt': strToU8('ignored')
|
||||
});
|
||||
|
||||
const sessions = await conversationsStore.parseImportFile(new File([zipped], 'archive'));
|
||||
|
||||
expect(sessions.map((session) => session.conv.id).sort()).toEqual(['a', 'b']);
|
||||
@@ -88,7 +86,6 @@ describe('conversationsStore.parseImportFile', () => {
|
||||
|
||||
it('imports the legacy JSON array format', async () => {
|
||||
const json = JSON.stringify([makeSession('a')], null, 2);
|
||||
|
||||
const sessions = await conversationsStore.parseImportFile(new File([json], 'export.jsonl'));
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
@@ -97,7 +94,6 @@ describe('conversationsStore.parseImportFile', () => {
|
||||
|
||||
it('imports the legacy JSON single object format', async () => {
|
||||
const json = JSON.stringify(makeSession('a'));
|
||||
|
||||
const sessions = await conversationsStore.parseImportFile(new File([json], 'export'));
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
colorizeFaviconSvg,
|
||||
padFaviconSvg,
|
||||
writeThemeFavicons
|
||||
} from '../../scripts/favicon-colorize';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const SOURCE_SVG = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">',
|
||||
@@ -19,6 +19,7 @@ const SOURCE_SVG = [
|
||||
describe('colorizeFaviconSvg', () => {
|
||||
it('substitutes every currentColor occurrence for the light variant', () => {
|
||||
const { light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
|
||||
expect(light.match(/currentColor/g)).toBeNull();
|
||||
expect(light).toContain('fill="#111111"');
|
||||
expect(light).toContain('<circle fill="#111111"/>');
|
||||
@@ -26,34 +27,39 @@ describe('colorizeFaviconSvg', () => {
|
||||
|
||||
it('substitutes every currentColor occurrence for the dark variant', () => {
|
||||
const { dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
|
||||
expect(dark.match(/currentColor/g)).toBeNull();
|
||||
expect(dark).toContain('fill="#fafafa"');
|
||||
expect(dark).toContain('<circle fill="#fafafa"/>');
|
||||
});
|
||||
|
||||
it('leaves non-currentColor colors untouched in both variants', () => {
|
||||
const { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
const { dark, light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
|
||||
expect(light).toContain('fill="#ff00aa"');
|
||||
expect(dark).toContain('fill="#ff00aa"');
|
||||
});
|
||||
|
||||
it('does not alter any other part of the SVG', () => {
|
||||
const { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
const { dark, light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
const stripColors = (s: string) =>
|
||||
s.replaceAll('#111111', '').replaceAll('#fafafa', '').replaceAll('currentColor', '');
|
||||
const expected = stripColors(SOURCE_SVG);
|
||||
|
||||
expect(stripColors(light)).toBe(expected);
|
||||
expect(stripColors(dark)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns the same SVG for light and dark when called with the same color', () => {
|
||||
const result = colorizeFaviconSvg(SOURCE_SVG, '#abcdef', '#abcdef');
|
||||
|
||||
expect(result.light).toBe(result.dark);
|
||||
});
|
||||
|
||||
it('returns the source unchanged when given a color that does not appear (no currentColor in source)', () => {
|
||||
const plain = '<svg><path fill="#000"/></svg>';
|
||||
const { light, dark } = colorizeFaviconSvg(plain, '#111111', '#fafafa');
|
||||
const { dark, light } = colorizeFaviconSvg(plain, '#111111', '#fafafa');
|
||||
|
||||
expect(light).toBe(plain);
|
||||
expect(dark).toBe(plain);
|
||||
});
|
||||
@@ -67,6 +73,7 @@ describe('padFaviconSvg', () => {
|
||||
|
||||
it('wraps inner content in a translate-then-scale group that matches padding', () => {
|
||||
const padded = padFaviconSvg(SIZED_SVG, 0.05);
|
||||
|
||||
// scale = 1 - 0.05 = 0.95
|
||||
// translate = (0.05 * 512) / 2 = 12.8 on each axis
|
||||
expect(padded).toContain('transform="translate(12.8 12.8) scale(0.95)"');
|
||||
@@ -77,6 +84,7 @@ describe('padFaviconSvg', () => {
|
||||
|
||||
it('preserves the outer <svg> tag attributes', () => {
|
||||
const padded = padFaviconSvg(SIZED_SVG, 0.1);
|
||||
|
||||
expect(padded.startsWith('<svg width="512" height="512" viewBox="0 0 512 512"')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -92,17 +100,20 @@ describe('padFaviconSvg', () => {
|
||||
|
||||
it('returns the input unchanged when no viewBox is present', () => {
|
||||
const noViewBox = '<svg width="32" height="32"><path d="M0 0Z"/></svg>';
|
||||
|
||||
expect(padFaviconSvg(noViewBox, 0.1)).toBe(noViewBox);
|
||||
});
|
||||
|
||||
it('returns the input unchanged when viewBox values are not finite numbers', () => {
|
||||
const bad = '<svg viewBox="auto auto 0 0"><path/></svg>';
|
||||
|
||||
expect(padFaviconSvg(bad, 0.1)).toBe(bad);
|
||||
});
|
||||
|
||||
it('tolerates a non-square viewBox', () => {
|
||||
const wide = '<svg viewBox="0 0 100 50"><rect/></svg>';
|
||||
const padded = padFaviconSvg(wide, 0.1);
|
||||
|
||||
// scale 0.9, translate (5, 2.5)
|
||||
expect(padded).toContain('transform="translate(5 2.5) scale(0.9)"');
|
||||
});
|
||||
@@ -121,27 +132,29 @@ describe('writeThemeFavicons', () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
rmSync(tmpDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
function setupSource() {
|
||||
const sourcePath = join(tmpDir, 'logo.svg');
|
||||
|
||||
writeFileSync(sourcePath, LOGO);
|
||||
|
||||
return {
|
||||
sourcePath,
|
||||
darkPath: join(tmpDir, 'favicon-dark.svg'),
|
||||
lightPath: join(tmpDir, 'favicon.svg'),
|
||||
darkPath: join(tmpDir, 'favicon-dark.svg')
|
||||
sourcePath
|
||||
};
|
||||
}
|
||||
|
||||
it('writes colorized, un-padded favicons without modifying the source', () => {
|
||||
const { sourcePath, lightPath, darkPath } = setupSource();
|
||||
const { darkPath, lightPath, sourcePath } = setupSource();
|
||||
const before = readFileSync(sourcePath, 'utf-8');
|
||||
|
||||
writeThemeFavicons('#abcdef', '#012345', {
|
||||
sourcePath,
|
||||
darkOutPath: darkPath,
|
||||
lightOutPath: lightPath,
|
||||
darkOutPath: darkPath
|
||||
sourcePath
|
||||
});
|
||||
|
||||
const lightOut = readFileSync(lightPath, 'utf-8');
|
||||
@@ -162,17 +175,17 @@ describe('writeThemeFavicons', () => {
|
||||
});
|
||||
|
||||
it('writes colorized favicons wrapped in a padding <g transform>...</g>', () => {
|
||||
const { sourcePath, lightPath, darkPath } = setupSource();
|
||||
const { darkPath, lightPath, sourcePath } = setupSource();
|
||||
// mirror the production wiring: PWA_ASSET_GENERATOR.FAVICON_PADDING
|
||||
const padding = 0.04;
|
||||
// scale = 1 - 0.04 = 0.96; translate = (0.04 * 512) / 2 = 10.24
|
||||
const expectedTransform = 'transform="translate(10.24 10.24) scale(0.96)"';
|
||||
|
||||
writeThemeFavicons('#111111', '#fafafa', {
|
||||
sourcePath,
|
||||
lightOutPath: lightPath,
|
||||
darkOutPath: darkPath,
|
||||
padding
|
||||
lightOutPath: lightPath,
|
||||
padding,
|
||||
sourcePath
|
||||
});
|
||||
|
||||
const lightOut = readFileSync(lightPath, 'utf-8');
|
||||
|
||||
@@ -4,8 +4,8 @@ vi.mock('$lib/services/tools.service', () => ({
|
||||
ToolsService: { executeToolRaw: vi.fn() }
|
||||
}));
|
||||
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { GlobSearchType } from '$lib/enums';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { runGlobSearchWithChildren } from '$lib/utils';
|
||||
|
||||
const mockExecute = vi.mocked(ToolsService.executeToolRaw);
|
||||
@@ -32,6 +32,7 @@ describe('runGlobSearchWithChildren', () => {
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootA/note.md', '/Users/rootA/src']);
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
@@ -54,8 +55,9 @@ describe('runGlobSearchWithChildren', () => {
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
{ descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.exactDir).toBe('/Users/rootB/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual([
|
||||
@@ -77,8 +79,9 @@ describe('runGlobSearchWithChildren', () => {
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.ALL, descendOnTrailingSeparator: true }
|
||||
{ descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
|
||||
);
|
||||
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -98,6 +101,7 @@ describe('runGlobSearchWithChildren', () => {
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.DIR }
|
||||
);
|
||||
|
||||
expect(res.exactDir).toBe('/Users/rootD/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootD/src', '/Users/rootD/src/a.txt']);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
@@ -112,6 +116,7 @@ describe('runGlobSearchWithChildren', () => {
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(res.error).toBe('boom');
|
||||
expect(res.entries).toEqual([]);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils/headers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the header serialization helpers used by the MCP server form
|
||||
@@ -72,7 +72,7 @@ describe('serializeHeaders', () => {
|
||||
|
||||
// Object key order follows insertion order in modern JS engines, so
|
||||
// the serialized JSON writes keys in our input order.
|
||||
expect(JSON.parse(serialized)).toEqual({ 'X-C': '3', 'X-A': '1', 'X-B': '2' });
|
||||
expect(JSON.parse(serialized)).toEqual({ 'X-A': '1', 'X-B': '2', 'X-C': '3' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,7 +82,6 @@ describe('parseHeadersToArray / serializeHeaders roundtrip', () => {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Trace-Id': 'abc-123'
|
||||
});
|
||||
|
||||
const roundtrip = serializeHeaders(parseHeadersToArray(original));
|
||||
|
||||
expect(JSON.parse(roundtrip)).toEqual(JSON.parse(original));
|
||||
@@ -102,7 +101,6 @@ describe('parseHeadersToArray / serializeHeaders roundtrip', () => {
|
||||
|
||||
it('preserves upstream keys untouched (does not lowercase them)', () => {
|
||||
const upperCased = '{"Authorization":"Bearer xyz"}';
|
||||
|
||||
const parsed = parseHeadersToArray(upperCased);
|
||||
|
||||
expect(parsed).toEqual([{ key: 'Authorization', value: 'Bearer xyz' }]);
|
||||
@@ -117,7 +115,6 @@ describe('parseHeadersToArray / serializeHeaders roundtrip', () => {
|
||||
{ key: 'X-Trace-Id', value: 'abc-123' },
|
||||
{ key: 'Authorization', value: 'Bearer super-secret' }
|
||||
];
|
||||
|
||||
const serialized = serializeHeaders(pairs);
|
||||
|
||||
expect(serialized).toBe('{"X-Trace-Id":"abc-123","Authorization":"Bearer super-secret"}');
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getJpegOrientationFromDataURL, isJpegMimeType } from '$lib/utils/jpeg-orientation';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Builds the TIFF payload of an APP1 segment holding a single IFD0 entry
|
||||
function buildTiff(littleEndian: boolean, tag: number, value: number): number[] {
|
||||
@@ -36,6 +36,7 @@ function buildJpegDataURL(tiff: number[] | null, prependApp0 = false): string {
|
||||
if (tiff) {
|
||||
const payload = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00, ...tiff];
|
||||
const length = payload.length + 2;
|
||||
|
||||
bytes.push(0xff, 0xe1, length >> 8, length & 0xff, ...payload);
|
||||
}
|
||||
|
||||
@@ -74,11 +75,13 @@ describe('getJpegOrientationFromDataURL', () => {
|
||||
|
||||
it('returns 1 for a payload that is not a JPEG', () => {
|
||||
const png = btoa(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a));
|
||||
|
||||
expect(getJpegOrientationFromDataURL(`data:image/png;base64,${png}`)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for a truncated payload', () => {
|
||||
const truncated = btoa(String.fromCharCode(0xff, 0xd8, 0xff));
|
||||
|
||||
expect(getJpegOrientationFromDataURL(`data:image/jpeg;base64,${truncated}`)).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { describe, it, expect, test } from 'vitest';
|
||||
import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection';
|
||||
import { describe, expect, it, test } from 'vitest';
|
||||
|
||||
describe('maskInlineLaTeX', () => {
|
||||
it('should protect LaTeX $x + y$ but not money $3.99', () => {
|
||||
@@ -126,6 +126,7 @@ describe('preprocessLaTeX', () => {
|
||||
const input =
|
||||
'\\( \\mathrm{GL}_2(\\mathbb{F}_7) \\): Group of invertible matrices with entries in \\(\\mathbb{F}_7\\).';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'$ \\mathrm{GL}_2(\\mathbb{F}_7) $: Group of invertible matrices with entries in $\\mathbb{F}_7$.'
|
||||
);
|
||||
@@ -135,6 +136,7 @@ describe('preprocessLaTeX', () => {
|
||||
const input =
|
||||
'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula \\((x_1,\\ldots,x_n)\\).';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula $(x_1,\\ldots,x_n)$.'
|
||||
);
|
||||
@@ -229,6 +231,7 @@ h \\bigl[\\, d_{\\text{model}}\\;d_{k} + d_{\\text{model}}\\;d_{v}\\, \\bigr]
|
||||
\\end{aligned}}
|
||||
\\]`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
`$$
|
||||
\\boxed{
|
||||
@@ -280,6 +283,7 @@ $$\pi_n(\mathbb{S}^3) = \begin{cases}
|
||||
\mathbb{Z}_2 & n = 4 \\
|
||||
\end{cases}$$`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
// If the formula contains '\\' the $$-delimiters should be in their own line.
|
||||
expect(output).toBe(`- Algebraic topology, Homotopy Groups of $\\mathbb{S}^3$:
|
||||
$$\n\\pi_n(\\mathbb{S}^3) = \\begin{cases}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY, STORAGE_APP_NAME } from '$lib/constants';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { STORAGE_APP_NAME, CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
|
||||
// node env unit project has no DOM, install a minimal localStorage backed by a Map
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
@@ -18,6 +18,7 @@ beforeAll(() => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
});
|
||||
|
||||
@@ -45,6 +46,7 @@ describe('mcp-default-overrides-merge-v1 migration', () => {
|
||||
|
||||
async function runMigrations() {
|
||||
const { MigrationService } = await import('$lib/services/migration.service');
|
||||
|
||||
await MigrationService.runAllMigrations();
|
||||
}
|
||||
|
||||
@@ -60,13 +62,13 @@ describe('mcp-default-overrides-merge-v1 migration', () => {
|
||||
|
||||
it('applies matching overrides onto mcpServers[i].enabled and preserves the legacy key', async () => {
|
||||
writeConfig({
|
||||
mcpServers: JSON.stringify([
|
||||
{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' },
|
||||
{ id: 'hf', enabled: false, url: 'https://huggingface.co/mcp' }
|
||||
]),
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
|
||||
{ serverId: 'exa', enabled: true },
|
||||
{ serverId: 'hf', enabled: false }
|
||||
{ enabled: true, serverId: 'exa' },
|
||||
{ enabled: false, serverId: 'hf' }
|
||||
]),
|
||||
mcpServers: JSON.stringify([
|
||||
{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' },
|
||||
{ enabled: false, id: 'hf', url: 'https://huggingface.co/mcp' }
|
||||
])
|
||||
});
|
||||
|
||||
@@ -85,11 +87,11 @@ describe('mcp-default-overrides-merge-v1 migration', () => {
|
||||
|
||||
it('skips override ids that do not match any configured server', async () => {
|
||||
writeConfig({
|
||||
mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
|
||||
{ serverId: 'orphan', enabled: true },
|
||||
{ serverId: 'exa', enabled: true }
|
||||
])
|
||||
{ enabled: true, serverId: 'orphan' },
|
||||
{ enabled: true, serverId: 'exa' }
|
||||
]),
|
||||
mcpServers: JSON.stringify([{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
|
||||
});
|
||||
|
||||
await runMigrations();
|
||||
@@ -107,7 +109,7 @@ describe('mcp-default-overrides-merge-v1 migration', () => {
|
||||
|
||||
it('is a no-op when there are no legacy overrides', async () => {
|
||||
writeConfig({
|
||||
mcpServers: JSON.stringify([{ id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }])
|
||||
mcpServers: JSON.stringify([{ enabled: true, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
|
||||
});
|
||||
|
||||
await runMigrations();
|
||||
@@ -124,25 +126,26 @@ describe('mcp-default-overrides-merge-v1 migration', () => {
|
||||
|
||||
it('does not rewrite mcpServers when override.enabled already matches', async () => {
|
||||
const originalServers = JSON.stringify([
|
||||
{ id: 'exa', enabled: true, url: 'https://mcp.exa.ai/mcp' }
|
||||
{ enabled: true, id: 'exa', url: 'https://mcp.exa.ai/mcp' }
|
||||
]);
|
||||
|
||||
writeConfig({
|
||||
mcpServers: originalServers,
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ enabled: true, serverId: 'exa' }]),
|
||||
mcpServers: originalServers
|
||||
});
|
||||
|
||||
await runMigrations();
|
||||
|
||||
const after = readConfig();
|
||||
|
||||
expect(after.mcpServers).toBe(originalServers);
|
||||
expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
|
||||
});
|
||||
|
||||
it('records itself as completed so subsequent loads do not re-run', async () => {
|
||||
writeConfig({
|
||||
mcpServers: JSON.stringify([{ id: 'exa', enabled: false, url: 'https://mcp.exa.ai/mcp' }]),
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ serverId: 'exa', enabled: true }])
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ enabled: true, serverId: 'exa' }]),
|
||||
mcpServers: JSON.stringify([{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
|
||||
});
|
||||
|
||||
const { MigrationService } = await import('$lib/services/migration.service');
|
||||
@@ -150,8 +153,10 @@ describe('mcp-default-overrides-merge-v1 migration', () => {
|
||||
await MigrationService.runAllMigrations();
|
||||
|
||||
const stateRaw = localStorage.getItem(MIGRATION_STATE_KEY);
|
||||
|
||||
expect(stateRaw).not.toBeNull();
|
||||
const state = JSON.parse(stateRaw!) as { completed: string[]; failed: string[] };
|
||||
|
||||
expect(state.completed).toContain('mcp-default-overrides-merge-v1');
|
||||
expect(state.failed).not.toContain('mcp-default-overrides-merge-v1');
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||
import type { DatabaseConversation } from '$lib/types/database';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// node env unit project has no DOM, install a minimal localStorage backed by a Map
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
@@ -20,6 +20,7 @@ beforeAll(() => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
});
|
||||
|
||||
@@ -37,8 +38,8 @@ describe('conversationsStore MCP override resolution', () => {
|
||||
CONFIG_LOCALSTORAGE_KEY,
|
||||
JSON.stringify({
|
||||
[SETTINGS_KEYS.MCP_SERVERS]: JSON.stringify([
|
||||
{ id: 'alpha', enabled: false, url: 'https://alpha.example.com/mcp' },
|
||||
{ id: 'bravo', enabled: true, url: 'https://bravo.example.com/mcp' }
|
||||
{ enabled: false, id: 'alpha', url: 'https://alpha.example.com/mcp' },
|
||||
{ enabled: true, id: 'bravo', url: 'https://bravo.example.com/mcp' }
|
||||
])
|
||||
})
|
||||
);
|
||||
@@ -49,6 +50,7 @@ describe('conversationsStore MCP override resolution', () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}';
|
||||
const saved = JSON.parse(raw) as Record<string, unknown>;
|
||||
|
||||
settingsStore.config = {
|
||||
...settingsStore.config,
|
||||
[SETTINGS_KEYS.MCP_SERVERS]: saved[SETTINGS_KEYS.MCP_SERVERS]
|
||||
@@ -63,16 +65,17 @@ describe('conversationsStore MCP override resolution', () => {
|
||||
overrides?: { serverId: string; enabled: boolean }[]
|
||||
): DatabaseConversation {
|
||||
return {
|
||||
id: 'conv-1',
|
||||
currNode: null,
|
||||
id: 'conv-1',
|
||||
lastModified: 0,
|
||||
name: 'Test chat',
|
||||
mcpServerOverrides: overrides
|
||||
mcpServerOverrides: overrides,
|
||||
name: 'Test chat'
|
||||
};
|
||||
}
|
||||
|
||||
it('inherits server.enabled when no conversation is active', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
conversationsStore.activeConversation = null;
|
||||
|
||||
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
|
||||
@@ -81,6 +84,7 @@ describe('conversationsStore MCP override resolution', () => {
|
||||
|
||||
it('inherits server.enabled on a newly created chat with no overrides', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation();
|
||||
|
||||
// Empty override list: must fall back to global server.enabled, not all-off.
|
||||
@@ -90,6 +94,7 @@ describe('conversationsStore MCP override resolution', () => {
|
||||
|
||||
it('inherits server.enabled on a newly created chat when overrides is undefined', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation(undefined);
|
||||
|
||||
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
|
||||
@@ -98,9 +103,10 @@ describe('conversationsStore MCP override resolution', () => {
|
||||
|
||||
it('uses explicit per-chat overrides, with defaults for non-overridden servers', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
// Override flips bravo off for this chat, alpha keeps its global default.
|
||||
conversationsStore.activeConversation = makeConversation([
|
||||
{ serverId: 'bravo', enabled: false }
|
||||
{ enabled: false, serverId: 'bravo' }
|
||||
]);
|
||||
|
||||
expect(conversationsStore.isMcpServerEnabledForChat('alpha')).toBe(false);
|
||||
@@ -109,35 +115,38 @@ describe('conversationsStore MCP override resolution', () => {
|
||||
|
||||
it('getAllMcpServerOverrides returns a complete list merged from defaults', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation([
|
||||
{ serverId: 'alpha', enabled: true }
|
||||
{ enabled: true, serverId: 'alpha' }
|
||||
]);
|
||||
|
||||
expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
|
||||
{ serverId: 'alpha', enabled: true },
|
||||
{ serverId: 'bravo', enabled: true }
|
||||
{ enabled: true, serverId: 'alpha' },
|
||||
{ enabled: true, serverId: 'bravo' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('getAllMcpServerOverrides falls back to defaults when there are no explicit overrides', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation();
|
||||
|
||||
expect(conversationsStore.getAllMcpServerOverrides()).toEqual([
|
||||
{ serverId: 'alpha', enabled: false },
|
||||
{ serverId: 'bravo', enabled: true }
|
||||
{ enabled: false, serverId: 'alpha' },
|
||||
{ enabled: true, serverId: 'bravo' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('getMcpServerOverride returns the global default when the server has no explicit override', async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
conversationsStore.activeConversation = makeConversation([
|
||||
{ serverId: 'alpha', enabled: true }
|
||||
{ enabled: true, serverId: 'alpha' }
|
||||
]);
|
||||
|
||||
expect(conversationsStore.getMcpServerOverride('bravo')).toEqual({
|
||||
serverId: 'bravo',
|
||||
enabled: true
|
||||
enabled: true,
|
||||
serverId: 'bravo'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SETTINGS_KEYS } from '$lib/constants/settings-keys';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Default-value policy for the `MCP_SERVERS` setting.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
|
||||
import type { MCPConnectionLog, MCPServerConfig } from '$lib/types';
|
||||
import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
|
||||
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import type { MCPConnectionLog, MCPServerConfig } from '$lib/types';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type DiagnosticFetchFactory = (
|
||||
serverName: string,
|
||||
@@ -33,25 +33,24 @@ describe('MCPService', () => {
|
||||
it('stops transport phase logging after handshake diagnostics are disabled', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, { method: 'POST', body: '{}' });
|
||||
await controller.fetch(config.url, { body: '{}', method: 'POST' });
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true);
|
||||
|
||||
controller.disable();
|
||||
await controller.fetch(config.url, { method: 'POST', body: '{}' });
|
||||
await controller.fetch(config.url, { body: '{}', method: 'POST' });
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
});
|
||||
@@ -59,38 +58,37 @@ describe('MCPService', () => {
|
||||
it('redacts all configured custom headers in diagnostic request logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
headers: {
|
||||
'x-auth-token': 'secret-token',
|
||||
'x-vendor-api-key': 'secret-key'
|
||||
}
|
||||
},
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {
|
||||
headers: config.headers
|
||||
});
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}'
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-auth-token': '[redacted]',
|
||||
'x-vendor-api-key': '[redacted]',
|
||||
'content-type': 'application/json'
|
||||
'x-vendor-api-key': '[redacted]'
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -102,19 +100,18 @@ describe('MCPService', () => {
|
||||
const proxiedContentType = `${CORS_PROXY_HEADER_PREFIX}content-type`;
|
||||
const proxiedSessionId = `${CORS_PROXY_HEADER_PREFIX}mcp-session-id`;
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(response);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp',
|
||||
useProxy: true
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(
|
||||
config,
|
||||
(log) => logs.push(log),
|
||||
@@ -128,15 +125,16 @@ describe('MCPService', () => {
|
||||
);
|
||||
|
||||
await controller.fetch('http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp', {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-request-12345'
|
||||
},
|
||||
body: '{}'
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const sentHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
|
||||
|
||||
expect(sentHeaders.get('authorization')).toBe('Bearer llama-server-key');
|
||||
expect(sentHeaders.get(proxiedAuthToken)).toBe('target-token');
|
||||
expect(sentHeaders.get(proxiedContentType)).toBe('application/json');
|
||||
@@ -161,13 +159,11 @@ describe('MCPService', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp',
|
||||
useProxy: true
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {}, true);
|
||||
|
||||
const response = await controller.fetch(
|
||||
'http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp',
|
||||
{ method: 'DELETE' }
|
||||
@@ -176,36 +172,35 @@ describe('MCPService', () => {
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
expect(logs.at(-1)?.details).toMatchObject({
|
||||
response: { status: 200, isFake: true }
|
||||
response: { isFake: true, status: 200 }
|
||||
});
|
||||
});
|
||||
|
||||
it('partially redacts mcp-session-id in diagnostic request and response logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-response-67890'
|
||||
}
|
||||
},
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
body: '{}',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-request-12345'
|
||||
},
|
||||
body: '{}'
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
@@ -230,35 +225,34 @@ describe('MCPService', () => {
|
||||
it('extracts JSON-RPC methods without logging the raw request body', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ id: 1, jsonrpc: '2.0', method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
])
|
||||
]),
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
method: 'POST',
|
||||
body: {
|
||||
kind: 'string',
|
||||
size: expect.any(Number)
|
||||
},
|
||||
jsonRpcMethods: ['initialize', 'notifications/initialized']
|
||||
jsonRpcMethods: ['initialize', 'notifications/initialized'],
|
||||
method: 'POST'
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -270,13 +264,12 @@ describe('MCPService', () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'http://localhost:8000/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'http://localhost:8000/mcp'
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await expect(controller.fetch(config.url, { method: 'POST', body: '{}' })).rejects.toThrow(
|
||||
await expect(controller.fetch(config.url, { body: '{}', method: 'POST' })).rejects.toThrow(
|
||||
'Failed to fetch'
|
||||
);
|
||||
|
||||
@@ -289,12 +282,13 @@ describe('MCPService', () => {
|
||||
it('detaches phase error logging after the initialize handshake completes', async () => {
|
||||
const phaseLogs: Array<{ phase: MCPConnectionPhase; log: MCPConnectionLog }> = [];
|
||||
const stopPhaseLogging = vi.fn();
|
||||
|
||||
let emitClientError: ((error: Error) => void) | undefined;
|
||||
|
||||
vi.spyOn(MCPService, 'createTransport').mockReturnValue({
|
||||
stopPhaseLogging,
|
||||
transport: {} as never,
|
||||
type: MCPTransportType.WEBSOCKET,
|
||||
stopPhaseLogging
|
||||
type: MCPTransportType.WEBSOCKET
|
||||
});
|
||||
vi.spyOn(MCPService, 'listTools').mockResolvedValue([]);
|
||||
vi.spyOn(Client.prototype, 'getServerVersion').mockReturnValue(undefined);
|
||||
@@ -308,18 +302,18 @@ describe('MCPService', () => {
|
||||
await MCPService.connect(
|
||||
'test-server',
|
||||
{
|
||||
url: 'ws://example.com/mcp',
|
||||
transport: MCPTransportType.WEBSOCKET
|
||||
transport: MCPTransportType.WEBSOCKET,
|
||||
url: 'ws://example.com/mcp'
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
(phase, log) => phaseLogs.push({ phase, log })
|
||||
(phase, log) => phaseLogs.push({ log, phase })
|
||||
);
|
||||
|
||||
expect(stopPhaseLogging).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ phase, log }) =>
|
||||
({ log, phase }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: handshake protocol error'
|
||||
)
|
||||
@@ -329,7 +323,7 @@ describe('MCPService', () => {
|
||||
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ phase, log }) =>
|
||||
({ log, phase }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: runtime protocol error'
|
||||
)
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
import {
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS,
|
||||
buildMentionInsertion,
|
||||
containsFileMentionLink,
|
||||
decodeFileLinkPath,
|
||||
encodeFileLinkPath,
|
||||
fileMentionLinkRe,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel
|
||||
getMentionBadgeLabel,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/utils';
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('encodeFileLinkPath', () => {
|
||||
it('leaves a clean path unchanged', () => {
|
||||
@@ -47,6 +47,7 @@ describe('fileMentionLinkRe', () => {
|
||||
const match = fileMentionLinkRe().exec(
|
||||
'[Screenshot (1).png](file:///Users/foo/Screenshot (1).png)'
|
||||
);
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1]).toBe('Screenshot (1).png');
|
||||
expect(match?.[2]).toBe('/Users/foo/Screenshot (1).png');
|
||||
@@ -121,24 +122,26 @@ describe('decodeFileLinkPath', () => {
|
||||
|
||||
describe('buildMentionInsertion', () => {
|
||||
const file = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
path,
|
||||
type: FileMentionEntryType.FILE
|
||||
});
|
||||
const dir = (path: string, name: string) => ({
|
||||
path,
|
||||
name,
|
||||
path,
|
||||
type: FileMentionEntryType.DIRECTORY
|
||||
});
|
||||
|
||||
it('splices a root-anchored file link in place of the token', () => {
|
||||
const value = 'hello @repo';
|
||||
const result = buildMentionInsertion(file('/Users/foo/myRepo', 'myRepo'), value, {
|
||||
start: 6,
|
||||
end: 11
|
||||
end: 11,
|
||||
start: 6
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const { newValue, caretOffset } = result!;
|
||||
const { caretOffset, newValue } = result!;
|
||||
|
||||
expect(newValue).toBe('hello [myRepo](file:///Users/foo/myRepo) ');
|
||||
expect(caretOffset).toBe(6 + '[myRepo](file:///Users/foo/myRepo) '.length);
|
||||
});
|
||||
@@ -146,9 +149,10 @@ describe('buildMentionInsertion', () => {
|
||||
it('keeps the trailing slash on the directory marker', () => {
|
||||
const value = 'see @src';
|
||||
const { newValue } = buildMentionInsertion(dir('/Users/foo/myRepo/src/', 'src'), value, {
|
||||
start: 4,
|
||||
end: 8
|
||||
end: 8,
|
||||
start: 4
|
||||
})!;
|
||||
|
||||
expect(newValue).toBe('see [src](file:///Users/foo/myRepo/src/) ');
|
||||
});
|
||||
|
||||
@@ -157,18 +161,20 @@ describe('buildMentionInsertion', () => {
|
||||
const { newValue } = buildMentionInsertion(
|
||||
file('/Users/foo/Desktop/Pic (1).png', 'Pic (1).png'),
|
||||
value,
|
||||
{ start: 0, end: 4 }
|
||||
{ end: 4, start: 0 }
|
||||
)!;
|
||||
|
||||
expect(newValue).toBe('[Pic (1).png](file:///Users/foo/Desktop/Pic%20(1).png) ');
|
||||
});
|
||||
|
||||
it('re-adds the directory marker when the cleaned path empties', () => {
|
||||
const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { start: 0, end: 1 })!;
|
||||
const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { end: 1, start: 0 })!;
|
||||
|
||||
expect(newValue).toBe('[root](file:///) ');
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range token', () => {
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 0, end: 5 })).toBeNull();
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { start: 2, end: 1 })).toBeNull();
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { end: 5, start: 0 })).toBeNull();
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { end: 1, start: 2 })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { findMentionToken, takeMentionDismissSnapshot } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('findMentionToken', () => {
|
||||
it('returns null for an empty/bare cursor', () => {
|
||||
@@ -8,11 +8,11 @@ describe('findMentionToken', () => {
|
||||
});
|
||||
|
||||
it('recognizes a mention at the start of the value', () => {
|
||||
expect(findMentionToken('@pr', 3)).toEqual({ start: 0, end: 3, query: 'pr' });
|
||||
expect(findMentionToken('@pr', 3)).toEqual({ end: 3, query: 'pr', start: 0 });
|
||||
});
|
||||
|
||||
it('recognizes a mention after a word boundary', () => {
|
||||
expect(findMentionToken('hello @pr', 9)).toEqual({ start: 6, end: 9, query: 'pr' });
|
||||
expect(findMentionToken('hello @pr', 9)).toEqual({ end: 9, query: 'pr', start: 6 });
|
||||
});
|
||||
|
||||
it('returns null when the @ is mid-identifier', () => {
|
||||
@@ -25,9 +25,9 @@ describe('findMentionToken', () => {
|
||||
});
|
||||
|
||||
it('treats boundary characters (parens, brackets, comma) as token starts', () => {
|
||||
expect(findMentionToken('(@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
|
||||
expect(findMentionToken('[@pr', 4)).toEqual({ start: 1, end: 4, query: 'pr' });
|
||||
expect(findMentionToken('a,@pr', 5)).toEqual({ start: 2, end: 5, query: 'pr' });
|
||||
expect(findMentionToken('(@pr', 4)).toEqual({ end: 4, query: 'pr', start: 1 });
|
||||
expect(findMentionToken('[@pr', 4)).toEqual({ end: 4, query: 'pr', start: 1 });
|
||||
expect(findMentionToken('a,@pr', 5)).toEqual({ end: 5, query: 'pr', start: 2 });
|
||||
});
|
||||
|
||||
it('does not treat an identifier character as a boundary', () => {
|
||||
@@ -35,17 +35,17 @@ describe('findMentionToken', () => {
|
||||
});
|
||||
|
||||
it('extracts the whole token up to the trailing boundary as the query', () => {
|
||||
expect(findMentionToken('@', 1)).toEqual({ start: 0, end: 1, query: '' });
|
||||
expect(findMentionToken('@hello', 6)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
expect(findMentionToken('@', 1)).toEqual({ end: 1, query: '', start: 0 });
|
||||
expect(findMentionToken('@hello', 6)).toEqual({ end: 6, query: 'hello', start: 0 });
|
||||
});
|
||||
|
||||
it('keeps the whole token as the query when the caret is mid-token', () => {
|
||||
expect(findMentionToken('@hello', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
expect(findMentionToken('@hello world', 4)).toEqual({ start: 0, end: 6, query: 'hello' });
|
||||
expect(findMentionToken('@hello', 4)).toEqual({ end: 6, query: 'hello', start: 0 });
|
||||
expect(findMentionToken('@hello world', 4)).toEqual({ end: 6, query: 'hello', start: 0 });
|
||||
});
|
||||
|
||||
it('ignores a boundary @ and keeps the most recent token', () => {
|
||||
expect(findMentionToken('a @foo @bar', 11)).toEqual({ start: 7, end: 11, query: 'bar' });
|
||||
expect(findMentionToken('a @foo @bar', 11)).toEqual({ end: 11, query: 'bar', start: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,8 +57,8 @@ describe('takeMentionDismissSnapshot', () => {
|
||||
|
||||
it('captures start and query of the current mention', () => {
|
||||
expect(takeMentionDismissSnapshot('hello @proj', 11)).toEqual({
|
||||
start: 6,
|
||||
query: 'proj'
|
||||
query: 'proj',
|
||||
start: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const { parseModelId } = ModelsService;
|
||||
|
||||
@@ -244,16 +244,16 @@ describe('parseModelId', () => {
|
||||
it('handles ambiguous model names', () => {
|
||||
// Qwen3.5 Instruct vs Thinking — tags should distinguish them
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Instruct')).toMatchObject({
|
||||
activatedParams: 'A3B',
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
activatedParams: 'A3B',
|
||||
tags: ['Instruct']
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Thinking')).toMatchObject({
|
||||
activatedParams: 'A3B',
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
activatedParams: 'A3B',
|
||||
tags: ['Thinking']
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isValidModelName, normalizeModelName } from '$lib/utils/model-names';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('normalizeModelName', () => {
|
||||
it('preserves Hugging Face org/model format (single slash)', () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isExitCodeSummaryLine, parseExecShellCommandExitStatus } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('parseExecShellCommandExitStatus', () => {
|
||||
it('returns undefined when result is empty', () => {
|
||||
@@ -9,15 +9,17 @@ describe('parseExecShellCommandExitStatus', () => {
|
||||
|
||||
it('parses a zero-exit summary at end of clean stdout', () => {
|
||||
const status = parseExecShellCommandExitStatus('hello world\n[exit code: 0]');
|
||||
|
||||
expect(status).toEqual({
|
||||
code: 0,
|
||||
timedOut: false,
|
||||
rawText: '[exit code: 0]'
|
||||
rawText: '[exit code: 0]',
|
||||
timedOut: false
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a non-zero exit summary', () => {
|
||||
const status = parseExecShellCommandExitStatus('cargo: error[E0425]\n[exit code: 101]');
|
||||
|
||||
expect(status?.code).toBe(101);
|
||||
expect(status?.timedOut).toBe(false);
|
||||
});
|
||||
@@ -26,12 +28,14 @@ describe('parseExecShellCommandExitStatus', () => {
|
||||
const status = parseExecShellCommandExitStatus(
|
||||
'still building...\n[exit code: -1] [exit due to timed out]'
|
||||
);
|
||||
|
||||
expect(status?.code).toBe(-1);
|
||||
expect(status?.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates trailing whitespace after the tail line', () => {
|
||||
const status = parseExecShellCommandExitStatus('[exit code: 0] \n\n');
|
||||
|
||||
expect(status?.code).toBe(0);
|
||||
});
|
||||
|
||||
@@ -41,11 +45,13 @@ describe('parseExecShellCommandExitStatus', () => {
|
||||
const status = parseExecShellCommandExitStatus(
|
||||
'the shell prints [exit code: 0]\nwhen done\nreally done\n'
|
||||
);
|
||||
|
||||
expect(status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not match mid-stream exit lines followed by more output', () => {
|
||||
const status = parseExecShellCommandExitStatus('[exit code: 0]\nmore output keeps streaming');
|
||||
|
||||
expect(status).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||
import { MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the mcpServers settings parser.
|
||||
@@ -36,7 +36,7 @@ describe('parseMcpServerSettings', () => {
|
||||
|
||||
it('drops entries with no parseable id and substitutes a stable fallback', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([{ url: 'https://a.test', enabled: true }, { url: 'https://b.test' }])
|
||||
JSON.stringify([{ enabled: true, url: 'https://a.test' }, { url: 'https://b.test' }])
|
||||
);
|
||||
|
||||
expect(parsed).toHaveLength(2);
|
||||
@@ -64,7 +64,7 @@ describe('parseMcpServerSettings', () => {
|
||||
// making the Settings value a no-op for existing servers. The
|
||||
// parser drops the field so the global applies live everywhere.
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([{ id: 'a', url: 'https://a.test', requestTimeoutSeconds: 45 }])
|
||||
JSON.stringify([{ id: 'a', requestTimeoutSeconds: 45, url: 'https://a.test' }])
|
||||
);
|
||||
|
||||
expect(parsed[0]).not.toHaveProperty('requestTimeoutSeconds');
|
||||
@@ -73,8 +73,8 @@ describe('parseMcpServerSettings', () => {
|
||||
it('treats whitespace-only headers strings as undefined', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'a', url: 'https://a.test', headers: ' ' },
|
||||
{ id: 'b', url: 'https://b.test', headers: '{"X-Foo":"bar"}' }
|
||||
{ headers: ' ', id: 'a', url: 'https://a.test' },
|
||||
{ headers: '{"X-Foo":"bar"}', id: 'b', url: 'https://b.test' }
|
||||
])
|
||||
);
|
||||
|
||||
@@ -87,8 +87,8 @@ describe('parseMcpServerSettings', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'a', url: 'https://a.test' },
|
||||
{ id: 'b', url: 'https://b.test', enabled: true },
|
||||
{ id: 'c', url: 'https://c.test', enabled: false },
|
||||
{ enabled: true, id: 'b', url: 'https://b.test' },
|
||||
{ enabled: false, id: 'c', url: 'https://c.test' },
|
||||
{ id: 'd', url: 'https://d.test', useProxy: true }
|
||||
])
|
||||
);
|
||||
@@ -107,8 +107,8 @@ describe('parseMcpServerSettings', () => {
|
||||
// contain disabled entries.
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'on', url: 'https://on.test', enabled: true },
|
||||
{ id: 'off', url: 'https://off.test', enabled: false }
|
||||
{ enabled: true, id: 'on', url: 'https://on.test' },
|
||||
{ enabled: false, id: 'off', url: 'https://off.test' }
|
||||
])
|
||||
);
|
||||
|
||||
@@ -122,7 +122,6 @@ describe('parseMcpServerSettings', () => {
|
||||
{ id: 'alpha', url: 'https://a.test' },
|
||||
{ id: 'beta', url: 'https://b.test' }
|
||||
];
|
||||
|
||||
const parsed = parseMcpServerSettings(JSON.stringify(source));
|
||||
|
||||
expect(parsed.map((entry) => entry.id)).toEqual(['gamma', 'alpha', 'beta']);
|
||||
@@ -131,7 +130,7 @@ describe('parseMcpServerSettings', () => {
|
||||
it('passes non-string raw input through the JSON-equality path', () => {
|
||||
const parsed = parseMcpServerSettings([
|
||||
{ id: 'a', url: 'https://a.test' },
|
||||
{ id: 'b', url: 'https://b.test', enabled: true }
|
||||
{ enabled: true, id: 'b', url: 'https://b.test' }
|
||||
]);
|
||||
|
||||
expect(parsed).toHaveLength(2);
|
||||
|
||||
@@ -3,22 +3,22 @@
|
||||
// streaming text tokens trigger redundant JSON.parse calls on unchanged
|
||||
// tool call data.
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { deriveAgenticSections } from '$lib/utils/agentic';
|
||||
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { MessageRole, AgenticSectionType } from '$lib/enums';
|
||||
import { deriveAgenticSections } from '$lib/utils/agentic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function makeMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
|
||||
return {
|
||||
id: 'm1',
|
||||
convId: 'c1',
|
||||
type: 'text',
|
||||
timestamp: 0,
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: '',
|
||||
parent: null,
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id: 'm1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
@@ -31,9 +31,8 @@ describe('parseToolCalls memoization', () => {
|
||||
// re-parse (which we verify by checking the returned sections
|
||||
// are equivalent).
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
const sections1 = deriveAgenticSections(msg, [], [], false);
|
||||
const sections2 = deriveAgenticSections(msg, [], [], false);
|
||||
@@ -44,9 +43,8 @@ describe('parseToolCalls memoization', () => {
|
||||
|
||||
it('does not re-parse JSON on cache hit', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
const spy = vi.spyOn(JSON, 'parse');
|
||||
|
||||
@@ -80,20 +78,18 @@ describe('parseToolCalls memoization', () => {
|
||||
describe('deriveAgenticSections O(1) tool message lookup', () => {
|
||||
it('matches tool messages to tool calls by toolCallId', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'test_1', arguments: '{}' } },
|
||||
{ id: 'call_2', type: 'function', function: { name: 'test_2', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'test_1' }, id: 'call_1', type: 'function' },
|
||||
{ function: { arguments: '{}', name: 'test_2' }, id: 'call_2', type: 'function' }
|
||||
]);
|
||||
|
||||
const toolMessages = [
|
||||
makeMessage({ role: MessageRole.TOOL, toolCallId: 'call_1', content: 'result_1' }),
|
||||
makeMessage({ role: MessageRole.TOOL, toolCallId: 'call_2', content: 'result_2' })
|
||||
makeMessage({ content: 'result_1', role: MessageRole.TOOL, toolCallId: 'call_1' }),
|
||||
makeMessage({ content: 'result_2', role: MessageRole.TOOL, toolCallId: 'call_2' })
|
||||
];
|
||||
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
const sections = deriveAgenticSections(msg, toolMessages, [], false);
|
||||
|
||||
// Expect: TEXT + 2 TOOL_CALL sections
|
||||
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
|
||||
|
||||
expect(toolCallSections).toHaveLength(2);
|
||||
expect(toolCallSections[0].toolResult).toBe('result_1');
|
||||
expect(toolCallSections[1].toolResult).toBe('result_2');
|
||||
@@ -101,13 +97,12 @@ describe('deriveAgenticSections O(1) tool message lookup', () => {
|
||||
|
||||
it('handles missing tool messages (pending calls during streaming)', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
|
||||
const msg = makeMessage({ content: '', toolCalls: toolCallsJson });
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
|
||||
const toolCallSection = sections.find((s) => s.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||
|
||||
expect(toolCallSection).toBeDefined();
|
||||
expect(toolCallSection?.content).toBe('');
|
||||
});
|
||||
@@ -117,29 +112,26 @@ describe('deriveAgenticSections O(1) tool message lookup', () => {
|
||||
const toolCalls = Array.from(
|
||||
{ length: N },
|
||||
(_, i): ApiChatCompletionToolCall => ({
|
||||
function: { arguments: '{}', name: `tool_${i}` },
|
||||
id: `call_${i}`,
|
||||
type: 'function',
|
||||
function: { name: `tool_${i}`, arguments: '{}' }
|
||||
type: 'function'
|
||||
})
|
||||
);
|
||||
const toolCallsJson = JSON.stringify(toolCalls);
|
||||
|
||||
const toolMessages = Array.from({ length: N }, (_, i) =>
|
||||
makeMessage({
|
||||
content: `result_${i}`,
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: `call_${i}`,
|
||||
content: `result_${i}`
|
||||
toolCallId: `call_${i}`
|
||||
})
|
||||
);
|
||||
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
|
||||
// If the lookup were still O(n^2), this would be noticeably slow
|
||||
const start = Date.now();
|
||||
const sections = deriveAgenticSections(msg, toolMessages, [], false);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
|
||||
|
||||
expect(toolCallSections).toHaveLength(N);
|
||||
expect(elapsed).toBeLessThan(100); // Should be fast with O(1) lookup
|
||||
});
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { deriveAgenticSections } from '$lib/utils/agentic';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { deriveAgenticSections } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
id: overrides.id ?? 'ast-1',
|
||||
convId: 'conv-1',
|
||||
type: 'text',
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: overrides.content ?? '',
|
||||
parent: null,
|
||||
children: [],
|
||||
content: overrides.content ?? '',
|
||||
convId: 'conv-1',
|
||||
id: overrides.id ?? 'ast-1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: Date.now(),
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
@@ -24,8 +24,10 @@ function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessag
|
||||
// onAssistantTurnComplete(...undefined).
|
||||
function buildApiToolCalls(message: DatabaseMessage): unknown[] | undefined {
|
||||
if (!message.toolCalls) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message.toolCalls);
|
||||
|
||||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
@@ -42,16 +44,15 @@ describe('partial tool call cleanup', () => {
|
||||
content: 'partial reasoning',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'exec_shell_command',
|
||||
arguments: '{"command":`grep -n \\"read_to\\" ` /Users'
|
||||
}
|
||||
arguments: '{"command":`grep -n \\"read_to\\" ` /Users',
|
||||
name: 'exec_shell_command'
|
||||
},
|
||||
id: 'call_1',
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
|
||||
const apiToolCalls = buildApiToolCalls(message);
|
||||
|
||||
// The bug: even though arguments are invalid, the outer array parses and
|
||||
@@ -59,6 +60,7 @@ describe('partial tool call cleanup', () => {
|
||||
// own for the server to execute the tool.
|
||||
expect(apiToolCalls).toBeDefined();
|
||||
const args = (apiToolCalls![0] as { function: { arguments: string } }).function.arguments;
|
||||
|
||||
expect(() => JSON.parse(args)).toThrow();
|
||||
});
|
||||
|
||||
@@ -71,8 +73,8 @@ describe('partial tool call cleanup', () => {
|
||||
content: 'partial reasoning',
|
||||
toolCalls: ''
|
||||
});
|
||||
|
||||
const apiToolCalls = buildApiToolCalls(clearedMessage);
|
||||
|
||||
expect(apiToolCalls).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -86,8 +88,8 @@ describe('partial tool call cleanup', () => {
|
||||
reasoningContent: 'thinking about read_to',
|
||||
toolCalls: ''
|
||||
});
|
||||
|
||||
const sections = deriveAgenticSections(cleared);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe('reasoning');
|
||||
expect(sections.some((s) => s.type.includes('tool_call'))).toBe(false);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
@@ -11,6 +11,7 @@ describe('PWA Build Output', () => {
|
||||
if (!distExists) {
|
||||
console.warn(`⚠ Skipping PWA Build Output tests - dist/ not found (run 'npm run build' first)`);
|
||||
it('skipped - dist/ not found', () => {});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,6 +26,7 @@ describe('PWA Build Output', () => {
|
||||
it('workbox library exists (hashed filename)', () => {
|
||||
// SvelteKit generates workbox-{hash}.js files
|
||||
const files = readdirSync(DIST_DIR).filter((f) => f.match(/^workbox-[^.]+\.js$/));
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -38,18 +40,22 @@ describe('PWA Build Output', () => {
|
||||
it('SvelteKit bundle.js exists in _app/immutable/', () => {
|
||||
// SvelteKit generates hashed bundle names in _app/immutable/
|
||||
const appDir = resolve(DIST_DIR, '_app', 'immutable');
|
||||
|
||||
expect(existsSync(appDir), '_app/immutable/ not found').toBeTruthy();
|
||||
const files = readdirSync(appDir).filter((f) => f.startsWith('bundle.') && f.endsWith('.js'));
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('SvelteKit bundle.css exists in _app/immutable/assets/', () => {
|
||||
// SvelteKit generates hashed CSS bundles in _app/immutable/assets/
|
||||
const cssDir = resolve(DIST_DIR, '_app', 'immutable', 'assets');
|
||||
|
||||
expect(existsSync(cssDir), '_app/immutable/assets/ not found').toBeTruthy();
|
||||
const files = readdirSync(cssDir).filter(
|
||||
(f) => f.startsWith('bundle.') && f.endsWith('.css')
|
||||
);
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -66,6 +72,7 @@ describe('PWA Build Output', () => {
|
||||
it('has valid JSON with version field', () => {
|
||||
const content = readFileSync(resolve(DIST_DIR, '_app', 'version.json'), 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed).toHaveProperty('version');
|
||||
expect(typeof parsed.version).toBe('string');
|
||||
expect(parsed.version.length).toBeGreaterThan(0);
|
||||
@@ -175,6 +182,7 @@ describe('PWA Build Output', () => {
|
||||
describe('Hashed workbox files', () => {
|
||||
it('workbox-*.js files exist in dist root (SvelteKit build output)', () => {
|
||||
const files = readdirSync(DIST_DIR).filter((f) => f.match(/^workbox-[^.]+\.js$/));
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the new reasoning content handling.
|
||||
@@ -27,15 +27,15 @@ describe('reasoning content in new structured format', () => {
|
||||
it('convertDbMessageToApiChatMessageData includes reasoning_content', () => {
|
||||
// Simulate the conversion logic
|
||||
const dbMessage = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
|
||||
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.',
|
||||
role: MessageRole.ASSISTANT
|
||||
};
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
content: dbMessage.content,
|
||||
role: dbMessage.role
|
||||
};
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
if (dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
@@ -49,17 +49,16 @@ describe('reasoning content in new structured format', () => {
|
||||
|
||||
it('API message excludes reasoning when excludeReasoningFromContext is true', () => {
|
||||
const dbMessage = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'internal thinking'
|
||||
reasoningContent: 'internal thinking',
|
||||
role: MessageRole.ASSISTANT
|
||||
};
|
||||
|
||||
const excludeReasoningFromContext = true;
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
content: dbMessage.content,
|
||||
role: dbMessage.role
|
||||
};
|
||||
|
||||
if (!excludeReasoningFromContext && dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
@@ -70,15 +69,15 @@ describe('reasoning content in new structured format', () => {
|
||||
|
||||
it('handles messages with no reasoning', () => {
|
||||
const dbMessage = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'No reasoning here.',
|
||||
reasoningContent: undefined
|
||||
reasoningContent: undefined,
|
||||
role: MessageRole.ASSISTANT
|
||||
};
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
content: dbMessage.content,
|
||||
role: dbMessage.role
|
||||
};
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
if (dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { redactValue } from '$lib/utils/redact';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('redactValue', () => {
|
||||
it('returns [redacted] by default', () => {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRequestUrl,
|
||||
getRequestMethod,
|
||||
getRequestBody,
|
||||
summarizeRequestBody,
|
||||
extractJsonRpcMethods,
|
||||
formatDiagnosticErrorMessage,
|
||||
extractJsonRpcMethods
|
||||
getRequestBody,
|
||||
getRequestMethod,
|
||||
getRequestUrl,
|
||||
summarizeRequestBody
|
||||
} from '$lib/utils/request-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('getRequestUrl', () => {
|
||||
it('returns a plain string input as-is', () => {
|
||||
@@ -19,6 +19,7 @@ describe('getRequestUrl', () => {
|
||||
|
||||
it('returns url from a Request object', () => {
|
||||
const req = new Request('https://example.com/mcp');
|
||||
|
||||
expect(getRequestUrl(req)).toBe('https://example.com/mcp');
|
||||
});
|
||||
});
|
||||
@@ -30,6 +31,7 @@ describe('getRequestMethod', () => {
|
||||
|
||||
it('falls back to Request.method', () => {
|
||||
const req = new Request('https://example.com', { method: 'PUT' });
|
||||
|
||||
expect(getRequestMethod(req)).toBe('PUT');
|
||||
});
|
||||
|
||||
@@ -67,6 +69,7 @@ describe('summarizeRequestBody', () => {
|
||||
|
||||
it('returns blob kind with size', () => {
|
||||
const blob = new Blob(['abc']);
|
||||
|
||||
expect(summarizeRequestBody(blob)).toEqual({ kind: 'blob', size: 3 });
|
||||
});
|
||||
|
||||
@@ -98,14 +101,16 @@ describe('formatDiagnosticErrorMessage', () => {
|
||||
describe('extractJsonRpcMethods', () => {
|
||||
it('extracts methods from a JSON-RPC array', () => {
|
||||
const body = JSON.stringify([
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ id: 1, jsonrpc: '2.0', method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
]);
|
||||
|
||||
expect(extractJsonRpcMethods(body)).toEqual(['initialize', 'notifications/initialized']);
|
||||
});
|
||||
|
||||
it('extracts method from a single JSON-RPC message', () => {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
const body = JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tools/list' });
|
||||
|
||||
expect(extractJsonRpcMethods(body)).toEqual(['tools/list']);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeHeaders } from '$lib/utils/api-headers';
|
||||
import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
|
||||
import { sanitizeHeaders } from '$lib/utils/api-headers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('sanitizeHeaders', () => {
|
||||
it('returns empty object for undefined input', () => {
|
||||
@@ -8,20 +8,22 @@ describe('sanitizeHeaders', () => {
|
||||
});
|
||||
|
||||
it('passes through non-sensitive headers', () => {
|
||||
const headers = new Headers({ 'content-type': 'application/json', accept: 'text/html' });
|
||||
const headers = new Headers({ accept: 'text/html', 'content-type': 'application/json' });
|
||||
|
||||
expect(sanitizeHeaders(headers)).toEqual({
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/html'
|
||||
accept: 'text/html',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts known sensitive headers', () => {
|
||||
const headers = new Headers({
|
||||
authorization: 'Bearer secret',
|
||||
'x-api-key': 'key-123',
|
||||
'content-type': 'application/json'
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'key-123'
|
||||
});
|
||||
const result = sanitizeHeaders(headers);
|
||||
|
||||
expect(result.authorization).toBe('[redacted]');
|
||||
expect(result['x-api-key']).toBe('[redacted]');
|
||||
expect(result['content-type']).toBe('application/json');
|
||||
@@ -30,20 +32,23 @@ describe('sanitizeHeaders', () => {
|
||||
it('partially redacts headers specified in partialRedactHeaders', () => {
|
||||
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
|
||||
const partial = new Map([['mcp-session-id', 5]]);
|
||||
|
||||
expect(sanitizeHeaders(headers, undefined, partial)['mcp-session-id']).toBe('....12345');
|
||||
});
|
||||
|
||||
it('fully redacts mcp-session-id when no partialRedactHeaders is given', () => {
|
||||
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
|
||||
|
||||
expect(sanitizeHeaders(headers)['mcp-session-id']).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('redacts extra headers provided by the caller', () => {
|
||||
const headers = new Headers({
|
||||
'x-vendor-key': 'vendor-secret',
|
||||
'content-type': 'application/json'
|
||||
'content-type': 'application/json',
|
||||
'x-vendor-key': 'vendor-secret'
|
||||
});
|
||||
const result = sanitizeHeaders(headers, ['x-vendor-key']);
|
||||
|
||||
expect(result['x-vendor-key']).toBe('[redacted]');
|
||||
expect(result['content-type']).toBe('application/json');
|
||||
});
|
||||
@@ -51,6 +56,7 @@ describe('sanitizeHeaders', () => {
|
||||
it('handles case-insensitive extra header names', () => {
|
||||
const headers = new Headers({ 'X-Custom-Token': 'token-value' });
|
||||
const result = sanitizeHeaders(headers, ['X-CUSTOM-TOKEN']);
|
||||
|
||||
expect(result['x-custom-token']).toBe('[redacted]');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { extractSearchResults, extractSearchQuery } from '$lib/utils/search-results';
|
||||
import { extractSearchQuery, extractSearchResults } from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SAMPLE = `Title: World Cup 2026 | Match schedule, fixtures, results & stadiums
|
||||
URL: https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/articles/match-schedule-fixtures-results-teams-stadiums
|
||||
@@ -23,12 +23,12 @@ Highlights:
|
||||
Something
|
||||
# World Cup
|
||||
...`;
|
||||
|
||||
const QUERY_ARGS = '{"query":"FIFA World Cup 2026 schedule"}';
|
||||
|
||||
describe('real-world Exa fixture', () => {
|
||||
it('extracts every search result and preserves rich highlights', () => {
|
||||
const results = extractSearchResults(SAMPLE);
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].title).toContain('World Cup 2026 | Match schedule');
|
||||
expect(results[0].url).toContain('fifa.com');
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
extractSearchResults,
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName
|
||||
} from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('extractSearchResults', () => {
|
||||
it('parses the Exa fixture with multiple results', () => {
|
||||
@@ -30,6 +30,7 @@ Highlights:
|
||||
# FIFA World Cup Schedule
|
||||
...`;
|
||||
const results = extractSearchResults(fixture);
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].title).toContain('World Cup 2026');
|
||||
expect(results[0].url).toBe('https://www.fifa.com/articles/match-schedule');
|
||||
@@ -60,6 +61,7 @@ just a paragraph
|
||||
Title: b
|
||||
URL: not a url`;
|
||||
const results = extractSearchResults(txt);
|
||||
|
||||
// Only middle one should pass (has title + url).
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].url).toBe('https://x.com');
|
||||
@@ -73,6 +75,7 @@ Author: alice
|
||||
Highlights:
|
||||
a highlight`;
|
||||
const results = extractSearchResults(txt);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('only one');
|
||||
expect(results[0].author).toBe('alice');
|
||||
|
||||
@@ -1,65 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { SourceHistory } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('SourceHistory', () => {
|
||||
it('coalesces pushes inside the group window into one undo step', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.push({ value: 'a', caret: 1 }, 1200);
|
||||
h.push({ value: 'ab', caret: 2 }, 1500);
|
||||
|
||||
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||
expect(h.undo({ value: '', caret: 0 })).toBeNull();
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.push({ caret: 1, value: 'a' }, 1200);
|
||||
h.push({ caret: 2, value: 'ab' }, 1500);
|
||||
|
||||
expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
|
||||
expect(h.undo({ caret: 0, value: '' })).toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new group once the window has passed', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.push({ value: 'abc', caret: 3 }, 2000);
|
||||
|
||||
expect(h.undo({ value: 'abcdef', caret: 6 })).toEqual({ value: 'abc', caret: 3 });
|
||||
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.push({ caret: 3, value: 'abc' }, 2000);
|
||||
|
||||
expect(h.undo({ caret: 6, value: 'abcdef' })).toEqual({ caret: 3, value: 'abc' });
|
||||
expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
|
||||
});
|
||||
|
||||
it('newGroup forces a separate entry even inside the window', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.push({ value: 'abc', caret: 3 }, 1100, true);
|
||||
|
||||
expect(h.undo({ value: 'abc\n', caret: 4 })).toEqual({ value: 'abc', caret: 3 });
|
||||
expect(h.undo({ value: 'abc', caret: 3 })).toEqual({ value: '', caret: 0 });
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.push({ caret: 3, value: 'abc' }, 1100, true);
|
||||
|
||||
expect(h.undo({ caret: 4, value: 'abc\n' })).toEqual({ caret: 3, value: 'abc' });
|
||||
expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
|
||||
});
|
||||
|
||||
it('redo round-trips and a fresh push clears the redo stack', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
|
||||
const undone = h.undo({ value: 'abc', caret: 3 });
|
||||
expect(undone).toEqual({ value: '', caret: 0 });
|
||||
expect(h.redo({ value: '', caret: 0 })).toEqual({ value: 'abc', caret: 3 });
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
|
||||
h.undo({ value: 'abc', caret: 3 });
|
||||
h.push({ value: '', caret: 0 }, 5000);
|
||||
expect(h.redo({ value: 'x', caret: 1 })).toBeNull();
|
||||
const undone = h.undo({ caret: 3, value: 'abc' });
|
||||
|
||||
expect(undone).toEqual({ caret: 0, value: '' });
|
||||
expect(h.redo({ caret: 0, value: '' })).toEqual({ caret: 3, value: 'abc' });
|
||||
|
||||
h.undo({ caret: 3, value: 'abc' });
|
||||
h.push({ caret: 0, value: '' }, 5000);
|
||||
expect(h.redo({ caret: 1, value: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new group on the first edit after an undo', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
h.push({ value: '', caret: 0 }, 1000);
|
||||
h.undo({ value: 'abc', caret: 3 });
|
||||
|
||||
h.push({ value: '', caret: 0 }, 1200);
|
||||
expect(h.undo({ value: 'x', caret: 1 })).toEqual({ value: '', caret: 0 });
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.undo({ caret: 3, value: 'abc' });
|
||||
|
||||
h.push({ caret: 0, value: '' }, 1200);
|
||||
expect(h.undo({ caret: 1, value: 'x' })).toEqual({ caret: 0, value: '' });
|
||||
});
|
||||
|
||||
it('evicts the oldest entry past the limit', () => {
|
||||
const h = new SourceHistory(2, 800);
|
||||
h.push({ value: 'one', caret: 0 }, 1000);
|
||||
h.push({ value: 'two', caret: 0 }, 2000);
|
||||
h.push({ value: 'three', caret: 0 }, 3000);
|
||||
|
||||
expect(h.undo({ value: 'cur', caret: 0 })).toEqual({ value: 'three', caret: 0 });
|
||||
expect(h.undo({ value: 'three', caret: 0 })).toEqual({ value: 'two', caret: 0 });
|
||||
expect(h.undo({ value: 'two', caret: 0 })).toBeNull();
|
||||
h.push({ caret: 0, value: 'one' }, 1000);
|
||||
h.push({ caret: 0, value: 'two' }, 2000);
|
||||
h.push({ caret: 0, value: 'three' }, 3000);
|
||||
|
||||
expect(h.undo({ caret: 0, value: 'cur' })).toEqual({ caret: 0, value: 'three' });
|
||||
expect(h.undo({ caret: 0, value: 'three' })).toEqual({ caret: 0, value: 'two' });
|
||||
expect(h.undo({ caret: 0, value: 'two' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseSseJsonStream } from '$lib/utils/sse';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSseResponse(events: string[]): Response {
|
||||
const body = events.join('\n\n') + '\n\n';
|
||||
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
@@ -13,6 +14,7 @@ describe('parseSseJsonStream', () => {
|
||||
it('yields parsed data for each record', async () => {
|
||||
const response = makeSseResponse(['data: {"chunk": "a"}', 'data: {"chunk": "b"}']);
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
@@ -26,6 +28,7 @@ describe('parseSseJsonStream', () => {
|
||||
'data: {"chunk": "after-done"}'
|
||||
]);
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
@@ -39,6 +42,7 @@ describe('parseSseJsonStream', () => {
|
||||
'data: {"chunk": "also-ok"}'
|
||||
]);
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
@@ -50,16 +54,18 @@ describe('parseSseJsonStream', () => {
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
|
||||
controller.enqueue(enc.encode(full.slice(0, full.length / 2)));
|
||||
controller.enqueue(enc.encode(full.slice(full.length / 2)));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
const response = new Response(stream, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/event-stream' }
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
status: 200
|
||||
});
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
@@ -69,6 +75,7 @@ describe('parseSseJsonStream', () => {
|
||||
it('returns immediately if response has no body', async () => {
|
||||
const response = new Response(null, { status: 200 });
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ChatService } from '$lib/services/chat.service';
|
||||
import type { ApiStreamSession } from '$lib/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSession(overrides: Partial<ApiStreamSession>): ApiStreamSession {
|
||||
return {
|
||||
completed_at: 0,
|
||||
conversation_id: 'conv',
|
||||
is_done: true,
|
||||
total_bytes: 0,
|
||||
started_at: 0,
|
||||
completed_at: 0,
|
||||
total_bytes: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -25,17 +25,20 @@ describe('selectActiveStream', () => {
|
||||
|
||||
it('returns the single session when it is running', () => {
|
||||
const s = makeSession({ conversation_id: 'only', is_done: false, started_at: 42 });
|
||||
|
||||
expect(ChatService.selectActiveStream([s])).toBe(s);
|
||||
});
|
||||
|
||||
it('returns null when the single session is finalized', () => {
|
||||
const s = makeSession({ conversation_id: 'only', is_done: true, started_at: 42 });
|
||||
|
||||
expect(ChatService.selectActiveStream([s])).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers a still running session over a finalized one regardless of started_at', () => {
|
||||
const finalized = makeSession({ conversation_id: 'old', is_done: true, started_at: 1000 });
|
||||
const running = makeSession({ conversation_id: 'new', is_done: false, started_at: 10 });
|
||||
|
||||
expect(ChatService.selectActiveStream([finalized, running])?.conversation_id).toBe('new');
|
||||
expect(ChatService.selectActiveStream([running, finalized])?.conversation_id).toBe('new');
|
||||
});
|
||||
@@ -44,6 +47,7 @@ describe('selectActiveStream', () => {
|
||||
const a = makeSession({ conversation_id: 'a', is_done: false, started_at: 100 });
|
||||
const b = makeSession({ conversation_id: 'b', is_done: false, started_at: 200 });
|
||||
const c = makeSession({ conversation_id: 'c', is_done: false, started_at: 150 });
|
||||
|
||||
expect(ChatService.selectActiveStream([a, b, c])?.conversation_id).toBe('b');
|
||||
expect(ChatService.selectActiveStream([c, a, b])?.conversation_id).toBe('b');
|
||||
});
|
||||
@@ -52,6 +56,7 @@ describe('selectActiveStream', () => {
|
||||
const a = makeSession({ conversation_id: 'a', is_done: true, started_at: 10 });
|
||||
const b = makeSession({ conversation_id: 'b', is_done: true, started_at: 30 });
|
||||
const c = makeSession({ conversation_id: 'c', is_done: true, started_at: 20 });
|
||||
|
||||
expect(ChatService.selectActiveStream([a, b, c])).toBeNull();
|
||||
});
|
||||
|
||||
@@ -59,6 +64,7 @@ describe('selectActiveStream', () => {
|
||||
// reduce visits left to right, the initial accumulator stays unless a strictly greater value appears
|
||||
const a = makeSession({ conversation_id: 'first', is_done: false, started_at: 50 });
|
||||
const b = makeSession({ conversation_id: 'second', is_done: false, started_at: 50 });
|
||||
|
||||
expect(ChatService.selectActiveStream([a, b])?.conversation_id).toBe('first');
|
||||
});
|
||||
|
||||
@@ -67,6 +73,7 @@ describe('selectActiveStream', () => {
|
||||
const old2 = makeSession({ conversation_id: 'old2', is_done: true, started_at: 200 });
|
||||
const freshFin = makeSession({ conversation_id: 'freshFin', is_done: true, started_at: 500 });
|
||||
const running = makeSession({ conversation_id: 'running', is_done: false, started_at: 400 });
|
||||
|
||||
expect(ChatService.selectActiveStream([old1, old2, freshFin, running])?.conversation_id).toBe(
|
||||
'running'
|
||||
);
|
||||
|
||||
@@ -4,12 +4,12 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
@@ -17,11 +17,12 @@ beforeAll(() => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
});
|
||||
|
||||
import { ChatService } from '$lib/services/chat.service';
|
||||
import { STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX } from '$lib/constants';
|
||||
import { ChatService } from '$lib/services/chat.service';
|
||||
|
||||
describe('ChatService stream resume', () => {
|
||||
beforeEach(() => {
|
||||
@@ -38,6 +39,7 @@ describe('ChatService stream resume', () => {
|
||||
it('saves and reads back the byte count', () => {
|
||||
ChatService.saveStreamState('conv-a', 4242);
|
||||
const got = ChatService.getStreamState('conv-a');
|
||||
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.bytesReceived).toBe(4242);
|
||||
expect(typeof got!.updatedAt).toBe('number');
|
||||
@@ -47,6 +49,7 @@ describe('ChatService stream resume', () => {
|
||||
ChatService.saveStreamState('conv-a', 100);
|
||||
ChatService.saveStreamState('conv-a', 200);
|
||||
const got = ChatService.getStreamState('conv-a');
|
||||
|
||||
expect(got!.bytesReceived).toBe(200);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { tryParseToolResultObject } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tryParseToolResultObject', () => {
|
||||
it('returns null when no result is provided', () => {
|
||||
@@ -9,8 +9,8 @@ describe('tryParseToolResultObject', () => {
|
||||
|
||||
it('returns the parsed object when the result is JSON', () => {
|
||||
expect(tryParseToolResultObject('{"result":"ok","bytes":42}')).toEqual({
|
||||
result: 'ok',
|
||||
bytes: 42
|
||||
bytes: 42,
|
||||
result: 'ok'
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
import { parseToolArgs } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
||||
import { lastPathSegment, abbreviateHome, formatCwdMessage, parseCwdMessage } from '$lib/utils';
|
||||
import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
|
||||
import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
|
||||
import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
|
||||
import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
|
||||
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
type WriteFileMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
|
||||
import { parseEditFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
|
||||
import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
|
||||
import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
|
||||
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
||||
import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/utils';
|
||||
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSection(
|
||||
overrides: Partial<AgenticSection> = {},
|
||||
toolName = BuiltInTool.READ_FILE
|
||||
): AgenticSection {
|
||||
return {
|
||||
type: AgenticSectionType.TOOL_CALL,
|
||||
content: '',
|
||||
toolName,
|
||||
toolArgs: JSON.stringify({ path: '/foo.txt' }),
|
||||
toolName,
|
||||
toolResult: undefined,
|
||||
type: AgenticSectionType.TOOL_CALL,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
@@ -91,6 +91,7 @@ describe('formatCwdMessage / parseCwdMessage', () => {
|
||||
|
||||
it('round-trips through the parser', () => {
|
||||
const info = parseCwdMessage(formatCwdMessage('/Users/al/Documents', '/Users/al'));
|
||||
|
||||
expect(info?.path).toBe('/Users/al/Documents');
|
||||
expect(info?.display).toBe('~/Documents');
|
||||
});
|
||||
@@ -100,11 +101,11 @@ describe('formatCwdMessage / parseCwdMessage', () => {
|
||||
parseCwdMessage(
|
||||
'Set working directory to [file:///a/b](~/b). Tool calls run with this as their working directory.'
|
||||
)
|
||||
).toEqual({ path: '/a/b', display: '~/b' });
|
||||
).toEqual({ display: '~/b', path: '/a/b' });
|
||||
});
|
||||
|
||||
it('parses the cleared marker', () => {
|
||||
expect(parseCwdMessage('Working directory cleared')).toEqual({ path: null, display: '' });
|
||||
expect(parseCwdMessage('Working directory cleared')).toEqual({ display: '', path: null });
|
||||
});
|
||||
|
||||
it('returns null for non-cwd content', () => {
|
||||
@@ -115,6 +116,7 @@ describe('formatCwdMessage / parseCwdMessage', () => {
|
||||
describe('parseToolArgs (shared)', () => {
|
||||
it('returns null when the section has no toolArgs', () => {
|
||||
const result = parseToolArgs(BuiltInTool.READ_FILE, makeSection({ toolArgs: undefined }));
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -123,6 +125,7 @@ describe('parseToolArgs (shared)', () => {
|
||||
BuiltInTool.READ_FILE,
|
||||
makeSection({ toolArgs: '{"path":"/x"}' }, BuiltInTool.WRITE_FILE)
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -131,6 +134,7 @@ describe('parseToolArgs (shared)', () => {
|
||||
BuiltInTool.READ_FILE,
|
||||
makeSection({ toolArgs: '{"path": "/foo.tx' })
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
@@ -139,6 +143,7 @@ describe('parseToolArgs (shared)', () => {
|
||||
BuiltInTool.READ_FILE,
|
||||
makeSection({ toolArgs: '{"path":"/foo.txt"}' })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ path: '/foo.txt' });
|
||||
});
|
||||
|
||||
@@ -148,6 +153,7 @@ describe('parseToolArgs (shared)', () => {
|
||||
makeSection({ toolArgs: '{"path": "/foo.tx' }),
|
||||
{ partial: true }
|
||||
);
|
||||
|
||||
expect(result).toEqual({ path: '/foo.tx' });
|
||||
});
|
||||
});
|
||||
@@ -156,7 +162,7 @@ describe('parseWriteFileMeta', () => {
|
||||
it('returns null for sections with a different tool name', () => {
|
||||
expect(
|
||||
parseWriteFileMeta(
|
||||
makeSection({ toolName: BuiltInTool.READ_FILE, toolArgs: '{"path":"/x","content":"y"}' })
|
||||
makeSection({ toolArgs: '{"path":"/x","content":"y"}', toolName: BuiltInTool.READ_FILE })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -164,15 +170,16 @@ describe('parseWriteFileMeta', () => {
|
||||
it('returns null when args have no path-like field', () => {
|
||||
expect(
|
||||
parseWriteFileMeta(
|
||||
makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"content":"x"}' })
|
||||
makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.WRITE_FILE })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts partial args (renders incrementally as content streams in)', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection({ toolName: BuiltInTool.WRITE_FILE, toolArgs: '{"path":"/foo.t' })
|
||||
makeSection({ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.WRITE_FILE })
|
||||
);
|
||||
|
||||
expect(meta?.filePath).toBe('/foo.t');
|
||||
});
|
||||
|
||||
@@ -180,18 +187,19 @@ describe('parseWriteFileMeta', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.WRITE_FILE,
|
||||
toolArgs: '{"path":"/foo.ts","content":"x"}',
|
||||
toolName: BuiltInTool.WRITE_FILE,
|
||||
toolResult: '{"result":"wrote","bytes":42}'
|
||||
},
|
||||
BuiltInTool.WRITE_FILE
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta).toMatchObject<Partial<WriteFileMeta>>({
|
||||
bytesWritten: 42,
|
||||
content: 'x',
|
||||
filePath: '/foo.ts',
|
||||
language: expect.any(String),
|
||||
content: 'x',
|
||||
bytesWritten: 42,
|
||||
resultMessage: 'wrote'
|
||||
});
|
||||
});
|
||||
@@ -199,11 +207,12 @@ describe('parseWriteFileMeta', () => {
|
||||
it('surfaces errorMessage from the result blob', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection({
|
||||
toolName: BuiltInTool.WRITE_FILE,
|
||||
toolArgs: '{"path":"/foo","content":"x"}',
|
||||
toolName: BuiltInTool.WRITE_FILE,
|
||||
toolResult: '{"error":"permission denied"}'
|
||||
})
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('permission denied');
|
||||
});
|
||||
});
|
||||
@@ -212,17 +221,18 @@ describe('parseEditFileMeta', () => {
|
||||
it('parses edits array and applies editsApplied from the result', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.EDIT_FILE,
|
||||
toolArgs:
|
||||
'{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}',
|
||||
toolName: BuiltInTool.EDIT_FILE,
|
||||
toolResult: '{"result":"ok","edits_applied":2}'
|
||||
},
|
||||
BuiltInTool.EDIT_FILE
|
||||
);
|
||||
const meta = parseEditFileMeta(section);
|
||||
|
||||
expect(meta?.edits).toEqual([
|
||||
{ oldText: 'a', newText: 'b' },
|
||||
{ oldText: 'c', newText: 'd' }
|
||||
{ newText: 'b', oldText: 'a' },
|
||||
{ newText: 'd', oldText: 'c' }
|
||||
]);
|
||||
expect(meta?.editsApplied).toBe(2);
|
||||
expect(meta?.resultMessage).toBe('ok');
|
||||
@@ -231,27 +241,29 @@ describe('parseEditFileMeta', () => {
|
||||
it('drops edits with empty old_text', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.EDIT_FILE,
|
||||
toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}'
|
||||
toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}',
|
||||
toolName: BuiltInTool.EDIT_FILE
|
||||
},
|
||||
BuiltInTool.EDIT_FILE
|
||||
);
|
||||
const meta = parseEditFileMeta(section);
|
||||
|
||||
// First entry is dropped (empty old_text). Second is kept
|
||||
// (empty new_text is fine - it's the "delete" case).
|
||||
expect(meta?.edits).toEqual([{ oldText: 'a', newText: '' }]);
|
||||
expect(meta?.edits).toEqual([{ newText: '', oldText: 'a' }]);
|
||||
});
|
||||
|
||||
it('errorMessage wins over result message', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.EDIT_FILE,
|
||||
toolArgs: '{"path":"/foo"}',
|
||||
toolName: BuiltInTool.EDIT_FILE,
|
||||
toolResult: '{"error":"bad path","result":"ok"}'
|
||||
},
|
||||
BuiltInTool.EDIT_FILE
|
||||
);
|
||||
const meta = parseEditFileMeta(section);
|
||||
|
||||
expect(meta?.errorMessage).toBe('bad path');
|
||||
expect(meta?.resultMessage).toBeUndefined();
|
||||
});
|
||||
@@ -262,6 +274,7 @@ describe('parseReadFileMeta', () => {
|
||||
const meta = parseReadFileMeta(
|
||||
makeSection({ toolArgs: '{"path":"/foo.txt"}' }, BuiltInTool.READ_FILE)
|
||||
);
|
||||
|
||||
expect(meta?.fileName).toBe('foo.txt');
|
||||
expect(meta?.lineRange).toBeNull();
|
||||
});
|
||||
@@ -273,7 +286,8 @@ describe('parseReadFileMeta', () => {
|
||||
BuiltInTool.READ_FILE
|
||||
)
|
||||
);
|
||||
expect(meta?.lineRange).toEqual({ start: 10, end: 20 });
|
||||
|
||||
expect(meta?.lineRange).toEqual({ end: 20, start: 10 });
|
||||
});
|
||||
|
||||
it('parses start_line + line_count into a range', () => {
|
||||
@@ -283,7 +297,8 @@ describe('parseReadFileMeta', () => {
|
||||
BuiltInTool.READ_FILE
|
||||
)
|
||||
);
|
||||
expect(meta?.lineRange).toEqual({ start: 10, end: 14 });
|
||||
|
||||
expect(meta?.lineRange).toEqual({ end: 14, start: 10 });
|
||||
});
|
||||
|
||||
it('returns null when args cannot be parsed', () => {
|
||||
@@ -295,12 +310,12 @@ describe('parseGrepSearchMeta', () => {
|
||||
it('returns null when path or pattern is missing', () => {
|
||||
expect(
|
||||
parseGrepSearchMeta(
|
||||
makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"pattern":"foo"}' })
|
||||
makeSection({ toolArgs: '{"pattern":"foo"}', toolName: BuiltInTool.GREP_SEARCH })
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseGrepSearchMeta(
|
||||
makeSection({ toolName: BuiltInTool.GREP_SEARCH, toolArgs: '{"path":"/x"}' })
|
||||
makeSection({ toolArgs: '{"path":"/x"}', toolName: BuiltInTool.GREP_SEARCH })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
@@ -309,28 +324,30 @@ describe('parseGrepSearchMeta', () => {
|
||||
const meta = parseGrepSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.GREP_SEARCH,
|
||||
toolArgs: '{"path":"/x","pattern":"foo"}',
|
||||
toolName: BuiltInTool.GREP_SEARCH,
|
||||
toolResult: JSON.stringify({ plain_text_response: 'a.ts:hello\nb.ts:world' })
|
||||
},
|
||||
BuiltInTool.GREP_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toHaveLength(2);
|
||||
expect(meta?.matches[0]).toEqual({ file: 'a.ts', content: 'hello' });
|
||||
expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts' });
|
||||
});
|
||||
|
||||
it('falls back to raw-text parsing when result is not JSON', () => {
|
||||
const meta = parseGrepSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.GREP_SEARCH,
|
||||
toolArgs: '{"path":"/x","pattern":"foo"}',
|
||||
toolName: BuiltInTool.GREP_SEARCH,
|
||||
toolResult: 'a.ts:hello\nb.ts:world'
|
||||
},
|
||||
BuiltInTool.GREP_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -338,14 +355,15 @@ describe('parseGrepSearchMeta', () => {
|
||||
const meta = parseGrepSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.GREP_SEARCH,
|
||||
toolArgs: '{"path":"/x","pattern":"foo","return_line_numbers":true}',
|
||||
toolName: BuiltInTool.GREP_SEARCH,
|
||||
toolResult: 'a.ts:12:hello'
|
||||
},
|
||||
BuiltInTool.GREP_SEARCH
|
||||
)
|
||||
);
|
||||
expect(meta?.matches[0]).toEqual({ file: 'a.ts', line: 12, content: 'hello' });
|
||||
|
||||
expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts', line: 12 });
|
||||
expect(meta?.showLineNumbers).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -355,13 +373,14 @@ describe('parseFileGlobSearchMeta', () => {
|
||||
const meta = parseFileGlobSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.FILE_GLOB_SEARCH,
|
||||
toolArgs: '{"path":"/x"}',
|
||||
toolName: BuiltInTool.FILE_GLOB_SEARCH,
|
||||
toolResult: 'a.ts\nb.ts'
|
||||
},
|
||||
BuiltInTool.FILE_GLOB_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
|
||||
});
|
||||
|
||||
@@ -369,13 +388,14 @@ describe('parseFileGlobSearchMeta', () => {
|
||||
const meta = parseFileGlobSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.FILE_GLOB_SEARCH,
|
||||
toolArgs: '{"path":"/x"}',
|
||||
toolName: BuiltInTool.FILE_GLOB_SEARCH,
|
||||
toolResult: JSON.stringify({ plain_text_response: 'a.ts\nb.ts' })
|
||||
},
|
||||
BuiltInTool.FILE_GLOB_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
|
||||
});
|
||||
|
||||
@@ -383,13 +403,14 @@ describe('parseFileGlobSearchMeta', () => {
|
||||
const meta = parseFileGlobSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.FILE_GLOB_SEARCH,
|
||||
toolArgs: '{"path":"/x"}',
|
||||
toolName: BuiltInTool.FILE_GLOB_SEARCH,
|
||||
toolResult: JSON.stringify({ error: 'permission denied' })
|
||||
},
|
||||
BuiltInTool.FILE_GLOB_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('permission denied');
|
||||
});
|
||||
});
|
||||
@@ -397,17 +418,18 @@ describe('parseFileGlobSearchMeta', () => {
|
||||
describe('parseRunJavascriptMeta', () => {
|
||||
it('returns null when code is missing', () => {
|
||||
expect(
|
||||
parseRunJavascriptMeta(makeSection({ toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{}' }))
|
||||
parseRunJavascriptMeta(makeSection({ toolArgs: '{}', toolName: BuiltInTool.RUN_JAVASCRIPT }))
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads code and timeout', () => {
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{ toolName: BuiltInTool.RUN_JAVASCRIPT, toolArgs: '{"code":"Math.PI","timeout_ms":5000}' },
|
||||
{ toolArgs: '{"code":"Math.PI","timeout_ms":5000}', toolName: BuiltInTool.RUN_JAVASCRIPT },
|
||||
BuiltInTool.RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.code).toBe('Math.PI');
|
||||
expect(meta?.timeoutMs).toBe(5000);
|
||||
});
|
||||
@@ -416,13 +438,14 @@ describe('parseRunJavascriptMeta', () => {
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.RUN_JAVASCRIPT,
|
||||
toolArgs: '{"code":"throw new Error()"}',
|
||||
toolName: BuiltInTool.RUN_JAVASCRIPT,
|
||||
toolResult: JSON.stringify({ error: 'undefined is not a function' })
|
||||
},
|
||||
BuiltInTool.RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('undefined is not a function');
|
||||
});
|
||||
|
||||
@@ -433,13 +456,14 @@ describe('parseRunJavascriptMeta', () => {
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.RUN_JAVASCRIPT,
|
||||
toolArgs: '{"code":"[1,2,3]"}',
|
||||
toolName: BuiltInTool.RUN_JAVASCRIPT,
|
||||
toolResult: '[1,2,3]'
|
||||
},
|
||||
BuiltInTool.RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -447,13 +471,14 @@ describe('parseRunJavascriptMeta', () => {
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolName: BuiltInTool.RUN_JAVASCRIPT,
|
||||
toolArgs: '{"code":"foo"}',
|
||||
toolName: BuiltInTool.RUN_JAVASCRIPT,
|
||||
toolResult: 'Error: undefined is not a function\n at <anonymous>:1:1'
|
||||
},
|
||||
BuiltInTool.RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('undefined is not a function');
|
||||
});
|
||||
});
|
||||
@@ -462,10 +487,11 @@ describe('parseExecShellCommandMeta', () => {
|
||||
it('reads command from the args', () => {
|
||||
const meta = parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"command":"ls -la"}' },
|
||||
{ toolArgs: '{"command":"ls -la"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.EXEC_SHELL_COMMAND
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.command).toBe('ls -la');
|
||||
});
|
||||
|
||||
@@ -473,7 +499,7 @@ describe('parseExecShellCommandMeta', () => {
|
||||
expect(
|
||||
parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cmd":"ls"}' },
|
||||
{ toolArgs: '{"cmd":"ls"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.EXEC_SHELL_COMMAND
|
||||
)
|
||||
)?.command
|
||||
@@ -481,7 +507,7 @@ describe('parseExecShellCommandMeta', () => {
|
||||
expect(
|
||||
parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"shell_command":"ls"}' },
|
||||
{ toolArgs: '{"shell_command":"ls"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.EXEC_SHELL_COMMAND
|
||||
)
|
||||
)?.command
|
||||
@@ -492,7 +518,7 @@ describe('parseExecShellCommandMeta', () => {
|
||||
expect(
|
||||
parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolName: BuiltInTool.EXEC_SHELL_COMMAND, toolArgs: '{"cwd":"/x"}' },
|
||||
{ toolArgs: '{"cwd":"/x"}', toolName: BuiltInTool.EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.EXEC_SHELL_COMMAND
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template';
|
||||
import {
|
||||
extractTemplateVariables,
|
||||
expandTemplate,
|
||||
extractTemplateVariables,
|
||||
isTemplateComplete,
|
||||
normalizeResourceUri
|
||||
} from '../../src/lib/utils/uri-template';
|
||||
import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('extractTemplateVariables', () => {
|
||||
it('extracts simple variables', () => {
|
||||
const vars = extractTemplateVariables('file:///{path}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'path', operator: '' }]);
|
||||
});
|
||||
|
||||
it('extracts multiple variables', () => {
|
||||
const vars = extractTemplateVariables('db://{schema}/{table}');
|
||||
|
||||
expect(vars).toEqual([
|
||||
{ name: 'schema', operator: '' },
|
||||
{ name: 'table', operator: '' }
|
||||
@@ -23,11 +25,13 @@ describe('extractTemplateVariables', () => {
|
||||
|
||||
it('extracts variables with operators', () => {
|
||||
const vars = extractTemplateVariables('http://example.com{+path}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.RESERVED }]);
|
||||
});
|
||||
|
||||
it('extracts comma-separated variable lists', () => {
|
||||
const vars = extractTemplateVariables('{x,y,z}');
|
||||
|
||||
expect(vars).toEqual([
|
||||
{ name: 'x', operator: '' },
|
||||
{ name: 'y', operator: '' },
|
||||
@@ -37,31 +41,37 @@ describe('extractTemplateVariables', () => {
|
||||
|
||||
it('deduplicates variable names', () => {
|
||||
const vars = extractTemplateVariables('{name}/{name}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'name', operator: '' }]);
|
||||
});
|
||||
|
||||
it('handles fragment expansion', () => {
|
||||
const vars = extractTemplateVariables('http://example.com/page{#section}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'section', operator: URI_TEMPLATE_OPERATORS.FRAGMENT }]);
|
||||
});
|
||||
|
||||
it('handles path segment expansion', () => {
|
||||
const vars = extractTemplateVariables('http://example.com{/path}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.PATH_SEGMENT }]);
|
||||
});
|
||||
|
||||
it('returns empty array for template without variables', () => {
|
||||
const vars = extractTemplateVariables('http://example.com/static');
|
||||
|
||||
expect(vars).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips explode modifier', () => {
|
||||
const vars = extractTemplateVariables('{list*}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'list', operator: '' }]);
|
||||
});
|
||||
|
||||
it('strips prefix modifier', () => {
|
||||
const vars = extractTemplateVariables('{value:5}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'value', operator: '' }]);
|
||||
});
|
||||
});
|
||||
@@ -69,11 +79,13 @@ describe('extractTemplateVariables', () => {
|
||||
describe('expandTemplate', () => {
|
||||
it('expands simple variable', () => {
|
||||
const result = expandTemplate('file:///{path}', { path: 'src/main.rs' });
|
||||
|
||||
expect(result).toBe('file:///src%2Fmain.rs');
|
||||
});
|
||||
|
||||
it('expands reserved variable (no encoding)', () => {
|
||||
const result = expandTemplate('file:///{+path}', { path: 'src/main.rs' });
|
||||
|
||||
expect(result).toBe('file:///src/main.rs');
|
||||
});
|
||||
|
||||
@@ -82,11 +94,13 @@ describe('expandTemplate', () => {
|
||||
schema: 'public',
|
||||
table: 'users'
|
||||
});
|
||||
|
||||
expect(result).toBe('db://public/users');
|
||||
});
|
||||
|
||||
it('leaves empty for missing variables', () => {
|
||||
const result = expandTemplate('{missing}', {});
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
@@ -94,16 +108,19 @@ describe('expandTemplate', () => {
|
||||
const result = expandTemplate('http://example.com/page{#section}', {
|
||||
section: 'intro'
|
||||
});
|
||||
|
||||
expect(result).toBe('http://example.com/page#intro');
|
||||
});
|
||||
|
||||
it('expands path segments', () => {
|
||||
const result = expandTemplate('http://example.com{/path}', { path: 'docs' });
|
||||
|
||||
expect(result).toBe('http://example.com/docs');
|
||||
});
|
||||
|
||||
it('expands query parameters', () => {
|
||||
const result = expandTemplate('http://example.com{?q}', { q: 'search term' });
|
||||
|
||||
expect(result).toBe('http://example.com?q=search%20term');
|
||||
});
|
||||
|
||||
@@ -112,11 +129,13 @@ describe('expandTemplate', () => {
|
||||
q: 'search term',
|
||||
sort: 'descending'
|
||||
});
|
||||
|
||||
expect(result).toBe('http://example.com?q=search%20term&sort=descending');
|
||||
});
|
||||
|
||||
it('keeps static parts unchanged', () => {
|
||||
const result = expandTemplate('http://example.com/static', {});
|
||||
|
||||
expect(result).toBe('http://example.com/static');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
|
||||
import {
|
||||
splitPathQuery,
|
||||
buildCaseInsensitiveGlob,
|
||||
buildGlobSearchArgs,
|
||||
rankEntries,
|
||||
highlightMatch,
|
||||
joinPath,
|
||||
highlightMatch
|
||||
rankEntries,
|
||||
splitPathQuery
|
||||
} from '$lib/utils';
|
||||
import { GLOB_WILDCARD, PATH_NAV_MAX_DEPTH } from '$lib/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('splitPathQuery', () => {
|
||||
it('treats a plain query as a home-relative glob (not navigation)', () => {
|
||||
@@ -15,54 +15,54 @@ describe('splitPathQuery', () => {
|
||||
});
|
||||
|
||||
it('navigates the root for `/`', () => {
|
||||
expect(splitPathQuery('/')).toEqual({ parent: '/', last: '' });
|
||||
expect(splitPathQuery('/')).toEqual({ last: '', parent: '/' });
|
||||
});
|
||||
|
||||
it('navigates home for `~`', () => {
|
||||
expect(splitPathQuery('~')).toEqual({ parent: '~', last: '' });
|
||||
expect(splitPathQuery('~')).toEqual({ last: '', parent: '~' });
|
||||
});
|
||||
|
||||
it('splits an absolute path into parent and last segment', () => {
|
||||
expect(splitPathQuery('/Users/al/proj')).toEqual({ parent: '/Users/al', last: 'proj' });
|
||||
expect(splitPathQuery('/Users/al/proj')).toEqual({ last: 'proj', parent: '/Users/al' });
|
||||
});
|
||||
|
||||
it('navigates a Windows drive path written with backslashes', () => {
|
||||
expect(splitPathQuery('C:\\repos\\llama.cpp')).toEqual({
|
||||
parent: 'C:/repos',
|
||||
last: 'llama.cpp'
|
||||
last: 'llama.cpp',
|
||||
parent: 'C:/repos'
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates a Windows drive path written with forward slashes', () => {
|
||||
expect(splitPathQuery('D:/repos')).toEqual({ parent: 'D:/', last: 'repos' });
|
||||
expect(splitPathQuery('D:/repos')).toEqual({ last: 'repos', parent: 'D:/' });
|
||||
});
|
||||
|
||||
it('treats a bare drive as its root', () => {
|
||||
expect(splitPathQuery('D:')).toEqual({ parent: 'D:/', last: '' });
|
||||
expect(splitPathQuery('D:\\')).toEqual({ parent: 'D:/', last: '' });
|
||||
expect(splitPathQuery('D:')).toEqual({ last: '', parent: 'D:/' });
|
||||
expect(splitPathQuery('D:\\')).toEqual({ last: '', parent: 'D:/' });
|
||||
});
|
||||
|
||||
it('navigates a UNC share', () => {
|
||||
expect(splitPathQuery('\\\\host\\share\\proj')).toEqual({
|
||||
parent: '//host/share/',
|
||||
last: 'proj'
|
||||
last: 'proj',
|
||||
parent: '//host/share/'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a backslash as a POSIX filename character', () => {
|
||||
expect(splitPathQuery('/tmp/a\\b')).toEqual({ parent: '/tmp', last: 'a\\b' });
|
||||
expect(splitPathQuery('/tmp/a\\b')).toEqual({ last: 'a\\b', parent: '/tmp' });
|
||||
});
|
||||
|
||||
it('splits a home-relative path into parent and last segment', () => {
|
||||
expect(splitPathQuery('~/Documents')).toEqual({ parent: '~', last: 'Documents' });
|
||||
expect(splitPathQuery('~/Documents')).toEqual({ last: 'Documents', parent: '~' });
|
||||
});
|
||||
|
||||
it('strips trailing slashes before splitting', () => {
|
||||
expect(splitPathQuery('/Users/al/')).toEqual({ parent: '/Users', last: 'al' });
|
||||
expect(splitPathQuery('/Users/al/')).toEqual({ last: 'al', parent: '/Users' });
|
||||
});
|
||||
|
||||
it('handles a single-segment absolute path', () => {
|
||||
expect(splitPathQuery('/opt')).toEqual({ parent: '/', last: 'opt' });
|
||||
expect(splitPathQuery('/opt')).toEqual({ last: 'opt', parent: '/' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,16 +85,19 @@ describe('rankEntries', () => {
|
||||
|
||||
it('ranks exact basename match first', () => {
|
||||
const ranked = rankEntries(entries, 'read');
|
||||
|
||||
expect(ranked[0].path).toBe('/h/read');
|
||||
});
|
||||
|
||||
it('breaks ties by shorter path, then alphabetically', () => {
|
||||
const ranked = rankEntries(entries, 'read');
|
||||
|
||||
expect(ranked[ranked.length - 1].path).toBe('/h/readme.txt');
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const snapshot = [...entries];
|
||||
|
||||
rankEntries(entries, 'read');
|
||||
expect(entries).toEqual(snapshot);
|
||||
});
|
||||
@@ -112,18 +115,18 @@ describe('joinPath', () => {
|
||||
|
||||
describe('highlightMatch', () => {
|
||||
it('returns a single non-matching segment when query is empty', () => {
|
||||
expect(highlightMatch('abc', '')).toEqual([{ text: 'abc', match: false }]);
|
||||
expect(highlightMatch('abc', '')).toEqual([{ match: false, text: 'abc' }]);
|
||||
});
|
||||
|
||||
it('marks every case-insensitive occurrence of the query', () => {
|
||||
expect(highlightMatch('aXa', 'ax')).toEqual([
|
||||
{ text: 'aX', match: true },
|
||||
{ text: 'a', match: false }
|
||||
{ match: true, text: 'aX' },
|
||||
{ match: false, text: 'a' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns non-matching text when the query is absent', () => {
|
||||
expect(highlightMatch('abc', 'z')).toEqual([{ text: 'abc', match: false }]);
|
||||
expect(highlightMatch('abc', 'z')).toEqual([{ match: false, text: 'abc' }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +135,7 @@ describe('buildGlobSearchArgs', () => {
|
||||
|
||||
it('glob-matches home-relative within the scope path', () => {
|
||||
const args = buildGlobSearchArgs('docs', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('/home');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('docs'));
|
||||
expect(args.maxDepth).toBe(DEPTH);
|
||||
@@ -141,6 +145,7 @@ describe('buildGlobSearchArgs', () => {
|
||||
|
||||
it('navigates home for a `~` path query', () => {
|
||||
const args = buildGlobSearchArgs('~/proj', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
@@ -150,6 +155,7 @@ describe('buildGlobSearchArgs', () => {
|
||||
|
||||
it('lists the scope root when a path query has no last segment', () => {
|
||||
const args = buildGlobSearchArgs('~/', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(GLOB_WILDCARD);
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
@@ -157,6 +163,7 @@ describe('buildGlobSearchArgs', () => {
|
||||
|
||||
it('navigates an absolute path under its root', () => {
|
||||
const args = buildGlobSearchArgs('/usr/local/bin', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('/usr/local');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
|
||||
expect(args.maxDepth).toBe(PATH_NAV_MAX_DEPTH);
|
||||
|
||||
Reference in New Issue
Block a user