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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user