include llama.cpp since there were some edits
This commit is contained in:
1 parent
db7f28d087
commit
8c66990853
3918 files changed
+1640170
-1
No files matched your search
@@ -0,0 +1,58 @@
|
||||
# Agentic thread perf harness
|
||||
|
||||
Two tiers, both reusing the existing vitest projects (see `vite.config.ts`).
|
||||
|
||||
## Tier 1 - `agentic-stream.perf.svelte.test.ts` (project: `client`, real Chromium)
|
||||
|
||||
Mounts `ChatMessageAgenticContent` and replays a stream, replacing the message
|
||||
object on each chunk exactly as the real pipeline does:
|
||||
|
||||
- `chat.svelte.ts` `updateStreamingUI()` runs per SSE chunk
|
||||
- `conversations.svelte.ts` `updateMessageAtIndex` does `{ ...old, ...updates }`
|
||||
|
||||
That new object identity is the thing under test: it cascades through
|
||||
`deriveAgenticSections` (which returns fresh `AgenticSection` objects) into every
|
||||
tool-call block in the message, including completed ones.
|
||||
|
||||
```
|
||||
npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
|
||||
```
|
||||
|
||||
### Reading the output
|
||||
|
||||
- `mean` / `p95` / `max` - the synchronous window per token: prop write,
|
||||
`await tick()`, then a forced `offsetHeight` read so style and layout are
|
||||
included rather than deferred.
|
||||
- `sync` - sum of those windows. This is the number to optimize.
|
||||
- `wall` - the whole run including work `MarkdownContent` defers into its own
|
||||
`requestAnimationFrame`. It carries a ~16.7ms/token idle floor because the
|
||||
harness yields a frame each iteration, so compare `wall` **across fixtures**,
|
||||
never against `sync`.
|
||||
|
||||
### The knobs, and what each one discriminates
|
||||
|
||||
The point of the harness is the _scaling curve_, not any single number.
|
||||
|
||||
| Knob | Reads on |
|
||||
| --------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `priorToolCalls` (0/1/5/20) | the reactive fan-out. Flat => no fan-out. Linear => confirmed. |
|
||||
| `toolResultBytes` | whole-blob string scans (`extractSearchResults`, `parseToolResultWithMedia`, `classifyToolResult`). |
|
||||
| `editFileEdits` | `computeLineDiff`, the O(m\*n) LCS. |
|
||||
| `openCodeFence` | `hljs.highlightAuto` on partial code. |
|
||||
|
||||
Deliberately no hard assertions: CI timing is noisy and the value here is the
|
||||
before/after delta, not a gate.
|
||||
|
||||
### Caveat
|
||||
|
||||
This measures one message's subtree. In the real app `ChatMessages.svelte`
|
||||
rebuilds its whole `displayMessages` list per token, so multiply by the number
|
||||
of rendered messages to get the conversation-level cost.
|
||||
|
||||
## Tier 2 - `../unit/agentic-hotpath.bench.ts` (project: `unit`, node)
|
||||
|
||||
Per-call costs for the pure functions the curve implicates.
|
||||
|
||||
```
|
||||
npx vitest bench --project=unit --run tests/unit/agentic-hotpath.bench.ts
|
||||
```
|
||||
@@ -0,0 +1,426 @@
|
||||
// Tier 1 perf harness for the agentic thread.
|
||||
//
|
||||
// Drives ChatMessageAgenticContent the way the real streaming pipeline does:
|
||||
// chat.svelte.ts -> conversations.svelte.ts:184 replaces the message object per
|
||||
// SSE chunk (`{ ...old, ...updates }`), which changes prop identity and cascades
|
||||
// through deriveAgenticSections into every tool-call block in the message.
|
||||
//
|
||||
// The fixture is parameterized so the *scaling curve* identifies the culprit -
|
||||
// a single number would not. See tests/client/README-perf.md.
|
||||
//
|
||||
// Run: npx vitest --project=client --run tests/client/agentic-stream.perf.svelte.test.ts
|
||||
|
||||
import { perfState } from './components/agentic-perf-state.svelte';
|
||||
import AgenticPerfWrapper from './components/AgenticPerfWrapper.svelte';
|
||||
import ChatMessagesPerfWrapper from './components/ChatMessagesPerfWrapper.svelte';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
// --- fixture construction -------------------------------------------------
|
||||
|
||||
interface FixtureOpts {
|
||||
/** Completed tool-call sections preceding the streaming text. */
|
||||
priorToolCalls: number;
|
||||
/** Size of each tool result blob. */
|
||||
toolResultBytes: number;
|
||||
/** Number of edit_file calls (each a 400x400-line diff). */
|
||||
editFileEdits: number;
|
||||
/** Leave an unclosed ``` fence at the end of the streamed content. */
|
||||
openCodeFence: boolean;
|
||||
/**
|
||||
* Emit a blank line every N chunks so the content forms real markdown
|
||||
* blocks. 0 = one unbroken paragraph, which defeats MarkdownContent's
|
||||
* stable-block cache entirely (worst case). Typical prose has breaks.
|
||||
*/
|
||||
paragraphEvery: number;
|
||||
}
|
||||
|
||||
const DEFAULTS: FixtureOpts = {
|
||||
editFileEdits: 0,
|
||||
openCodeFence: false,
|
||||
paragraphEvery: 0,
|
||||
priorToolCalls: 0,
|
||||
toolResultBytes: 1024
|
||||
};
|
||||
|
||||
function blob(bytes: number, seed: string): string {
|
||||
const line = `${seed} output line with some representative width to it`;
|
||||
const n = Math.max(1, Math.ceil(bytes / (line.length + 1)));
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) out.push(`${line} ${i}`);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
function diffLines(n: number, seed: string): string {
|
||||
const out: string[] = [];
|
||||
|
||||
for (let i = 0; i < n; i++) out.push(`${seed} line ${i} const value_${i} = compute(${i});`);
|
||||
|
||||
return out.join('\n');
|
||||
}
|
||||
|
||||
let msgSeq = 0;
|
||||
|
||||
function baseMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'perf-conv',
|
||||
id: `m${msgSeq++}`,
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
function buildFixture(opts: FixtureOpts): {
|
||||
message: DatabaseMessage;
|
||||
toolMessages: DatabaseMessage[];
|
||||
} {
|
||||
const toolCalls: unknown[] = [];
|
||||
const toolMessages: DatabaseMessage[] = [];
|
||||
|
||||
for (let i = 0; i < opts.priorToolCalls; i++) {
|
||||
const id = `call_${i}`;
|
||||
|
||||
toolCalls.push({
|
||||
function: {
|
||||
arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
|
||||
name: 'exec_shell_command'
|
||||
},
|
||||
id,
|
||||
type: 'function'
|
||||
});
|
||||
toolMessages.push(
|
||||
baseMessage({
|
||||
content: `${blob(opts.toolResultBytes, `t${i}`)}\n[exit code: 0]`,
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (let i = 0; i < opts.editFileEdits; i++) {
|
||||
const id = `edit_${i}`;
|
||||
|
||||
toolCalls.push({
|
||||
function: {
|
||||
arguments: JSON.stringify({
|
||||
edits: [{ new_text: diffLines(400, 'new'), old_text: diffLines(400, 'old') }],
|
||||
path: `/src/file_${i}.ts`
|
||||
}),
|
||||
name: 'edit_file'
|
||||
},
|
||||
id,
|
||||
type: 'function'
|
||||
});
|
||||
toolMessages.push(
|
||||
baseMessage({
|
||||
content: JSON.stringify({ edits_applied: 1, result: 'ok' }),
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const message = baseMessage({
|
||||
content: '',
|
||||
toolCalls: toolCalls.length > 0 ? JSON.stringify(toolCalls) : undefined
|
||||
});
|
||||
|
||||
return { message, toolMessages };
|
||||
}
|
||||
|
||||
// --- the driver -----------------------------------------------------------
|
||||
|
||||
interface Sample {
|
||||
label: string;
|
||||
tokens: number;
|
||||
mean: number;
|
||||
p95: number;
|
||||
max: number;
|
||||
/** Sum of the synchronous per-token windows. */
|
||||
total: number;
|
||||
/** Wall-clock for the whole run incl. deferred rAF work, then settle. */
|
||||
wall: number;
|
||||
}
|
||||
|
||||
function nextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
const results: Sample[] = [];
|
||||
|
||||
/**
|
||||
* Replays `tokens` streamed chunks, replacing the message object each time
|
||||
* exactly as conversations.svelte.ts:184 does, flushing Svelte and forcing
|
||||
* layout so the measurement includes render + style/layout, not just script.
|
||||
*/
|
||||
async function measure(label: string, partial: Partial<FixtureOpts>, tokens = 60) {
|
||||
const opts = { ...DEFAULTS, ...partial };
|
||||
const { message, toolMessages } = buildFixture(opts);
|
||||
|
||||
perfState.message = message;
|
||||
perfState.toolMessages = toolMessages;
|
||||
perfState.isStreaming = true;
|
||||
|
||||
// Every wrapper reads the same module state, so a leftover mount from a
|
||||
// previous fixture would re-render on each mutation and fold its cost into
|
||||
// this measurement. Tear down explicitly between fixtures.
|
||||
const { unmount } = render(AgenticPerfWrapper);
|
||||
|
||||
await tick();
|
||||
|
||||
const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
|
||||
|
||||
let accumulated = opts.openCodeFence ? '```notalanguage\n' : '';
|
||||
|
||||
const durations: number[] = [];
|
||||
const wallStart = performance.now();
|
||||
|
||||
for (let i = 0; i < tokens; i++) {
|
||||
accumulated += CHUNK;
|
||||
|
||||
if (opts.paragraphEvery > 0 && (i + 1) % opts.paragraphEvery === 0) {
|
||||
accumulated += '\n\n';
|
||||
}
|
||||
|
||||
const t0 = performance.now();
|
||||
|
||||
// Mirrors updateMessageAtIndex: a brand-new object identity per chunk.
|
||||
perfState.message = { ...perfState.message!, content: accumulated };
|
||||
await tick();
|
||||
void document.body.offsetHeight; // force style + layout
|
||||
durations.push(performance.now() - t0);
|
||||
|
||||
// MarkdownContent coalesces its parse into a rAF, so that work lands
|
||||
// outside the window above. Yield a frame each iteration so it is
|
||||
// captured in `wall` - the gap between `wall` and `total` is the
|
||||
// deferred cost.
|
||||
await nextFrame();
|
||||
}
|
||||
|
||||
// Let any trailing coalesced work drain before stopping the clock.
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
const wall = performance.now() - wallStart;
|
||||
|
||||
await unmount();
|
||||
|
||||
durations.sort((a, b) => a - b);
|
||||
const total = durations.reduce((a, b) => a + b, 0);
|
||||
|
||||
results.push({
|
||||
label,
|
||||
max: durations[durations.length - 1],
|
||||
mean: total / durations.length,
|
||||
p95: durations[Math.floor(durations.length * 0.95)],
|
||||
tokens,
|
||||
total,
|
||||
wall
|
||||
});
|
||||
}
|
||||
|
||||
function report() {
|
||||
const pad = (s: string, n: number) => s.padEnd(n);
|
||||
const num = (n: number) => n.toFixed(2).padStart(8);
|
||||
const header = `${pad('fixture', 40)}${pad('tok', 5)}${'mean'.padStart(8)}${'p95'.padStart(8)}${'max'.padStart(8)}${'sync'.padStart(9)}${'wall'.padStart(9)}`;
|
||||
const lines = [
|
||||
'',
|
||||
'=== Tier 1: ms per streamed token (agentic content subtree) ===',
|
||||
'mean/p95/max = synchronous window per token (script + style + layout).',
|
||||
'sync = sum of those windows.',
|
||||
'wall = whole run, incl. work MarkdownContent defers into its own rAF.',
|
||||
' NOTE: wall carries a ~16.7ms/token idle floor from the harness',
|
||||
' yielding a frame each iteration (60 tokens => ~1000ms floor).',
|
||||
' Compare wall ACROSS fixtures / against the baseline row,',
|
||||
' never against sync.',
|
||||
'',
|
||||
header,
|
||||
'-'.repeat(header.length)
|
||||
];
|
||||
|
||||
for (const r of results) {
|
||||
lines.push(
|
||||
`${pad(r.label, 40)}${pad(String(r.tokens), 5)}${num(r.mean)}${num(r.p95)}${num(r.max)}${num(r.total)}${num(r.wall)}`
|
||||
);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
console.log(lines.join('\n'));
|
||||
}
|
||||
|
||||
// --- conversation-level driver --------------------------------------------
|
||||
// The per-message driver above cannot see the fan-out in ChatMessages: a single
|
||||
// token mutation invalidates `displayMessages`, which rebuilds a fresh
|
||||
// toolMessages array for EVERY message in the conversation. Drive the real
|
||||
// store through the real list component to measure that.
|
||||
|
||||
async function measureConversation(
|
||||
label: string,
|
||||
priorMessages: number,
|
||||
tokens = 60,
|
||||
/**
|
||||
* Give each prior assistant turn a resolved tool call, so the fixture pays
|
||||
* `hasAgenticContent`'s JSON.parse and the tool-message grouping walk that a
|
||||
* real agent thread would - plain prose messages skip both.
|
||||
*/
|
||||
agentic = false
|
||||
) {
|
||||
const history: DatabaseMessage[] = [];
|
||||
|
||||
for (let i = 0; i < priorMessages; i++) {
|
||||
const isAssistant = i % 2 !== 0;
|
||||
|
||||
if (isAssistant && agentic) {
|
||||
const id = `prior_call_${i}`;
|
||||
|
||||
history.push(
|
||||
baseMessage({
|
||||
content: `Message ${i}`,
|
||||
role: MessageRole.ASSISTANT,
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: {
|
||||
arguments: JSON.stringify({ command: `grep -rn "thing_${i}" src/` }),
|
||||
name: 'exec_shell_command'
|
||||
},
|
||||
id,
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
})
|
||||
);
|
||||
history.push(
|
||||
baseMessage({
|
||||
content: `${blob(1024, `r${i}`)}\n[exit code: 0]`,
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: id
|
||||
})
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
history.push(
|
||||
baseMessage({
|
||||
content: `Message ${i}: ${blob(512, `m${i}`)}`,
|
||||
role: isAssistant ? MessageRole.ASSISTANT : MessageRole.USER
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const streaming = baseMessage({ content: '', role: MessageRole.ASSISTANT });
|
||||
|
||||
history.push(streaming);
|
||||
|
||||
conversationsStore.activeMessages = history;
|
||||
|
||||
const { unmount } = render(ChatMessagesPerfWrapper);
|
||||
|
||||
await tick();
|
||||
|
||||
const idx = conversationsStore.findMessageIndex(streaming.id);
|
||||
const CHUNK = 'The quick brown fox jumps over the lazy dog. ';
|
||||
|
||||
let accumulated = '';
|
||||
|
||||
const durations: number[] = [];
|
||||
const wallStart = performance.now();
|
||||
|
||||
for (let i = 0; i < tokens; i++) {
|
||||
accumulated += CHUNK;
|
||||
|
||||
const t0 = performance.now();
|
||||
|
||||
// The real path: chat.svelte.ts -> conversations.svelte.ts.
|
||||
conversationsStore.updateMessageAtIndex(idx, { content: accumulated });
|
||||
await tick();
|
||||
void document.body.offsetHeight;
|
||||
durations.push(performance.now() - t0);
|
||||
|
||||
await nextFrame();
|
||||
}
|
||||
|
||||
await nextFrame();
|
||||
await nextFrame();
|
||||
const wall = performance.now() - wallStart;
|
||||
|
||||
await unmount();
|
||||
conversationsStore.activeMessages = [];
|
||||
|
||||
durations.sort((a, b) => a - b);
|
||||
const total = durations.reduce((a, b) => a + b, 0);
|
||||
|
||||
results.push({
|
||||
label,
|
||||
max: durations[durations.length - 1],
|
||||
mean: total / durations.length,
|
||||
p95: durations[Math.floor(durations.length * 0.95)],
|
||||
tokens,
|
||||
total,
|
||||
wall
|
||||
});
|
||||
}
|
||||
|
||||
// --- the matrix -----------------------------------------------------------
|
||||
// Sequential, in one test, so the table prints together and the samples do not
|
||||
// interleave with other suites competing for the main thread.
|
||||
|
||||
describe('agentic streaming perf', () => {
|
||||
it('scales', { timeout: 600_000 }, async () => {
|
||||
// Baseline: plain text streaming, nothing agentic.
|
||||
await measure('baseline: no tool calls', {});
|
||||
|
||||
// Knob: priorToolCalls. Flat => no fan-out. Linear => fan-out confirmed.
|
||||
await measure('priorToolCalls=1 (1KB results)', { priorToolCalls: 1 });
|
||||
await measure('priorToolCalls=5 (1KB results)', { priorToolCalls: 5 });
|
||||
await measure('priorToolCalls=20 (1KB results)', { priorToolCalls: 20 });
|
||||
|
||||
// Knob: toolResultBytes, at fixed section count.
|
||||
await measure('5 calls x 200KB results', {
|
||||
priorToolCalls: 5,
|
||||
toolResultBytes: 200 * 1024
|
||||
});
|
||||
|
||||
// Knob: editFileEdits (computeLineDiff, 400x400 LCS).
|
||||
await measure('3 edit_file calls (400x400 diff)', { editFileEdits: 3 });
|
||||
|
||||
// Knob: openCodeFence (hljs highlightAuto on partial code).
|
||||
await measure('open code fence, unknown language', { openCodeFence: true });
|
||||
|
||||
// Conversation level: does streaming one message cost more as the
|
||||
// conversation grows? Flat => no fan-out. Linear => confirmed.
|
||||
await measureConversation('convo: 1 prior message', 1);
|
||||
await measureConversation('convo: 10 prior messages', 10);
|
||||
await measureConversation('convo: 40 prior messages', 40);
|
||||
|
||||
// Same, but each prior assistant turn carries a resolved tool call.
|
||||
await measureConversation('convo: 10 prior, agentic', 10, 60, true);
|
||||
await measureConversation('convo: 40 prior, agentic', 40, 60, true);
|
||||
|
||||
// Message length: MarkdownContent re-parses the whole accumulated string
|
||||
// each frame, so per-token cost should climb as the response grows.
|
||||
// A rising mean across these three rows means O(n^2) over the stream.
|
||||
// One unbroken paragraph: worst case, stable-block cache never applies.
|
||||
await measure('len 60tok 1-para (worst case)', {}, 60);
|
||||
await measure('len 250tok 1-para (worst case)', {}, 250);
|
||||
await measure('len 600tok 1-para (worst case)', {}, 600);
|
||||
|
||||
// Same lengths, broken into paragraphs every 8 chunks: the typical shape,
|
||||
// where only the trailing paragraph should be unstable.
|
||||
await measure('len 60tok paras (typical)', { paragraphEvery: 8 }, 60);
|
||||
await measure('len 250tok paras (typical)', { paragraphEvery: 8 }, 250);
|
||||
await measure('len 600tok paras (typical)', { paragraphEvery: 8 }, 600);
|
||||
|
||||
report();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { validateApiKey } from '$lib/utils/api-key-validation';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function fakeFetch(status: number, capture: { auth?: string | null } = {}) {
|
||||
return (async (_url: RequestInfo | URL, init?: RequestInit) => {
|
||||
capture.auth = (init?.headers as Record<string, string>)?.['Authorization'] ?? null;
|
||||
|
||||
return new Response(status === 200 ? '{}' : 'Unauthorized', { status });
|
||||
}) as typeof globalThis.fetch;
|
||||
}
|
||||
|
||||
const is401 = (err: unknown) =>
|
||||
typeof err === 'object' && err !== null && 'status' in err && err.status === 401;
|
||||
|
||||
describe('api key validation surfaces the splash', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('keyed server, no stored key: throws 401 so the splash shows (onboarding)', async () => {
|
||||
await expect(validateApiKey(fakeFetch(401))).rejects.toSatisfy(is401);
|
||||
});
|
||||
|
||||
it('keyed server, wrong stored key: throws 401 so the splash shows', async () => {
|
||||
settingsStore.updateConfig('apiKey', 'wrong-key');
|
||||
await expect(validateApiKey(fakeFetch(401))).rejects.toSatisfy(is401);
|
||||
});
|
||||
|
||||
it('open server, no stored key: passes silently', async () => {
|
||||
await expect(validateApiKey(fakeFetch(200))).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('valid stored key: passes and sends the bearer header', async () => {
|
||||
settingsStore.updateConfig('apiKey', 'sk-good');
|
||||
const capture: { auth?: string | null } = {};
|
||||
|
||||
await expect(validateApiKey(fakeFetch(200, capture))).resolves.toBeUndefined();
|
||||
expect(capture.auth).toBe('Bearer sk-good');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { capImageDataURLSize } from '$lib/utils/cap-img-size';
|
||||
import { getJpegOrientationFromDataURL } from '$lib/utils/jpeg-orientation';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Real 64x32 jpegs generated with Pillow, quality 90. The upright picture is
|
||||
// four solid quadrants: top left red, top right green, bottom left blue,
|
||||
// bottom right yellow. For each exif value the stored pixels are inverse
|
||||
// transposed so a conforming decoder shows the upright picture, exactly like
|
||||
// a rotated smartphone photo.
|
||||
const EXIF1 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
|
||||
const EXIF3 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAMAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD7Nooor/Mo/XQooooA/Cqiiiv+og/moKKKKAPuuiiiv+fw/lcKKKKAPEaKKK+9P+jMKKKKAP/Z`;
|
||||
const EXIF5 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAUAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UzwKiiiv9xj/HQ99ooor/Dk/wBizwKiiiv9xj/HQ+66KKK/5/D+Vz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/9k=`;
|
||||
const EXIF6 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDCooor+Tz+Hz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/Keiiiv7CP72PAqKKK/3GP8AHQ99ooor/Dk/2LPAqKKK/wBxj/HQ/9k=`;
|
||||
const EXIF8 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAgAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD896KKK/1TPhz32iiiv8OT/Ys8Cooor/cY/wAdD32iiiv8OT/Ys/Viiiiv49P4JPhWiiiv6FP8wj7qooor+ej/AE9PhWiiiv6FP8wj/9k=`;
|
||||
const NOEXIF = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
|
||||
const RED: Rgb = [255, 0, 0];
|
||||
const GREEN: Rgb = [0, 200, 0];
|
||||
const BLUE: Rgb = [0, 0, 255];
|
||||
const YELLOW: Rgb = [255, 220, 0];
|
||||
// Wide tolerance per channel, jpeg compression shifts solid colors a bit
|
||||
const COLOR_TOLERANCE = 70;
|
||||
// 0.000512 megapixels is 512 pixels, a quarter of the area of the 2048 pixel fixtures
|
||||
const QUARTER_AREA_MEGAPIXELS = 0.000512;
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Failed to decode image.'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// Decodes a data URL and samples the center of each quadrant of the picture
|
||||
async function quadrantColors(dataUrl: string): Promise<Rgb[]> {
|
||||
const img = await loadImage(dataUrl);
|
||||
const canvas = document.createElement('canvas');
|
||||
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const points = [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.25],
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75]
|
||||
];
|
||||
|
||||
return points.map(([fx, fy]) => {
|
||||
const d = ctx.getImageData(
|
||||
Math.floor(canvas.width * fx),
|
||||
Math.floor(canvas.height * fy),
|
||||
1,
|
||||
1
|
||||
).data;
|
||||
|
||||
return [d[0], d[1], d[2]];
|
||||
});
|
||||
}
|
||||
|
||||
function expectUpright(colors: Rgb[]) {
|
||||
const targets = [RED, GREEN, BLUE, YELLOW];
|
||||
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
expect(Math.abs(colors[i][c] - targets[i][c])).toBeLessThan(COLOR_TOLERANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('capImageDataURLSize orientation and capping', () => {
|
||||
it('passes upright jpegs through untouched when capping is disabled', async () => {
|
||||
expect(await capImageDataURLSize(EXIF1, 0)).toBe(EXIF1);
|
||||
expect(await capImageDataURLSize(NOEXIF, 0)).toBe(NOEXIF);
|
||||
});
|
||||
|
||||
it('passes upright jpegs through untouched when under the cap threshold', async () => {
|
||||
expect(await capImageDataURLSize(EXIF1, 1)).toBe(EXIF1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['orientation 3', EXIF3],
|
||||
['orientation 5', EXIF5],
|
||||
['orientation 6', EXIF6],
|
||||
['orientation 8', EXIF8]
|
||||
])('bakes %s into upright pixels without capping', async (_label, fixture) => {
|
||||
const result = await capImageDataURLSize(fixture, 0);
|
||||
|
||||
expect(result).not.toBe(fixture);
|
||||
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(64);
|
||||
expect(img.naturalHeight).toBe(32);
|
||||
expectUpright(await quadrantColors(result));
|
||||
|
||||
// The re-encoded jpeg carries no orientation tag anymore
|
||||
expect(getJpegOrientationFromDataURL(result)).toBe(1);
|
||||
});
|
||||
|
||||
it('caps and bakes the orientation in a single output', async () => {
|
||||
const result = await capImageDataURLSize(EXIF6, QUARTER_AREA_MEGAPIXELS);
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(32);
|
||||
expect(img.naturalHeight).toBe(16);
|
||||
expectUpright(await quadrantColors(result));
|
||||
expect(getJpegOrientationFromDataURL(result)).toBe(1);
|
||||
});
|
||||
|
||||
it('caps upright jpegs without disturbing the picture', async () => {
|
||||
const result = await capImageDataURLSize(EXIF1, QUARTER_AREA_MEGAPIXELS);
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(32);
|
||||
expect(img.naturalHeight).toBe(16);
|
||||
expectUpright(await quadrantColors(result));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
// Guards the Enter-key contract of the chat form against the
|
||||
// fenced-code-block flow: while the caret sits inside a fenced
|
||||
// block region - closed, or still OPEN while the user is typing
|
||||
// one - plain Enter adds a line instead of submitting the message.
|
||||
// The textarea path is covered here end-to-end (the ChatFormInputRich
|
||||
// consumes the same case locally; see chat-form-input-rich).
|
||||
|
||||
import ChatFormTestWrapper from './components/ChatFormTestWrapper.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
function textareaIn(container: HTMLElement): HTMLTextAreaElement {
|
||||
const el = container.querySelector('textarea');
|
||||
|
||||
if (!(el instanceof HTMLTextAreaElement)) throw new Error('textarea not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
describe('ChatForm Enter in code blocks', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.SEND_ON_ENTER, true);
|
||||
});
|
||||
|
||||
it('adds a line after a still-open fence instead of submitting', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(textarea.value).toBe('```\n');
|
||||
});
|
||||
|
||||
it('keeps adding lines while the block stays open', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```js');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await userEvent.keyboard('const a = 1;');
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
expect(textarea.value).toBe('```js\nconst a = 1;\n');
|
||||
});
|
||||
|
||||
it('submits when the caret is before the opening fence', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
textarea.setSelectionRange(0, 0);
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Enter outside a code block', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('hello');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('submits on Ctrl+Enter even inside a code block', async () => {
|
||||
const onSubmit = vi.fn();
|
||||
const { container } = render(ChatFormTestWrapper, { onSubmit });
|
||||
|
||||
await tick();
|
||||
|
||||
const textarea = textareaIn(container);
|
||||
|
||||
await userEvent.click(textarea);
|
||||
await userEvent.keyboard('```');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||
await tick();
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
// Guards the newline contract of the ChatFormInputRich: browsers
|
||||
// restructure the flat DOM on Enter (`<div>` wrappers, `<br>` shapes) and
|
||||
// serialization must fold those back into `\n` so the emitted value never
|
||||
// diverges from what is on screen.
|
||||
|
||||
import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b) here';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function fireInput(root: HTMLElement) {
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true }));
|
||||
}
|
||||
|
||||
function setCaret(node: Node, offset: number) {
|
||||
const range = document.createRange();
|
||||
|
||||
range.setStart(node, offset);
|
||||
range.setEnd(node, offset);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) throw new Error('no selection');
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
describe('ChatFormInputRich browser newline shapes', () => {
|
||||
it('serializes a Chromium Enter <div> wrapper as a newline', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.textContent = 'second line';
|
||||
root.appendChild(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a Firefox full <div> wrap as lines, badge included', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const first = document.createElement('div');
|
||||
|
||||
while (root.firstChild) first.appendChild(root.firstChild);
|
||||
const second = document.createElement('div');
|
||||
|
||||
second.textContent = 'second line';
|
||||
root.appendChild(first);
|
||||
root.appendChild(second);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE}\nsecond line`);
|
||||
});
|
||||
|
||||
it('serializes a <br> as a newline', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'here' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
root.appendChild(document.createElement('br'));
|
||||
root.appendChild(document.createTextNode('second line'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('here\nsecond line');
|
||||
});
|
||||
|
||||
it('ignores a trailing <br> (browser caret placeholder)', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
root.appendChild(document.createElement('br'));
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('serializes one newline per empty-line <div><br></div>', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.appendChild(div);
|
||||
}
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('abc\n\n');
|
||||
});
|
||||
|
||||
it('treats a <div><br></div>-only buffer as empty for the placeholder', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.appendChild(document.createElement('br'));
|
||||
root.replaceChildren(div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(root.dataset.empty).toBe('true');
|
||||
});
|
||||
|
||||
it('maps the caret across block boundaries in both directions', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc\ndef' });
|
||||
|
||||
await tick();
|
||||
|
||||
// Rebuild into the Chromium block shape; the source is unchanged,
|
||||
// so no re-render fires.
|
||||
const root = editableIn(screen.container);
|
||||
const div = document.createElement('div');
|
||||
|
||||
div.textContent = 'def';
|
||||
root.replaceChildren(document.createTextNode('abc'), div);
|
||||
fireInput(root);
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc\ndef');
|
||||
|
||||
const divText = div.firstChild;
|
||||
|
||||
if (!divText) throw new Error('div text missing');
|
||||
|
||||
setCaret(divText, 2);
|
||||
expect(screen.component.getCaretOffset()).toBe(6);
|
||||
|
||||
screen.component.setCaretOffset(6);
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(selection?.anchorNode).toBe(divText);
|
||||
expect(selection?.anchorOffset).toBe(2);
|
||||
|
||||
// The boundary newline itself: offset 3 is the end of "abc", offset
|
||||
// 4 the start of the "def" line.
|
||||
screen.component.setCaretOffset(4);
|
||||
expect(window.getSelection()?.anchorNode).toBe(divText);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(0);
|
||||
|
||||
screen.component.setCaretOffset(3);
|
||||
expect(window.getSelection()?.anchorNode).toBe(root.firstChild);
|
||||
expect(window.getSelection()?.anchorOffset).toBe(3);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
// Guards the editing-key contract of the ChatFormInputRich:
|
||||
// undo/redo is replayed from source snapshots (the token rebuilds destroy
|
||||
// the native undo stack), and Tab is NOT intercepted (WCAG 2.1.2 no
|
||||
// keyboard trap), matching the plain textarea.
|
||||
|
||||
import ChatFormInputRichHarness from './components/ChatFormInputRichHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const SOURCE = 'see [docs](file:///a/b)';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function type(root: HTMLElement, text: string, inputType = 'insertText') {
|
||||
root.appendChild(document.createTextNode(text));
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType }));
|
||||
}
|
||||
|
||||
function keydown(root: HTMLElement, init: KeyboardEventInit) {
|
||||
const event = new KeyboardEvent('keydown', { bubbles: true, cancelable: true, ...init });
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
describe('ChatFormInputRich undo/redo', () => {
|
||||
it('undoes and redoes an edit across a badge-containing buffer', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
|
||||
const undoEvent = keydown(root, { ctrlKey: true, key: 'z' });
|
||||
|
||||
await tick();
|
||||
expect(undoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
const redoEvent = keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
|
||||
|
||||
await tick();
|
||||
expect(redoEvent.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('redoes with Ctrl+Y as well', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, ' more');
|
||||
await tick();
|
||||
keydown(root, { key: 'z', metaKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(SOURCE);
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'y' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe(`${SOURCE} more`);
|
||||
});
|
||||
|
||||
it('coalesces a typing burst into one undo step', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, 'd');
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcde');
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('keeps a newline as its own undo step', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, 'd');
|
||||
type(root, '\n', 'insertLineBreak');
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd\n');
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abcd');
|
||||
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('is a no-op when there is nothing to undo', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { ctrlKey: true, key: 'z' });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
});
|
||||
|
||||
it('abandons the redo branch after a fresh edit', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: 'abc' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
|
||||
type(root, 'd');
|
||||
await tick();
|
||||
keydown(root, { ctrlKey: true, key: 'z' });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abc');
|
||||
|
||||
type(root, 'e');
|
||||
await tick();
|
||||
keydown(root, { ctrlKey: true, key: 'z', shiftKey: true });
|
||||
await tick();
|
||||
expect(screen.component.getValue()).toBe('abce');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich Tab key', () => {
|
||||
it('does not trap Tab (focus can leave the editable)', async () => {
|
||||
const screen = render(ChatFormInputRichHarness, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(screen.container);
|
||||
const event = keydown(root, { key: 'Tab' });
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,804 @@
|
||||
// Guards the clipboard contract of the ChatFormInputRich:
|
||||
// copy/cut expose the markdown SOURCE of the selection (each badge
|
||||
// contributes its full `[name](file://...)` link) and pasting such
|
||||
// markdown re-renders the badges.
|
||||
|
||||
import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
|
||||
import { rangeToTextOffset, serializeContent, textOffsetToRange } from '$lib/utils';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const SOURCE = 'hello [docs](file:///a/b) world';
|
||||
const BADGE_SELECTOR = '[data-mention-badge="true"]';
|
||||
|
||||
function editableIn(container: HTMLElement): HTMLElement {
|
||||
const el = container.querySelector('[role="textbox"]');
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('ChatFormInputRich not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
function setSelection(root: HTMLElement, place: (range: Range, root: HTMLElement) => void) {
|
||||
const range = document.createRange();
|
||||
|
||||
place(range, root);
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection) throw new Error('no selection');
|
||||
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
function clipboardEvent(type: 'copy' | 'cut' | 'paste', text = '') {
|
||||
const data = new DataTransfer();
|
||||
|
||||
if (text) data.setData('text/plain', text);
|
||||
|
||||
const event = new ClipboardEvent(type, { bubbles: true, cancelable: true, clipboardData: data });
|
||||
|
||||
return { data, event };
|
||||
}
|
||||
|
||||
describe('ChatFormInputRich clipboard', () => {
|
||||
it('copy exposes the markdown source of the selection', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe(SOURCE);
|
||||
});
|
||||
|
||||
it('cut exposes the markdown source and removes the slice', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => {
|
||||
const badge = root.querySelector(BADGE_SELECTOR);
|
||||
|
||||
if (!badge) throw new Error('badge not rendered');
|
||||
|
||||
range.setStartBefore(badge);
|
||||
range.setEndAfter(badge);
|
||||
});
|
||||
|
||||
const { data, event } = clipboardEvent('cut');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe('[docs](file:///a/b)');
|
||||
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||
expect(root.textContent).toBe('hello world');
|
||||
});
|
||||
|
||||
it('paste of markdown mention links re-renders badges', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'hello ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
const { event } = clipboardEvent('paste', '[docs](file:///a/b) world');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
const badge = root.querySelector(BADGE_SELECTOR);
|
||||
|
||||
expect(badge).not.toBeNull();
|
||||
expect(badge!.getAttribute('data-mention-name')).toBe('docs');
|
||||
expect(root.textContent).toContain('world');
|
||||
});
|
||||
|
||||
it('paste without mention links keeps the DOM untouched', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'hello ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
const firstChild = root.firstChild;
|
||||
const { event } = clipboardEvent('paste', 'plain text');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(root.querySelector(BADGE_SELECTOR)).toBeNull();
|
||||
// no rebuild: the live text node is the same instance
|
||||
expect(root.firstChild).toBe(firstChild);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich code spans', () => {
|
||||
it('renders inline code from the initial value', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'run `npm test` now' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="code_inline"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
});
|
||||
|
||||
it('renders a fenced code block with a language', async () => {
|
||||
const source = 'before\n```js\nconst a = 1;\n```\nafter';
|
||||
const { container } = render(ChatFormInputRich, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('```js\nconst a = 1;\n```');
|
||||
});
|
||||
|
||||
it('copy exposes the markdown source of a selection spanning code', async () => {
|
||||
const source = 'run `npm test` now';
|
||||
const { container } = render(ChatFormInputRich, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
expect(data.getData('text/plain')).toBe(source);
|
||||
});
|
||||
|
||||
it('paste of a code span renders the styled element', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'run ' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
const { event } = clipboardEvent('paste', '`npm test` now');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
await tick();
|
||||
|
||||
expect(event.defaultPrevented).toBe(true);
|
||||
const code = root.querySelector('code[data-code-token="code_inline"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.textContent).toBe('`npm test`');
|
||||
expect(root.textContent).toContain('now');
|
||||
});
|
||||
|
||||
it('highlights a fenced block content and stays byte-exact', async () => {
|
||||
const source = '```js\nconst a = 1;\n```';
|
||||
const { container } = render(ChatFormInputRich, { value: source });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||
|
||||
expect(code).not.toBeNull();
|
||||
expect(code!.querySelector('.hljs-keyword')).not.toBeNull();
|
||||
expect(code!.textContent).toBe(source);
|
||||
});
|
||||
|
||||
it('does not highlight inline code', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: 'run `const` now' });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.querySelector('[class*="hljs-"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich code block escape hatches', () => {
|
||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||
const BLOCK_SELECTOR = 'code[data-code-token="code_block"]';
|
||||
|
||||
function blockIn(root: HTMLElement): HTMLElement {
|
||||
const el = root.querySelector(BLOCK_SELECTOR);
|
||||
|
||||
if (!(el instanceof HTMLElement)) throw new Error('code block not rendered');
|
||||
|
||||
return el;
|
||||
}
|
||||
|
||||
// Caret at the very start/end of the block's text (across highlight spans)
|
||||
function placeCaretInBlock(root: HTMLElement, where: 'start' | 'end') {
|
||||
const code = blockIn(root);
|
||||
const walker = document.createTreeWalker(code, NodeFilter.SHOW_TEXT);
|
||||
|
||||
let target: Node | null = null;
|
||||
|
||||
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
||||
target = where === 'start' ? (target ?? n) : n;
|
||||
}
|
||||
|
||||
if (!target) throw new Error('no text inside code block');
|
||||
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(target!, where === 'start' ? 0 : (target!.textContent ?? '').length);
|
||||
range.collapse(true);
|
||||
});
|
||||
}
|
||||
|
||||
function caretContainer(): Node {
|
||||
const selection = window.getSelection();
|
||||
|
||||
if (!selection || selection.rangeCount === 0) throw new Error('no selection');
|
||||
|
||||
return selection.getRangeAt(0).startContainer;
|
||||
}
|
||||
|
||||
it('pads a trailing code block with a br hatch that stays invisible to copy', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
// no permanent empty line above a leading block
|
||||
expect(root.firstChild).toBe(blockIn(root));
|
||||
expect(root.lastChild?.nodeName).toBe('BR');
|
||||
|
||||
setSelection(root, (range) => range.selectNodeContents(root));
|
||||
const { data, event } = clipboardEvent('copy');
|
||||
|
||||
root.dispatchEvent(event);
|
||||
|
||||
expect(data.getData('text/plain')).toBe(BLOCK_SOURCE);
|
||||
});
|
||||
|
||||
it('escapes a trailing code block with ArrowDown and types after it', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||
// the DOM holds no separator newline (it would render as a
|
||||
// phantom empty line); serialization synthesizes it so the
|
||||
// markdown source keeps the text below the block
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
// the stale trailing hatch is removed once real text follows the block
|
||||
expect(root.lastChild?.nodeName).not.toBe('BR');
|
||||
});
|
||||
|
||||
it('escapes a leading code block with ArrowUp and types before it', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
// the transient hatch line exists while the caret sits on it
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
|
||||
await userEvent.keyboard('y');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).textContent).toBe(BLOCK_SOURCE);
|
||||
expect(root.textContent).toBe('y' + BLOCK_SOURCE);
|
||||
expect(serializeContent(root)).toBe('y\n' + BLOCK_SOURCE);
|
||||
// the typed text consumed the hatch
|
||||
expect(root.firstChild?.nodeName).not.toBe('BR');
|
||||
});
|
||||
|
||||
it('escapes a leading code block with ArrowLeft from its first character', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowLeft}');
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(false);
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
});
|
||||
|
||||
it('removes the transient leading hatch when the caret moves back into the block', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'start');
|
||||
|
||||
await userEvent.keyboard('{ArrowUp}');
|
||||
expect(root.firstChild?.nodeName).toBe('BR');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await tick();
|
||||
|
||||
expect(blockIn(root).contains(caretContainer())).toBe(true);
|
||||
expect(root.firstChild).toBe(blockIn(root));
|
||||
});
|
||||
|
||||
it('extends the selection out of the block with Shift+ArrowDown', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{Shift>}{ArrowDown}{/Shift}');
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(selection).not.toBeNull();
|
||||
expect(selection!.isCollapsed).toBe(false);
|
||||
expect(blockIn(root).contains(selection!.getRangeAt(0).endContainer)).toBe(false);
|
||||
});
|
||||
|
||||
it('line-separates text typed right after the closing fence', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
// no arrow keys: the caret sits at the block's end edge, where the
|
||||
// post-rebuild restore lands it, and the typed text renders on the
|
||||
// line below the block
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
// the text stays on the caret's line in the DOM (no phantom empty
|
||||
// line); the source gets the separator newline
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
});
|
||||
|
||||
it('does not double the newline when Shift+Enter already added one', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'x');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nx');
|
||||
});
|
||||
|
||||
it('moves a caret stuck before the inserted newline onto the new line', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// post-break DOM some browsers produce: the inserted newline plus
|
||||
// the artificial trailing one, with the caret stuck BEFORE the
|
||||
// inserted one (visually at the end of the old line)
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root.childNodes[2], 0);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
});
|
||||
|
||||
it('appends the artificial trailing newline when the browser did not add one', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// post-break DOM some browsers produce: a lone trailing \n (or a
|
||||
// <br> the hatch sync strips). Collapsed by the renderer, so the
|
||||
// caret looks stuck on the old line and the next typed character
|
||||
// would consume the newline.
|
||||
root.appendChild(document.createTextNode('\n'));
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root.childNodes[2], 1);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
root.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'insertLineBreak' }));
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
});
|
||||
|
||||
it('lands the caret on the new line with a single Shift+Enter after text below a block', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\ntext after the code block'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
const text = root.childNodes[1];
|
||||
|
||||
range.setStart(text, (text.textContent ?? '').length);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Shift>}{Enter}{/Shift}');
|
||||
await tick();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(
|
||||
(BLOCK_SOURCE + '\ntext after the code block\n').length
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\n\n');
|
||||
|
||||
// the next typed character lands on the new line
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ntext after the code block\nx');
|
||||
});
|
||||
|
||||
it('lets Backspace at the text start move into the block without a source fight', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await userEvent.keyboard('create');
|
||||
await tick();
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||
|
||||
// Backspace at the start of the text line: the separator newline
|
||||
// is structural (synthesized while text follows the block), so
|
||||
// the caret just moves to the block's edge - nothing is re-added
|
||||
await userEvent.keyboard('{Home}');
|
||||
await userEvent.keyboard('{Backspace}');
|
||||
await tick();
|
||||
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\ncreate');
|
||||
expect(caretContainer()).toBe(root);
|
||||
});
|
||||
|
||||
it('lets forward Delete eat the text after a block normally', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
placeCaretInBlock(root, 'end');
|
||||
|
||||
await userEvent.keyboard('{ArrowDown}');
|
||||
await userEvent.keyboard('create');
|
||||
await tick();
|
||||
|
||||
await userEvent.keyboard('{Home}');
|
||||
await userEvent.keyboard('{Delete}');
|
||||
await tick();
|
||||
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nreate');
|
||||
});
|
||||
|
||||
it('renders text after a block without a phantom empty line', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\nhello'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + 'hello');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\nhello');
|
||||
});
|
||||
|
||||
it('keeps an intentional blank line after a block out of the separator', async () => {
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
value: BLOCK_SOURCE + '\n\nhello'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
expect(root.textContent).toBe(BLOCK_SOURCE + '\nhello');
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE + '\n\nhello');
|
||||
});
|
||||
|
||||
it('re-highlights while typing inside a block and keeps the caret', async () => {
|
||||
const { container } = render(ChatFormInputRich, { value: BLOCK_SOURCE });
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('x');
|
||||
await tick();
|
||||
|
||||
const code = blockIn(root);
|
||||
|
||||
expect(serializeContent(root)).toBe('```js\nxconst a = 1;\n```');
|
||||
expect(code.textContent).toBe('```js\nxconst a = 1;\n```');
|
||||
expect(code.querySelector('.hljs-number')).not.toBeNull();
|
||||
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(code.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChatFormInputRich Enter in code blocks', () => {
|
||||
const BLOCK_SOURCE = '```js\nconst a = 1;\n```';
|
||||
|
||||
it('adds a line instead of submitting on plain Enter inside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
// consumed locally: the parent's submit handler never sees it
|
||||
expect(onKeydown).not.toHaveBeenCalled();
|
||||
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;\n```');
|
||||
|
||||
const code = root.querySelector('code[data-code-token="code_block"]');
|
||||
const selection = window.getSelection();
|
||||
|
||||
expect(code!.contains(selection!.getRangeAt(0).startContainer)).toBe(true);
|
||||
expect(rangeToTextOffset(root, selection!.getRangeAt(0))).toBe(7);
|
||||
});
|
||||
|
||||
it('adds a line after a still-open fence (no closing ``` yet)', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: '```js\nconst a = 1;'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
|
||||
// caret at the start of the block content (after the opening fence)
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await tick();
|
||||
|
||||
expect(onKeydown).not.toHaveBeenCalled();
|
||||
expect(serializeContent(root)).toBe('```js\n\nconst a = 1;');
|
||||
});
|
||||
|
||||
it('forwards plain Enter to the parent when the caret is outside a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE + '\nafter'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
range.selectNodeContents(root);
|
||||
range.collapse(false);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
expect(onKeydown.mock.calls[0][0].defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards plain Enter on the trailing hatch line after a block', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
// root-level caret between the block and its trailing br hatch
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(root, 1);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('forwards Ctrl+Enter inside a block so explicit submit survives', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: BLOCK_SOURCE
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
setSelection(root, (range) => {
|
||||
const target = textOffsetToRange(root, 6);
|
||||
|
||||
range.setStart(target.startContainer, target.startOffset);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Control>}{Enter}{/Control}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ ctrlKey: true, key: 'Enter' })
|
||||
);
|
||||
expect(serializeContent(root)).toBe(BLOCK_SOURCE);
|
||||
});
|
||||
|
||||
it('forwards Enter inside an inline code span', async () => {
|
||||
const onKeydown = vi.fn();
|
||||
const { container } = render(ChatFormInputRich, {
|
||||
onKeydown,
|
||||
value: 'run `npm test` now'
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const root = editableIn(container);
|
||||
|
||||
root.focus();
|
||||
const code = root.querySelector('code[data-code-token="code_inline"]')!;
|
||||
|
||||
setSelection(root, (range) => {
|
||||
range.setStart(code.firstChild!, 3);
|
||||
range.collapse(true);
|
||||
});
|
||||
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(onKeydown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
// Guards the @-mention picker's file_glob_search gate: when the server
|
||||
// does not expose the tool (started without --tools) or the user disabled
|
||||
// it, the picker still opens but explains why instead of firing searches
|
||||
// that would only fail with "Search failed".
|
||||
|
||||
import ChatFormPickerMention from '$lib/components/app/chat/ChatForm/ChatFormPickers/ChatFormPickerMention.svelte';
|
||||
import { DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { BuiltInTool } from '$lib/enums';
|
||||
import { toolsStore } from '$lib/stores/tools.svelte';
|
||||
import type { OpenAIToolDefinition } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const FILE_SEARCH_DEF: OpenAIToolDefinition = {
|
||||
function: { description: '', name: BuiltInTool.SERVER_FILE_GLOB_SEARCH, parameters: {} },
|
||||
type: 'function'
|
||||
};
|
||||
const FILE_SEARCH_KEY = `server:${BuiltInTool.SERVER_FILE_GLOB_SEARCH}`;
|
||||
|
||||
// The store keeps its server tool list private; tests inject it through
|
||||
// the reactive field so the derived gates recompute.
|
||||
function setServerTools(defs: OpenAIToolDefinition[]) {
|
||||
(toolsStore as unknown as { _serverTools: OpenAIToolDefinition[] })._serverTools = defs;
|
||||
}
|
||||
|
||||
function renderPicker() {
|
||||
return render(ChatFormPickerMention, {
|
||||
isOpen: true,
|
||||
onClose: () => {},
|
||||
onSelect: () => {},
|
||||
query: 'main'
|
||||
});
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setServerTools([]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, true);
|
||||
localStorage.removeItem(DISABLED_TOOL_KEYS_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('ChatFormPickerMention file_glob_search gate', () => {
|
||||
it('explains that file search is unavailable when the server has no tools', async () => {
|
||||
setServerTools([]);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is unavailable on this server (started without --tools)'
|
||||
);
|
||||
});
|
||||
|
||||
it('explains that file search must be enabled when the user disabled it', async () => {
|
||||
setServerTools([FILE_SEARCH_DEF]);
|
||||
toolsStore.setToolEnabled(FILE_SEARCH_KEY, false);
|
||||
renderPicker();
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(
|
||||
'File search is disabled - enable "Search files" in Settings > Tools to use @-mentions'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// Guards the slash-command dispatch contract: commands dispatch only on
|
||||
// explicit selection (Enter/click in the picker), never mid-typing.
|
||||
// Typing `/model is broken` is prose until the command is picked - the
|
||||
// buffer must survive; only an actual selection consumes the token.
|
||||
|
||||
import ChatFormPickersHarness from './components/ChatFormPickersHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('slash command dispatch', () => {
|
||||
it('does not dispatch or clear the buffer when a space follows the name', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
|
||||
expect(screen.component.getValue()).toBe('/model is broken');
|
||||
expect(screen.component.getCalls()).not.toContain('openModelSelector');
|
||||
expect(screen.component.getCalls().some((c) => c.startsWith('setValue:'))).toBe(false);
|
||||
expect(pickers.isCommandPickerOpen).toBe(true);
|
||||
expect(pickers.commandQuery).toBe('model');
|
||||
});
|
||||
|
||||
it('dispatches /model on explicit selection and consumes the token', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/model is broken');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const model = pickers.availableCommands.find((c) => c.name === 'model');
|
||||
|
||||
if (!model) throw new Error('model command missing');
|
||||
|
||||
pickers.handleCommandSelect(model);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(screen.component.getCalls()).toContain('openModelSelector');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('seeds the prompt picker search from the token args on selection', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/prompt weather');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
|
||||
expect(pickers.isPromptPickerOpen).toBe(false);
|
||||
|
||||
const prompt = pickers.availableCommands.find((c) => c.name === 'prompt');
|
||||
|
||||
if (!prompt) throw new Error('prompt command missing');
|
||||
|
||||
pickers.handleCommandSelect(prompt);
|
||||
await tick();
|
||||
|
||||
expect(screen.component.getValue()).toBe('');
|
||||
expect(pickers.isPromptPickerOpen).toBe(true);
|
||||
expect(pickers.promptSearchQuery).toBe('weather');
|
||||
});
|
||||
|
||||
it('normalizes a partial /cwd token on selection and keeps it in the buffer', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cw docs');
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs');
|
||||
expect(screen.component.getValue()).toBe('/cwd docs');
|
||||
});
|
||||
|
||||
it('syncs the /cwd token into the picker search while the picker is open', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwd docs/sub');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(true);
|
||||
expect(pickers.workingDirectoryQuery).toBe('docs/sub');
|
||||
expect(pickers.isCommandPickerOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('abandons the /cwd picker when the token is edited away from /cwd', async () => {
|
||||
const screen = render(ChatFormPickersHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
const pickers = screen.component.getPickers();
|
||||
const cwd = pickers.availableCommands.find((c) => c.name === 'cwd');
|
||||
|
||||
if (!cwd) throw new Error('cwd command missing');
|
||||
|
||||
screen.component.type('/cwd docs');
|
||||
pickers.handleCommandSelect(cwd);
|
||||
await tick();
|
||||
|
||||
screen.component.type('/cwdd docs');
|
||||
await tick();
|
||||
|
||||
expect(pickers.isWorkingDirectoryPickerOpen).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$app/navigation', () => ({ goto: vi.fn() }));
|
||||
|
||||
import { goto } from '$app/navigation';
|
||||
import { CONVERSATION_TABS_LOCALSTORAGE_KEY, NEW_CHAT_TAB_ID } from '$lib/constants';
|
||||
import { TabsStore } from '$lib/stores/tabs.svelte';
|
||||
|
||||
const gotoMock = vi.mocked(goto);
|
||||
|
||||
function freshStore(persisted?: unknown, validIds: string[] = []) {
|
||||
localStorage.removeItem(CONVERSATION_TABS_LOCALSTORAGE_KEY);
|
||||
|
||||
if (persisted !== undefined) {
|
||||
localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(persisted));
|
||||
}
|
||||
|
||||
const tabs = new TabsStore();
|
||||
|
||||
tabs.init(validIds);
|
||||
|
||||
return tabs;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gotoMock.mockClear();
|
||||
});
|
||||
|
||||
/**
|
||||
* A plain navigation shows the new target in the current tab; only an
|
||||
* explicit new-tab action grows the strip.
|
||||
*/
|
||||
describe('tabsStore route sync', () => {
|
||||
it('creates the first tab, then navigates it in place', () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
tabs.syncWithRoute('b');
|
||||
tabs.syncWithRoute('c');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['c']);
|
||||
});
|
||||
|
||||
it('activates an open tab instead of replacing the current one', () => {
|
||||
const tabs = freshStore(['a', 'b'], ['a', 'b']);
|
||||
|
||||
tabs.syncWithRoute('b');
|
||||
tabs.syncWithRoute('a');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('turns the new-chat tab into the conversation it creates', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.openInNewTab(NEW_CHAT_TAB_ID, { activate: true });
|
||||
tabs.syncWithRoute(NEW_CHAT_TAB_ID);
|
||||
tabs.syncWithRoute('b');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('waits for init before applying the route', () => {
|
||||
localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(['a', 'b']));
|
||||
|
||||
const tabs = new TabsStore();
|
||||
|
||||
tabs.syncWithRoute('b');
|
||||
tabs.init(['a', 'b']);
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabsStore explicit tabs', () => {
|
||||
it('opens new tabs at the end, in the background, without duplicates', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.openInNewTab('b');
|
||||
await tabs.openInNewTab('c');
|
||||
await tabs.openInNewTab('b');
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', 'b', 'c']);
|
||||
expect(gotoMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('closes the active tab to its left neighbour', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.openInNewTab('b');
|
||||
tabs.syncWithRoute('b');
|
||||
await tabs.close('b', true);
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a']);
|
||||
expect(gotoMock).toHaveBeenLastCalledWith('#/chat/a');
|
||||
});
|
||||
|
||||
it('falls back to the new-chat screen when the last tab closes', async () => {
|
||||
const tabs = freshStore();
|
||||
|
||||
tabs.syncWithRoute('a');
|
||||
await tabs.close('a', true);
|
||||
|
||||
expect(gotoMock).toHaveBeenLastCalledWith('#/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('tabsStore persistence', () => {
|
||||
it('prunes deleted conversations and keeps the new-chat tab', () => {
|
||||
const tabs = freshStore(['a', NEW_CHAT_TAB_ID, 'gone'], ['a']);
|
||||
|
||||
expect(tabs.openTabs).toEqual(['a', NEW_CHAT_TAB_ID]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// Guards the lazy-body behaviour of the shared collapsible wrappers.
|
||||
//
|
||||
// bits-ui's Collapsible.Content renders its children unconditionally and only
|
||||
// sets `hidden`, so before this was gated a collapsed tool result kept its whole
|
||||
// body in the DOM and re-rendered it on every streamed token (measured at
|
||||
// ~41ms/section/token for a 200KB result). These tests pin the fix: closed means
|
||||
// not rendered, and opening still mounts the body.
|
||||
|
||||
import CollapsibleLazyBodyHarness from './components/CollapsibleLazyBodyHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const MARKER = 'collapsible-body-marker';
|
||||
|
||||
describe('collapsible wrappers render their body lazily', () => {
|
||||
for (const variant of ['content', 'terminal'] as const) {
|
||||
it(`${variant}: body is absent while closed and present once open`, async () => {
|
||||
const screen = render(CollapsibleLazyBodyHarness, { open: false, variant });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(MARKER);
|
||||
|
||||
await screen.rerender({ open: true, variant });
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(MARKER);
|
||||
|
||||
// And it unmounts again on close, so a collapsed block stops costing
|
||||
// anything during streaming.
|
||||
await screen.rerender({ open: false, variant });
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).not.toContain(MARKER);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { perfState } from './agentic-perf-state.svelte';
|
||||
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
{#if perfState.message}
|
||||
<ChatMessageAgenticContent
|
||||
isLastAssistantMessage
|
||||
isStreaming={perfState.isStreaming}
|
||||
message={perfState.message}
|
||||
toolMessages={perfState.toolMessages}
|
||||
/>
|
||||
{/if}
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script lang="ts">
|
||||
import ChatFormInputRich from '$lib/components/app/chat/ChatForm/ChatFormInput/ChatFormInputRich.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
let { value: initial = '' }: Props = $props();
|
||||
|
||||
let value = $state(untrack(() => initial));
|
||||
let inputRef: ChatFormInputRich | undefined = $state(undefined);
|
||||
|
||||
export function getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCaretOffset() {
|
||||
return inputRef?.getCaretOffset();
|
||||
}
|
||||
|
||||
export function setCaretOffset(offset: number) {
|
||||
inputRef?.setCaretOffset(offset);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ChatFormInputRich bind:this={inputRef} bind:value />
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
useChatFormPickers,
|
||||
type UseChatFormPickersReturn
|
||||
} from '$lib/hooks/use-chat-form-pickers.svelte';
|
||||
|
||||
let value = $state('');
|
||||
let caretOffset = $state(0);
|
||||
const calls: string[] = [];
|
||||
|
||||
const pickers = useChatFormPickers({
|
||||
focusInput: () => {},
|
||||
getCaretOffset: () => caretOffset,
|
||||
getCwd: () => null,
|
||||
getPickersRef: () => undefined,
|
||||
getServerHome: () => null,
|
||||
getShowModelSelector: () => true,
|
||||
getValue: () => value,
|
||||
hasCwdTools: () => true,
|
||||
hasPrompts: () => true,
|
||||
openModelSelector: () => {
|
||||
calls.push('openModelSelector');
|
||||
},
|
||||
setCaretOffset: (o) => {
|
||||
caretOffset = o;
|
||||
},
|
||||
setValue: (v) => {
|
||||
value = v;
|
||||
calls.push(`setValue:${v}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Simulate the user typing: update the buffer and run the input flow.
|
||||
export function type(text: string) {
|
||||
value = text;
|
||||
caretOffset = text.length;
|
||||
pickers.handleInput();
|
||||
}
|
||||
|
||||
export function getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getCalls() {
|
||||
return calls;
|
||||
}
|
||||
|
||||
export function getPickers(): UseChatFormPickersReturn {
|
||||
return pickers;
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import ChatForm from '$lib/components/app/chat/ChatForm/ChatForm.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
|
||||
let { onSubmit }: { onSubmit?: () => void } = $props();
|
||||
|
||||
let value = $state('');
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<ChatForm bind:value {onSubmit} />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
// Mounts the real ChatMessages list against the real conversations store, so
|
||||
// the harness exercises `displayMessages` (which rebuilds every message's
|
||||
// toolMessages array) rather than a single message subtree.
|
||||
import ChatMessages from '$lib/components/app/chat/ChatMessages/ChatMessages.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
</script>
|
||||
|
||||
<Tooltip.Provider>
|
||||
<ChatMessages messages={conversationsStore.activeMessages} />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
import CollapsibleContentBlock from '$lib/components/app/content/CollapsibleContentBlock.svelte';
|
||||
import CollapsibleTerminalBlock from '$lib/components/app/content/CollapsibleTerminalBlock.svelte';
|
||||
|
||||
interface Props {
|
||||
variant: 'content' | 'terminal';
|
||||
open: boolean;
|
||||
}
|
||||
|
||||
let { open, variant }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if variant === 'content'}
|
||||
<CollapsibleContentBlock {open} title="Block">
|
||||
<span>collapsible-body-marker</span>
|
||||
</CollapsibleContentBlock>
|
||||
{:else}
|
||||
<CollapsibleTerminalBlock {open} title="Block">
|
||||
<span>collapsible-body-marker</span>
|
||||
</CollapsibleTerminalBlock>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import DialogMcpServers from '$lib/components/app/dialogs/DialogMcpServers.svelte';
|
||||
import DialogSettingsChat from '$lib/components/app/dialogs/DialogSettingsChat.svelte';
|
||||
|
||||
let { panel }: { panel: 'mcp' | 'settings' } = $props();
|
||||
</script>
|
||||
|
||||
{#if panel === 'settings'}
|
||||
<DialogSettingsChat open />
|
||||
{:else}
|
||||
<DialogMcpServers open />
|
||||
{/if}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
headers?: string;
|
||||
}
|
||||
|
||||
let { headers = '' }: Props = $props();
|
||||
|
||||
let headersState = $state(untrack(() => headers));
|
||||
let lastCapturedHeaders = $state(untrack(() => headers));
|
||||
|
||||
$effect(() => {
|
||||
if (headers !== lastCapturedHeaders) {
|
||||
headersState = headers;
|
||||
lastCapturedHeaders = headers;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Drives McpServerForm with a controlled `headers` string and exposes the
|
||||
latest captured value through `data-captured-headers` so the client test
|
||||
can read it back without a custom binding API.
|
||||
-->
|
||||
<McpServerForm
|
||||
headers={headersState}
|
||||
id="mcp-server-form-test"
|
||||
onHeadersChange={(value) => {
|
||||
headersState = value;
|
||||
}}
|
||||
onUrlChange={() => {}}
|
||||
url="https://example.test/mcp"
|
||||
/>
|
||||
|
||||
<div data-captured-headers={headersState} data-testid="captured-headers" hidden></div>
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import ModelsManagerModelRow from '$lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
interface Props {
|
||||
option: ModelOption;
|
||||
selected?: boolean;
|
||||
onSelect: (option: ModelOption) => void;
|
||||
}
|
||||
|
||||
let { onSelect, option, selected = false }: Props = $props();
|
||||
</script>
|
||||
|
||||
<!-- The row actions mount a tooltip, which needs the provider +layout.svelte supplies. -->
|
||||
<Tooltip.Provider>
|
||||
<ModelsManagerModelRow
|
||||
isFavorite={() => false}
|
||||
onDelete={() => {}}
|
||||
{onSelect}
|
||||
{option}
|
||||
{selected}
|
||||
/>
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,9 @@
|
||||
<script lang="ts">
|
||||
import ModelsManager from '$lib/components/app/models/ModelsManager/ModelsManager.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
</script>
|
||||
|
||||
<!-- The rows mount tooltips, which need the provider +layout.svelte supplies. -->
|
||||
<Tooltip.Provider>
|
||||
<ModelsManager />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import { useModelsSelector } from '$lib/hooks/use-models-selector.svelte';
|
||||
|
||||
interface Props {
|
||||
currentModel?: () => string | null;
|
||||
}
|
||||
|
||||
let { currentModel = () => null }: Props = $props();
|
||||
|
||||
// the caller seeds modelsStore.models before mounting, so the hook's own
|
||||
// fetch() short-circuits on an already populated list; the hook keeps the getter
|
||||
// and calls it later, so reading the prop here is intended
|
||||
// svelte-ignore state_referenced_locally
|
||||
const selector = useModelsSelector({ currentModel, onOpenChange: () => {} });
|
||||
|
||||
/** Every model id the selector lists, in the order the sections render them. */
|
||||
export function listedIds(): string[] {
|
||||
return [
|
||||
...selector.favoriteItems.map((item) => item.option.id),
|
||||
...selector.loadedItems.map((item) => item.option.id),
|
||||
...selector.groupedFilteredOptions.available.flatMap((group) =>
|
||||
group.items.map((item) => item.option.id)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
export function shownModelId(): string | undefined {
|
||||
return selector.getDisplayOption()?.id;
|
||||
}
|
||||
|
||||
export function isEmpty(): boolean {
|
||||
return selector.isEmpty;
|
||||
}
|
||||
</script>
|
||||
|
||||
<span data-testid="selector-hook"></span>
|
||||
@@ -0,0 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { ChatFormPickerList, ChatFormPickerListItem } from '$lib/components/app/chat';
|
||||
|
||||
interface Item {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const items: Item[] = Array.from({ length: 20 }, (_, i) => ({
|
||||
id: String(i),
|
||||
label: `item ${i}`
|
||||
}));
|
||||
|
||||
let open = $state(false);
|
||||
let scrollTrigger = $state(0);
|
||||
let selectedIndex = $state(0);
|
||||
|
||||
export function openPicker() {
|
||||
open = true;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div style="height: 5000px;">conversation</div>
|
||||
|
||||
{#if open}
|
||||
<div data-testid="picker-host">
|
||||
<ChatFormPickerList
|
||||
isLoading={false}
|
||||
itemKey={(it) => it.id}
|
||||
{items}
|
||||
{scrollTrigger}
|
||||
searchQuery=""
|
||||
{selectedIndex}
|
||||
showSearchInput={false}
|
||||
>
|
||||
{#snippet item(it, index, isSelected)}
|
||||
<ChatFormPickerListItem dataIndex={index} {isSelected} onclick={() => {}}>
|
||||
{it.label}
|
||||
</ChatFormPickerListItem>
|
||||
{/snippet}
|
||||
</ChatFormPickerList>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import Page from '../../../src/routes/(chat)/+page.svelte';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Test wrapper that provides necessary context providers for component testing.
|
||||
This mirrors the providers from +layout.svelte.
|
||||
-->
|
||||
<Tooltip.Provider>
|
||||
<Page />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,17 @@
|
||||
// Shared reactive fixture state for the Tier 1 perf harness.
|
||||
//
|
||||
// The wrapper component reads this module directly rather than taking props,
|
||||
// so the driver can mutate it without depending on how the test renderer
|
||||
// forwards props.
|
||||
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
|
||||
export const perfState = $state<{
|
||||
message: DatabaseMessage | null;
|
||||
toolMessages: DatabaseMessage[];
|
||||
isStreaming: boolean;
|
||||
}>({
|
||||
isStreaming: true,
|
||||
message: null,
|
||||
toolMessages: []
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
// Guards the phone shape of the context panel: a phone shows it in a drawer, while a
|
||||
// wider screen keeps the card anchored above the dial.
|
||||
|
||||
import ContextGaugePopup from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/ContextGaugePopup.svelte';
|
||||
import { gaugePopup } from '$lib/components/app/chat/ChatForm/ChatFormContextGauge/gauge-popup.svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
|
||||
describe('context panel on a phone', () => {
|
||||
beforeEach(async () => {
|
||||
gaugePopup.open = false;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
gaugePopup.open = false;
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('shows the panel in a drawer', async () => {
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
|
||||
const screen = render(ContextGaugePopup);
|
||||
|
||||
gaugePopup.open = true;
|
||||
|
||||
await expect.element(screen.getByRole('heading', { name: 'Context' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('keeps the anchored card on a wider screen', async () => {
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
|
||||
const screen = render(ContextGaugePopup);
|
||||
|
||||
gaugePopup.open = true;
|
||||
|
||||
await expect.element(screen.getByRole('status')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import type { ExportedConversation } from '$lib/types/database';
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSession(id: string): ExportedConversation {
|
||||
return {
|
||||
conv: { currNode: `${id}-msg`, id, lastModified: 0, name: `Chat ${id}` },
|
||||
messages: [
|
||||
{
|
||||
children: [],
|
||||
content: `hello from ${id}`,
|
||||
convId: id,
|
||||
id: `${id}-msg`,
|
||||
parent: null,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
}
|
||||
]
|
||||
} as unknown as ExportedConversation;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const conversations = await DatabaseService.getAllConversations();
|
||||
|
||||
await DatabaseService.bulkDeleteConversations(conversations.map((conv) => conv.id));
|
||||
});
|
||||
|
||||
/**
|
||||
* An import leaves a conversation already in the database untouched, so the
|
||||
* caller needs to know what was written to report it instead of echoing the
|
||||
* selection back at the user.
|
||||
*/
|
||||
describe('DatabaseService.importConversations', () => {
|
||||
it('reports the conversations it wrote', async () => {
|
||||
const { imported, skipped } = await DatabaseService.importConversations([
|
||||
makeSession('a'),
|
||||
makeSession('b')
|
||||
]);
|
||||
|
||||
expect(imported.map((conv) => conv.id)).toEqual(['a', 'b']);
|
||||
expect(skipped).toEqual([]);
|
||||
expect(await DatabaseService.getConversationMessages('a')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports an existing conversation as skipped and leaves it untouched', async () => {
|
||||
await DatabaseService.importConversations([makeSession('a')]);
|
||||
await DatabaseService.updateConversation('a', { name: 'Renamed locally' });
|
||||
|
||||
const { imported, skipped } = await DatabaseService.importConversations([makeSession('a')]);
|
||||
|
||||
expect(imported).toEqual([]);
|
||||
expect(skipped.map((conv) => conv.id)).toEqual(['a']);
|
||||
expect((await DatabaseService.getConversation('a'))?.name).toBe('Renamed locally');
|
||||
});
|
||||
|
||||
it('imports the new conversations of a partially known selection', async () => {
|
||||
await DatabaseService.importConversations([makeSession('a')]);
|
||||
|
||||
const { imported, skipped } = await DatabaseService.importConversations([
|
||||
makeSession('a'),
|
||||
makeSession('b')
|
||||
]);
|
||||
|
||||
expect(imported.map((conv) => conv.id)).toEqual(['b']);
|
||||
expect(skipped.map((conv) => conv.id)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
// Guards that opening a panel on a phone leaves its corner close alone: the tap that
|
||||
// opened the panel is already inside it, so a focus ring on that close reads as a stray
|
||||
// highlight.
|
||||
|
||||
import DialogPanelWrapper from './components/DialogPanelWrapper.svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
|
||||
describe.each(['settings', 'mcp'] as const)('%s panel on a phone', (panel) => {
|
||||
beforeEach(async () => {
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('leaves the corner close unfocused', async () => {
|
||||
const screen = render(DialogPanelWrapper, { panel });
|
||||
|
||||
await expect.element(screen.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// the panel focuses its first button on open unless it is told not to
|
||||
await expect.poll(() => document.activeElement?.tagName).not.toBe('BUTTON');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
// Guards the exec_shell_command title while the call is still being
|
||||
// generated: the command must stream into the terminal title from the
|
||||
// first character (args arrive as partial JSON), with a blinking cursor
|
||||
// while the args are incomplete, and the conversation cwd standing in
|
||||
// for the x-tool-cwd value that only arrives with the result message.
|
||||
|
||||
import ChatMessageToolCallBlockExecShellCommand from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockExecShellCommand.svelte';
|
||||
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { AgenticSectionType, BuiltInTool, MessageRole, MessageType } from '$lib/enums';
|
||||
import { conversationsStore, settingsStore } from '$lib/stores';
|
||||
import type { AgenticSection, DatabaseMessage } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const CONV_ID = 'conv-1';
|
||||
const TOOL_CALL_ID = 'call-1';
|
||||
const TOOL_NAME = BuiltInTool.SERVER_EXEC_SHELL_COMMAND;
|
||||
const CWD = '/home/user/project';
|
||||
|
||||
function messageWithArgs(args: string): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: CONV_ID,
|
||||
id: 'assistant-1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
|
||||
]),
|
||||
type: MessageType.TEXT
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultMessage(content: string, toolCwd?: string): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content,
|
||||
convId: CONV_ID,
|
||||
id: 'tool-1',
|
||||
parent: null,
|
||||
role: MessageRole.TOOL,
|
||||
timestamp: 0,
|
||||
toolCallId: TOOL_CALL_ID,
|
||||
toolCalls: '',
|
||||
toolCwd,
|
||||
type: MessageType.TEXT
|
||||
};
|
||||
}
|
||||
|
||||
function triggerText(container: Element): string {
|
||||
return container.querySelector('[data-slot="collapsible-trigger"]')?.textContent ?? '';
|
||||
}
|
||||
|
||||
function hasCursor(container: Element): boolean {
|
||||
return container.querySelector('.exec-cursor') !== null;
|
||||
}
|
||||
|
||||
describe('exec_shell_command streaming title', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateMultipleConfig({
|
||||
[SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: false,
|
||||
[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: true
|
||||
});
|
||||
conversationsStore.activeConversation = {
|
||||
currNode: null,
|
||||
cwd: CWD,
|
||||
id: CONV_ID,
|
||||
lastModified: 0,
|
||||
name: 'test'
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
conversationsStore.activeConversation = null;
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
|
||||
it('streams the partial command into the title with a cursor', async () => {
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git sta'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const title = triggerText(container);
|
||||
|
||||
expect(title).toContain('git sta');
|
||||
expect(title).toContain('$');
|
||||
expect(title).toContain(CWD);
|
||||
expect(hasCursor(container)).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the cursor once the args JSON is complete', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git sta'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(hasCursor(screen.container)).toBe(true);
|
||||
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: []
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain('git status');
|
||||
expect(hasCursor(screen.container)).toBe(false);
|
||||
});
|
||||
|
||||
it('switches the prompt from the conversation cwd to the persisted one', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain(CWD);
|
||||
|
||||
// Result message lands: its x-tool-cwd takes over the prompt.
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: [toolResultMessage('', '/other/dir')]
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain('/other/dir');
|
||||
expect(triggerText(screen.container)).not.toContain(CWD);
|
||||
});
|
||||
|
||||
it('shows the executing label only after the command finishes streaming', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git sta'),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// While the args stream, the title cursor alone signals activity.
|
||||
expect(screen.container.textContent).not.toContain('Executing command...');
|
||||
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: messageWithArgs('{"command":"git status"}'),
|
||||
toolMessages: []
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(screen.container.textContent).toContain('Executing command...');
|
||||
});
|
||||
|
||||
it('shows Executing command... while executing before the first output chunk', async () => {
|
||||
// The result message is created empty right before execution, so
|
||||
// the block must not go blank while the process produces nothing.
|
||||
const section: AgenticSection = {
|
||||
content: '',
|
||||
toolArgs: '{"command":"sleep 1"}',
|
||||
toolCallId: TOOL_CALL_ID,
|
||||
toolName: TOOL_NAME,
|
||||
toolResult: '',
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
};
|
||||
const { container } = render(ChatMessageToolCallBlockExecShellCommand, {
|
||||
isExecuting: true,
|
||||
isStreaming: true,
|
||||
open: true,
|
||||
section
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(container.textContent).toContain('Executing command...');
|
||||
});
|
||||
|
||||
it('updates the title when the store mutates toolCalls in place, as the stream does', async () => {
|
||||
// The live flow never swaps the message object: onToolCallsStreaming
|
||||
// writes growing toolCalls strings into the same store row per delta.
|
||||
conversationsStore.activeMessages = [messageWithArgs('')];
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: conversationsStore.activeMessages[0]!,
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
const idx = conversationsStore.findMessageIndex('assistant-1');
|
||||
|
||||
for (const args of ['{"command', '{"command":"git', '{"command":"git sta']) {
|
||||
conversationsStore.updateMessageAtIndex(idx, {
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: args, name: TOOL_NAME }, id: TOOL_CALL_ID }
|
||||
])
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(triggerText(screen.container)).toContain(args.slice('{"command":"'.length));
|
||||
expect(hasCursor(screen.container)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,160 @@
|
||||
// Adversarial checks for MarkdownContent's allowHtml mode: the discover README
|
||||
// renders HuggingFace model cards, which are third-party content, so every
|
||||
// payload below must come out neutralized - both right after sanitize and
|
||||
// after an innerHTML re-parse, which is where mutation XSS would surface.
|
||||
import MarkdownContent from '$lib/components/app/content/MarkdownContent/MarkdownContent.svelte';
|
||||
import { SAFE_HTML_CONFIG } from '$lib/components/app/content/MarkdownContent/safe-html-config';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { mount, unmount } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const HANDLER_ATTR = /\son[a-z]+\s*=/i;
|
||||
const JS_URL = /javascript\s*:/i;
|
||||
|
||||
/** Payload execution canary: set by any payload that runs. */
|
||||
function xssFired(): boolean {
|
||||
return (window as { __mdXss?: number }).__mdXss !== undefined;
|
||||
}
|
||||
|
||||
function roundTrip(html: string): string {
|
||||
const el = document.createElement('div');
|
||||
|
||||
el.innerHTML = html;
|
||||
document.body.appendChild(el);
|
||||
const again = el.innerHTML;
|
||||
|
||||
el.remove();
|
||||
|
||||
return again;
|
||||
}
|
||||
|
||||
const PAYLOADS: Record<string, string> = {
|
||||
'details ontoggle': '<details open ontoggle="window.__mdXss=1">x</details>',
|
||||
'dom clobbering': '<p id="content" name="location"><input name="domain"></p>',
|
||||
'form and input': '<form><input autofocus onfocus="window.__mdXss=1"></form>',
|
||||
'iframe javascript src': '<iframe src="javascript:alert(1)"></iframe>',
|
||||
'iframe srcdoc': '<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
|
||||
'img onerror': '<img src=x onerror="window.__mdXss=1">',
|
||||
'link case and entity href': '<a href="JaVaScRiPt:alert(1)">x</a>',
|
||||
'link data:text/html href':
|
||||
'<a href="data:text/html;base64,PHNjcmlwdD5hbGVydCgxKTwvc2NyaXB0Pg==">x</a>',
|
||||
'link javascript href': '<a href="javascript:alert(1)">x</a>',
|
||||
'link vbscript href': '<a href="vbscript:msgbox(1)">x</a>',
|
||||
'math href': '<math href="javascript:alert(1)"></math>',
|
||||
'mathml annotation-xml integration point':
|
||||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
|
||||
'mathml mtext style mXSS':
|
||||
'<math><mtext><table><mglyph><style><img src=x onerror="window.__mdXss=1"></style></mglyph></table></mtext></math>',
|
||||
noscript: '<noscript><p title="</noscript><img src=x onerror="window.__mdXss=1>">',
|
||||
script: '<script>window.__mdXss=1</script>',
|
||||
'srcset javascript': '<img srcset="javascript:alert(1) 1x, x 2x" src=x>',
|
||||
'style tag': '<style>@import url(javascript:alert(1));</style>',
|
||||
'svg foreignObject smuggling':
|
||||
'<svg><foreignObject><img src=x onerror="window.__mdXss=1"></foreignObject></svg>',
|
||||
'svg set-attribute animation': '<svg><set attributeName="onmouseover" to="alert(1)"/></svg>',
|
||||
'unclosed tag': '<img src="x" onerror="window.__mdXss=1"',
|
||||
'uppercase tags': '<IMG SRC=x ONERROR="window.__mdXss=1">',
|
||||
'video source onerror': '<video><source onerror="window.__mdXss=1" src=x></video>',
|
||||
'xlink href': '<math><mtext xlink:href="javascript:alert(1)"></mtext></math>'
|
||||
};
|
||||
|
||||
describe('SAFE_HTML_CONFIG adversarial battery', () => {
|
||||
it('neutralizes every payload after sanitize and after innerHTML re-parse', () => {
|
||||
const failures: string[] = [];
|
||||
|
||||
for (const [name, payload] of Object.entries(PAYLOADS)) {
|
||||
const clean = DOMPurify.sanitize(payload, SAFE_HTML_CONFIG) as string;
|
||||
const again = roundTrip(clean);
|
||||
|
||||
if (HANDLER_ATTR.test(clean) || HANDLER_ATTR.test(again)) {
|
||||
failures.push(`${name}: handler attribute survived: ${again}`);
|
||||
}
|
||||
|
||||
if (JS_URL.test(clean) || JS_URL.test(again)) {
|
||||
failures.push(`${name}: javascript: URL survived: ${again}`);
|
||||
}
|
||||
}
|
||||
|
||||
expect(failures).toEqual([]);
|
||||
});
|
||||
|
||||
it('scrubs positioned styles that can draw an overlay, keeps KaTeX styles', () => {
|
||||
const clean = DOMPurify.sanitize(
|
||||
'<div style="position:fixed;inset:0;z-index:9999;background:red">x</div><span style="height:0.83em;vertical-align:-0.2em">y</span>',
|
||||
SAFE_HTML_CONFIG
|
||||
) as string;
|
||||
|
||||
expect(clean).not.toMatch(/position\s*:\s*fixed/i);
|
||||
expect(clean).toContain('height:0.83em');
|
||||
expect(clean).toContain('background:red');
|
||||
});
|
||||
|
||||
it('keeps benign model-card markup intact', () => {
|
||||
const clean = DOMPurify.sanitize(
|
||||
'<h2 id="title">Title</h2><p>Text with <a href="https://example.com" target="_blank" rel="noopener">a link</a>, <img src="https://example.com/i.png" alt="i" width="100">, <code>code</code>, <table><tr><td colspan="2">cell</td></tr></table>, <math><semantics><annotation encoding="application/x-tex">x^2</annotation></semantics></math></p>',
|
||||
SAFE_HTML_CONFIG
|
||||
) as string;
|
||||
|
||||
expect(clean).toContain('href="https://example.com"');
|
||||
expect(clean).toContain('<img');
|
||||
expect(clean).toContain('colspan="2"');
|
||||
expect(clean).toContain('<annotation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MarkdownContent end to end', () => {
|
||||
it('renders an allowHtml payload README without executing it', async () => {
|
||||
const evil = [
|
||||
'# Evil card',
|
||||
'',
|
||||
'<img src=x onerror="window.__mdXss=1">',
|
||||
'<a href="javascript:alert(1)">x</a>',
|
||||
'<script>window.__mdXss=1</script>',
|
||||
'<iframe srcdoc="<script>window.__mdXss=1</script>"></iframe>',
|
||||
'<math><annotation-xml encoding="text/html"><img src=x onerror="window.__mdXss=1"></annotation-xml></math>',
|
||||
'',
|
||||
'normal **markdown** continues'
|
||||
].join('\n');
|
||||
const target = document.createElement('div');
|
||||
|
||||
document.body.appendChild(target);
|
||||
mount(MarkdownContent, { props: { allowHtml: true, content: evil }, target });
|
||||
|
||||
for (let i = 0; i < 100 && !target.textContent?.includes('normal'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(target.textContent).toContain('normal');
|
||||
expect(xssFired()).toBe(false);
|
||||
expect(target.querySelector('script')).toBeNull();
|
||||
expect(target.querySelector('iframe')).toBeNull();
|
||||
|
||||
target.remove();
|
||||
});
|
||||
|
||||
it('escapes raw HTML to literal text in the default mode', async () => {
|
||||
const target = document.createElement('div');
|
||||
|
||||
document.body.appendChild(target);
|
||||
const component = mount(MarkdownContent, {
|
||||
props: {
|
||||
content: '<script>window.__mdXss=1</script><img src=x onerror="window.__mdXss=1">'
|
||||
},
|
||||
target
|
||||
});
|
||||
|
||||
for (let i = 0; i < 100 && !target.textContent?.includes('onerror'); i++) {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
|
||||
expect(xssFired()).toBe(false);
|
||||
expect(target.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(target.textContent).toContain('onerror');
|
||||
|
||||
target.remove();
|
||||
|
||||
if (component) unmount(component);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { McpServerForm } from '$lib/components/app/mcp';
|
||||
import { mcpStore } from '$lib/stores/mcp/index.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('mcp server display name', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateConfig('mcpServers', '[]');
|
||||
});
|
||||
|
||||
it('custom display name wins over the url fallback', () => {
|
||||
const server = mcpStore.addServer({
|
||||
displayName: 'My Tools',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
|
||||
expect(mcpStore.getServerLabel(server)).toBe('My Tools');
|
||||
});
|
||||
|
||||
it('without a custom name the url is the label', () => {
|
||||
const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/a' });
|
||||
|
||||
expect(mcpStore.getServerLabel(server)).toBe('https://mcp.example.com/a');
|
||||
});
|
||||
|
||||
it('identical labels get positional suffixes', () => {
|
||||
const a = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const b = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/b'
|
||||
});
|
||||
|
||||
expect(mcpStore.getServerLabel(a)).toBe('GitHub (1)');
|
||||
expect(mcpStore.getServerLabel(b)).toBe('GitHub (2)');
|
||||
});
|
||||
|
||||
it('renaming one twin dissolves the suffixes', () => {
|
||||
const a = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const b = mcpStore.addServer({
|
||||
displayName: 'GitHub',
|
||||
enabled: false,
|
||||
url: 'https://mcp.example.com/b'
|
||||
});
|
||||
|
||||
mcpStore.updateServer(b.id, { displayName: 'GitHub Work' });
|
||||
expect(mcpStore.getServerLabel(a)).toBe('GitHub');
|
||||
expect(mcpStore.getServerLabel(mcpStore.getServerById(b.id)!)).toBe('GitHub Work');
|
||||
});
|
||||
|
||||
it('the form exposes an editable display name field', async () => {
|
||||
let captured = '';
|
||||
|
||||
const screen = await render(McpServerForm, {
|
||||
headers: '',
|
||||
name: '',
|
||||
onHeadersChange: () => {},
|
||||
onNameChange: (v: string) => (captured = v),
|
||||
onUrlChange: () => {},
|
||||
url: 'https://mcp.example.com/a'
|
||||
});
|
||||
const input = screen.getByLabelText('Display name');
|
||||
|
||||
await expect.element(input).toBeVisible();
|
||||
await input.fill('My Custom Server');
|
||||
expect(captured).toBe('My Custom Server');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
// MCP icons live on a remote origin, which the browser cannot load under the
|
||||
// UI's cross-origin isolation. When the user enabled the server's CORS proxy the
|
||||
// icons are fetched through it; with the proxy off the origin URL stands in and
|
||||
// the components fall back when it fails. Inline data: icons load under any
|
||||
// policy and are never proxied.
|
||||
|
||||
import { mcpStore } from '$lib/stores/mcp/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { buildProxiedIconUrl } from '$lib/utils/cors-proxy';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// The favicon fallback resolves to the root domain of the server url, so the
|
||||
// icons of https://mcp.example.com/mcp are fetched from https://example.com.
|
||||
const FAVICON = 'https://example.com/favicon.ico';
|
||||
const DATA_ICON = 'data:image/png;base64,iVBORw0KGgo=';
|
||||
|
||||
function mockProxyEnabled(enabled: boolean) {
|
||||
Object.defineProperty(serverStore, 'props', {
|
||||
configurable: true,
|
||||
get: () => ({ cors_proxy_enabled: enabled }) as unknown as typeof serverStore.props
|
||||
});
|
||||
}
|
||||
|
||||
/** Target a proxied icon URL carries, or null when the URL is not proxied. */
|
||||
function proxiedTarget(iconUrl: string | null): string | null {
|
||||
if (!iconUrl) return null;
|
||||
|
||||
const url = new URL(iconUrl, window.location.origin);
|
||||
|
||||
return url.pathname.endsWith('/cors-proxy') ? url.searchParams.get('url') : null;
|
||||
}
|
||||
|
||||
describe('mcp server icon urls', () => {
|
||||
beforeEach(() => {
|
||||
settingsStore.updateConfig('mcpServers', '[]');
|
||||
});
|
||||
|
||||
it('routes the favicon fallback through the proxy when it is enabled', () => {
|
||||
mockProxyEnabled(true);
|
||||
|
||||
const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/mcp' });
|
||||
|
||||
expect(proxiedTarget(mcpStore.getServerFavicon(server.id))).toBe(FAVICON);
|
||||
});
|
||||
|
||||
it('keeps the origin url when the proxy is off', () => {
|
||||
mockProxyEnabled(false);
|
||||
|
||||
const server = mcpStore.addServer({ enabled: false, url: 'https://mcp.example.com/mcp' });
|
||||
|
||||
expect(mcpStore.getServerFavicon(server.id)).toBe(FAVICON);
|
||||
});
|
||||
|
||||
it('proxies an http icon url too', () => {
|
||||
expect(proxiedTarget(buildProxiedIconUrl('http://example.com/favicon.ico', true))).toBe(
|
||||
'http://example.com/favicon.ico'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a data url alone whichever way the proxy is set', () => {
|
||||
expect(buildProxiedIconUrl(DATA_ICON, true)).toBe(DATA_ICON);
|
||||
expect(buildProxiedIconUrl(DATA_ICON, false)).toBe(DATA_ICON);
|
||||
expect(buildProxiedIconUrl(null, true)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const AUTHORIZATION_HEADER = 'Authorization';
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
const BEARER_PLACEHOLDER = 'Paste token here';
|
||||
|
||||
/**
|
||||
* Client-side tests for the McpServerForm bearer UI.
|
||||
*
|
||||
* The dedicated UI only "owns" Authorization headers that already carry a
|
||||
* Bearer scheme (heuristic check on the value). Other Authorization values
|
||||
* stay in the KV section so the user can still edit them verbatim. Storage
|
||||
* always goes through the same custom-headers slot, so a round-trip via this
|
||||
* UI produces exactly one `Authorization: Bearer <token>` entry.
|
||||
*
|
||||
* Equivalent parser coverage lives in `tests/unit/headers.test.ts`.
|
||||
*/
|
||||
describe('McpServerForm - Authorization / bearer UI', () => {
|
||||
function bearerInput(screen: Awaited<ReturnType<typeof render>>) {
|
||||
return screen.locator.getByPlaceholder(BEARER_PLACEHOLDER);
|
||||
}
|
||||
|
||||
function capturedHeaders(screen: Awaited<ReturnType<typeof render>>) {
|
||||
return screen.getByTestId('captured-headers');
|
||||
}
|
||||
|
||||
it('mounts with the bearer input hidden when no auth header is present', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: /server url/i })).toBeVisible();
|
||||
|
||||
await expect.element(bearerInput(screen)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggling Authorization shows the bearer input', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect.element(bearerInput(screen)).toBeVisible();
|
||||
});
|
||||
|
||||
it('typing a token writes the Authorization row with the Bearer prefix prepended', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
const token = 'super-secret';
|
||||
|
||||
await bearerInput(screen).fill(token);
|
||||
|
||||
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${token}` });
|
||||
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', expected);
|
||||
});
|
||||
|
||||
it('pre-existing Bearer header pre-fills the bearer input with the token stripped', async () => {
|
||||
const existing = JSON.stringify({
|
||||
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`,
|
||||
'X-Trace-Id': 'abc'
|
||||
});
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await expect.element(bearerInput(screen)).toBeVisible();
|
||||
await expect.element(bearerInput(screen)).toHaveValue('preexisting');
|
||||
});
|
||||
|
||||
it('non-Bearer Authorization is ignored by the dedicated UI and stays in the KV section', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await expect.element(bearerInput(screen)).not.toBeInTheDocument();
|
||||
|
||||
const headerKeyInput = screen.getByPlaceholder('Header name');
|
||||
|
||||
await expect.element(headerKeyInput).toBeVisible();
|
||||
});
|
||||
|
||||
it('engaging the token UI replaces a non-Bearer Authorization with the Bearer scheme', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic old' });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
await bearerInput(screen).fill('new');
|
||||
|
||||
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}new` });
|
||||
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', expected);
|
||||
});
|
||||
|
||||
it('toggling Authorization off with no token drops the Bearer row but keeps non-Bearer schemes', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
|
||||
});
|
||||
|
||||
it('toggling Authorization off when no Bearer row is present leaves headers untouched', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', existing);
|
||||
});
|
||||
|
||||
it('clearing the bearer input drops the Authorization row', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await bearerInput(screen).fill('');
|
||||
|
||||
await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
|
||||
});
|
||||
|
||||
it('does not surface Bearer Authorization in the KV section even when pre-existing', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
const headerKeyInput = screen.getByPlaceholder('Header name');
|
||||
|
||||
await expect.element(headerKeyInput).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
// Guards which org a model avatar shows: a row that names neither flag follows the
|
||||
// family grouping (grouped lists leave the base org to the family heading), while a
|
||||
// caller that names one flag keeps control of what the avatar shows.
|
||||
|
||||
import ModelAvatar from '$lib/components/app/models/ModelAvatar.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
// the base-model tag resolves the base org without a Hub lookup
|
||||
const option: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B-GGUF:Q4_K_M',
|
||||
model: 'org/Qwen3-8B-GGUF:Q4_K_M',
|
||||
name: 'Qwen3-8B',
|
||||
tags: ['base_model:Qwen/Qwen3-8B']
|
||||
};
|
||||
/** The main avatar plus the quantizer badge, when the badge differs from it. */
|
||||
const avatars = (container: HTMLElement) => container.querySelectorAll('img').length;
|
||||
|
||||
function setGrouping(grouped: boolean) {
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY, grouped);
|
||||
settingsStore.updateConfig(SETTINGS_KEYS.USE_HUGGING_FACE_HUB, true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setGrouping(false);
|
||||
});
|
||||
|
||||
describe('model avatar orgs', () => {
|
||||
it('shows the base org with the quantizer badge when the lists are flat', async () => {
|
||||
const screen = render(ModelAvatar, { option });
|
||||
|
||||
expect(avatars(screen.container)).toBe(2);
|
||||
});
|
||||
|
||||
it('leaves the base org to the family heading when the lists are grouped', async () => {
|
||||
setGrouping(true);
|
||||
|
||||
const screen = render(ModelAvatar, { option });
|
||||
|
||||
expect(avatars(screen.container)).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the base org of a caller that names it, grouped lists or not', async () => {
|
||||
setGrouping(true);
|
||||
|
||||
const screen = render(ModelAvatar, { option, showBaseModelAvatar: true });
|
||||
|
||||
expect(avatars(screen.container)).toBe(2);
|
||||
});
|
||||
|
||||
it('keeps the repo org of a caller that names it, flat lists or not', async () => {
|
||||
const screen = render(ModelAvatar, { option, showRepoOrgAvatar: true });
|
||||
|
||||
expect(avatars(screen.container)).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
// Guards the download flows the chips depend on: one POST per click, the chip
|
||||
// reading downloaded once the list reports the model, a resume keeping the
|
||||
// sidecars it started with, a cancel dropping the whole download, and a reload
|
||||
// restoring the paused state of an unfinished one.
|
||||
|
||||
import { ModelAuxSidecar, ModelDraftSidecar, ServerModelStatus, ServerRole } from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ApiModelDataEntry, ApiModelsListResponse } from '$lib/types/api';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const REPO = 'org/model-GGUF';
|
||||
const TAG = `${REPO}:Q4_K_M`;
|
||||
const DRAFTS = [ModelAuxSidecar.MMPROJ, ModelDraftSidecar.DFLASH];
|
||||
|
||||
/** The listing entry a router reports once a tag is downloaded. */
|
||||
function entry(id: string, sidecars: string[] = [], status = ServerModelStatus.DOWNLOADED) {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
sidecars,
|
||||
status: { value: status }
|
||||
} as unknown as ApiModelDataEntry;
|
||||
}
|
||||
|
||||
/** The download endpoints the store talks to; the feed is not live in these tests. */
|
||||
function stubEndpoints() {
|
||||
const post = vi.spyOn(ModelsService, 'downloadModel').mockResolvedValue({ success: true });
|
||||
const cancel = vi.spyOn(ModelsService, 'cancelDownload').mockResolvedValue({ success: true });
|
||||
const unload = vi.spyOn(ModelsService, 'unload').mockResolvedValue({ success: true });
|
||||
|
||||
vi.spyOn(ModelsService, 'watchModelEvents').mockResolvedValue(undefined);
|
||||
vi.spyOn(ModelsService, 'list').mockResolvedValue({
|
||||
data: [],
|
||||
models: [],
|
||||
object: 'list'
|
||||
} as unknown as ApiModelsListResponse);
|
||||
|
||||
return { cancel, post, unload };
|
||||
}
|
||||
|
||||
describe('model download flows', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
modelsStore.routerModels = [];
|
||||
// the store is a singleton: drop what an earlier case tracked
|
||||
modelsStore.status.unsubscribe();
|
||||
});
|
||||
|
||||
it('posts once for two quick clicks on the same chip', async () => {
|
||||
const { post } = stubEndpoints();
|
||||
const first = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
const second = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
|
||||
// the chip reads in flight before the server answers, so the second click
|
||||
// finds it busy instead of posting again
|
||||
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(true);
|
||||
|
||||
await Promise.all([first, second]);
|
||||
|
||||
expect(post).toHaveBeenCalledTimes(1);
|
||||
expect(post).toHaveBeenCalledWith(TAG, DRAFTS);
|
||||
});
|
||||
|
||||
it('reads the chip as downloaded once the list reports the model', async () => {
|
||||
stubEndpoints();
|
||||
|
||||
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
|
||||
expect(modelsStore.status.isModelDownloaded(TAG)).toBe(false);
|
||||
|
||||
// the feed's reload lands as a listing the tag now appears in
|
||||
modelsStore.routerModels = [entry(TAG, DRAFTS)];
|
||||
|
||||
expect(modelsStore.status.isModelDownloaded(TAG)).toBe(true);
|
||||
// the sidecar option of that model reads the draft off the same entry
|
||||
expect(modelsStore.status.isModelDownloaded(`${TAG}-dflash`)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not pause a download whose POST is still on its way', async () => {
|
||||
const { post, unload } = stubEndpoints();
|
||||
|
||||
let release: () => void = () => {};
|
||||
|
||||
post.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
release = () => resolve({ success: true });
|
||||
})
|
||||
);
|
||||
|
||||
const started = modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
|
||||
// the chip reads as downloading before the server answers, so a second click
|
||||
// must not stop a model that never started
|
||||
await modelsStore.status.pauseDownload(TAG);
|
||||
|
||||
expect(unload).not.toHaveBeenCalled();
|
||||
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(true);
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(false);
|
||||
|
||||
release();
|
||||
await started;
|
||||
});
|
||||
|
||||
it('resumes with the sidecars the download started with', async () => {
|
||||
const { post } = stubEndpoints();
|
||||
|
||||
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
await modelsStore.status.pauseDownload(TAG);
|
||||
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(true);
|
||||
|
||||
// the resume a chip sends names no kinds of its own
|
||||
await modelsStore.status.downloadModel(TAG);
|
||||
|
||||
expect(post).toHaveBeenLastCalledWith(TAG, DRAFTS);
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(false);
|
||||
});
|
||||
|
||||
it('cancels the model and its sidecars as one download', async () => {
|
||||
const { cancel } = stubEndpoints();
|
||||
|
||||
await modelsStore.status.downloadModel(TAG, [ModelDraftSidecar.DFLASH]);
|
||||
await modelsStore.status.cancelDownload(TAG);
|
||||
|
||||
// no sidecar of its own is asked for: the model goes as one unit
|
||||
expect(cancel).toHaveBeenCalledWith(TAG, null);
|
||||
expect(modelsStore.status.isDownloadInProgress(TAG)).toBe(false);
|
||||
expect(modelsStore.status.getDownloadEntries()).toEqual([]);
|
||||
});
|
||||
|
||||
it('restores the paused state of an unfinished download after a reload', async () => {
|
||||
const { post } = stubEndpoints();
|
||||
|
||||
modelsStore.routerModels = [entry(TAG, DRAFTS, ServerModelStatus.INCOMPLETE)];
|
||||
modelsStore.status.syncPausedDownloads();
|
||||
|
||||
expect(modelsStore.status.isDownloadPaused(TAG)).toBe(true);
|
||||
expect(modelsStore.status.getDownloadEntries()).toEqual([
|
||||
{ isPaused: true, progress: null, repoWithTag: TAG }
|
||||
]);
|
||||
|
||||
// and its resume keeps the kinds the incomplete listing reports
|
||||
await modelsStore.status.downloadModel(TAG);
|
||||
|
||||
expect(post).toHaveBeenCalledWith(TAG, DRAFTS);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
// Guards the row behaviour of the manager table: a row selects its model with a
|
||||
// click or from the keyboard, and a repo row toggles its quants the same way.
|
||||
|
||||
import ModelsManagerRowWrapper from './components/ModelsManagerRowWrapper.svelte';
|
||||
import ModelsManagerRepoRow from '$lib/components/app/models/ModelsManager/ModelsManagerRepoRow.svelte';
|
||||
import type { ModelQuantGroup } from '$lib/components/app/models/ModelsManager/utils';
|
||||
import { ModelGroupKind, ServerRole } from '$lib/enums';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const option: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B:Q4_K_M',
|
||||
model: 'org/Qwen3-8B:Q4_K_M',
|
||||
name: 'Qwen3-8B'
|
||||
};
|
||||
const entry: ModelQuantGroup = {
|
||||
base: option,
|
||||
key: 'org/Qwen3-8B',
|
||||
kind: ModelGroupKind.QUANTS,
|
||||
quants: [
|
||||
option,
|
||||
{ ...option, id: 'org/Qwen3-8B:Q8_0', model: 'org/Qwen3-8B:Q8_0', name: 'Qwen3-8B-Q8_0' }
|
||||
]
|
||||
};
|
||||
/** The name the row carries once the model id is split into its badges. */
|
||||
const ROW_NAME = /org\/Qwen3\s+8B/;
|
||||
|
||||
describe('manager model row', () => {
|
||||
let selected: ModelOption[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
selected = [];
|
||||
// the row only offers a load control on a router server
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
});
|
||||
|
||||
function row() {
|
||||
return render(ModelsManagerRowWrapper, {
|
||||
onSelect: (picked: ModelOption) => selected.push(picked),
|
||||
option,
|
||||
selected: false
|
||||
});
|
||||
}
|
||||
|
||||
it('selects the model from the row', async () => {
|
||||
const screen = await row();
|
||||
|
||||
await screen.getByRole('button', { name: ROW_NAME }).click();
|
||||
|
||||
expect(selected).toEqual([option]);
|
||||
});
|
||||
|
||||
it('selects the model from the keyboard', async () => {
|
||||
const screen = await row();
|
||||
const target = screen.getByRole('button', { name: ROW_NAME }).element();
|
||||
|
||||
target.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
expect(selected).toEqual([option]);
|
||||
});
|
||||
|
||||
it('keeps the load and actions controls of the row', async () => {
|
||||
const screen = await row();
|
||||
|
||||
await expect
|
||||
.element(screen.getByRole('button', { exact: true, name: 'Model actions' }))
|
||||
.toBeVisible();
|
||||
expect(screen.container.querySelector('[aria-label="Load model"]')).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('manager repo row', () => {
|
||||
it('toggles the quants from the row', async () => {
|
||||
let toggles = 0;
|
||||
|
||||
const screen = await render(ModelsManagerRepoRow, {
|
||||
entry,
|
||||
expanded: true,
|
||||
onToggle: () => (toggles += 1)
|
||||
});
|
||||
|
||||
await screen.getByRole('button', { name: /2 quants available/ }).click();
|
||||
|
||||
expect(toggles).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
// Guards the manager table ordering and filtering: the context column sorts and
|
||||
// filters once a model's context is known, from the option or the Hub record.
|
||||
|
||||
import ModelsManagerWrapper from './components/ModelsManagerWrapper.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services';
|
||||
import { modelsStore, settingsStore } from '$lib/stores';
|
||||
import type { ApiModelDataEntry } from '$lib/types';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { SvelteMap } from 'svelte/reactivity';
|
||||
import { beforeEach, expect, it, vi } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
function option(model: string, contextLength?: number): ModelOption {
|
||||
return {
|
||||
capabilities: [],
|
||||
contextLength,
|
||||
id: model,
|
||||
model,
|
||||
name: model
|
||||
};
|
||||
}
|
||||
|
||||
const models = [
|
||||
option('org/alpha-8b:Q4_K_M', 8192),
|
||||
option('org/beta-8b:Q4_K_M', 131072),
|
||||
option('org/gamma-8b:Q4_K_M', 32768)
|
||||
];
|
||||
// the router listing carries no context, so a row starts without one
|
||||
const modelsWithoutContext = models.map((model) => ({ ...model, contextLength: undefined }));
|
||||
|
||||
beforeEach(() => {
|
||||
modelsStore.routerModels = [];
|
||||
modelsStore.models = modelsWithoutContext;
|
||||
modelsStore.favoriteModelIds = new Set();
|
||||
settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY] = false;
|
||||
});
|
||||
|
||||
/** Renders the manager and waits for the test models to show. */
|
||||
async function renderWithModels(rows: ModelOption[] = modelsWithoutContext) {
|
||||
const screen = render(ModelsManagerWrapper);
|
||||
|
||||
modelsStore.models = rows;
|
||||
|
||||
await expect.element(screen.getByText(/gamma\s+8B/)).toBeVisible();
|
||||
|
||||
return screen;
|
||||
}
|
||||
|
||||
function rowNames(container: HTMLElement): string[] {
|
||||
return [...container.querySelectorAll('button[aria-pressed]')].map(
|
||||
(row) => row.textContent ?? ''
|
||||
);
|
||||
}
|
||||
|
||||
/** The Hub details cache the rows read their context from. */
|
||||
function detailsCache() {
|
||||
return (
|
||||
HuggingFaceService as unknown as {
|
||||
detailsCache: SvelteMap<string, { gguf?: { context_length?: number } } | null>;
|
||||
}
|
||||
).detailsCache;
|
||||
}
|
||||
|
||||
function warmCache() {
|
||||
const cache = detailsCache();
|
||||
|
||||
cache.set('org/alpha-8b', { gguf: { context_length: 8192 } });
|
||||
cache.set('org/beta-8b', { gguf: { context_length: 131072 } });
|
||||
cache.set('org/gamma-8b', { gguf: { context_length: 32768 } });
|
||||
}
|
||||
|
||||
it('sorts by context', async () => {
|
||||
const screen = await renderWithModels(models);
|
||||
|
||||
// lowest first
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const ascending = rowNames(screen.container);
|
||||
|
||||
await screen.getByTitle('Sort by context, highest first').click();
|
||||
|
||||
const descending = rowNames(screen.container);
|
||||
|
||||
expect(ascending).not.toEqual(descending);
|
||||
});
|
||||
|
||||
it('sorts by context with family grouping on', async () => {
|
||||
settingsStore.config[SETTINGS_KEYS.GROUP_MODELS_BY_FAMILY] = true;
|
||||
|
||||
const screen = await renderWithModels(models);
|
||||
|
||||
// lowest first
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const ascending = rowNames(screen.container);
|
||||
|
||||
await screen.getByTitle('Sort by context, highest first').click();
|
||||
|
||||
const descending = rowNames(screen.container);
|
||||
|
||||
expect(ascending).not.toEqual(descending);
|
||||
});
|
||||
|
||||
it('lists the favorited quants of a repo as flat rows', async () => {
|
||||
// one quant of the beta repo is a favorite, the other stays with the repo
|
||||
modelsStore.favoriteModelIds = new Set(['org/alpha-8b:Q4_K_M', 'org/beta-8b:Q4_K_M']);
|
||||
|
||||
const screen = await renderWithModels([
|
||||
modelsWithoutContext[0],
|
||||
modelsWithoutContext[1],
|
||||
option('org/beta-8b:Q8_0'),
|
||||
modelsWithoutContext[2]
|
||||
]);
|
||||
|
||||
// the favorite quant is a model row of its own, not a repo with subitems
|
||||
expect(screen.container.textContent).not.toContain('2 quants available');
|
||||
|
||||
// the repo appears once in favorites for its favorited quant and once in the
|
||||
// local block for the quant left behind
|
||||
expect(screen.getByText(/beta\s+8B/).elements().length).toBe(2);
|
||||
});
|
||||
|
||||
/** A router listing entry carrying only the meta context. */
|
||||
function entry(model: string, nCtxTrain: number): ApiModelDataEntry {
|
||||
return {
|
||||
created: 0,
|
||||
id: model,
|
||||
in_cache: false,
|
||||
meta: { n_ctx_train: nCtxTrain },
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${model}`,
|
||||
status: { value: ServerModelStatus.UNLOADED }
|
||||
};
|
||||
}
|
||||
|
||||
it('sorts by the meta context of a listing without the router field', async () => {
|
||||
// a listing that skips the router's GGUF read reports the trained context
|
||||
// only as meta.n_ctx_train, so the option mapping falls back to it
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
|
||||
if (url.includes('/props')) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
default_generation_settings: { n_ctx: 0, params: {} },
|
||||
model_alias: 'llama-server',
|
||||
model_path: 'none',
|
||||
role: 'router'
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
if (url.includes('/server')) {
|
||||
return new Response(
|
||||
JSON.stringify({ git_branch: 'test', git_commit: 'test', mode: 'router', version: 'test' }),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
if (/\/v1\/models|\/models\b/.test(url)) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
entry('org/alpha-8b:Q4_K_M', 8192),
|
||||
entry('org/beta-8b:Q4_K_M', 131072),
|
||||
entry('org/gamma-8b:Q4_K_M', 32768)
|
||||
],
|
||||
object: 'list'
|
||||
}),
|
||||
{ headers: { 'Content-Type': 'application/json' }, status: 200 }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch in the test: ${url}`);
|
||||
});
|
||||
|
||||
const screen = render(ModelsManagerWrapper);
|
||||
|
||||
// the fetch maps the listing into options, the meta context fills in
|
||||
await modelsStore.fetch(true);
|
||||
|
||||
await expect.element(screen.getByText(/gamma\s+8B/)).toBeVisible();
|
||||
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const names = rowNames(screen.container).join(' | ');
|
||||
|
||||
expect(names.indexOf('alpha')).toBeLessThan(names.indexOf('gamma'));
|
||||
expect(names.indexOf('gamma')).toBeLessThan(names.indexOf('beta'));
|
||||
});
|
||||
|
||||
it('re-sorts when the Hub details arrive after the sort was clicked', async () => {
|
||||
const screen = await renderWithModels();
|
||||
|
||||
// the user sorts while the contexts are still unknown
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
// then the rows fetch their Hub records
|
||||
warmCache();
|
||||
|
||||
// the table re-sorts once the cache answers
|
||||
await vi.waitFor(() => {
|
||||
const names = rowNames(screen.container).join(' | ');
|
||||
|
||||
expect(names.indexOf('alpha')).toBeLessThan(names.indexOf('gamma'));
|
||||
expect(names.indexOf('gamma')).toBeLessThan(names.indexOf('beta'));
|
||||
|
||||
return names;
|
||||
});
|
||||
});
|
||||
|
||||
it('filters by search', async () => {
|
||||
const screen = await renderWithModels();
|
||||
|
||||
await screen.getByPlaceholder('Search your models').fill('beta');
|
||||
|
||||
await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText(/beta\s+8B/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('filters by context and sorts from the Hub details cache', async () => {
|
||||
warmCache();
|
||||
|
||||
const screen = await renderWithModels();
|
||||
|
||||
// open the context filter and ask for 32K or more
|
||||
await screen.getByText('Context:').click();
|
||||
await screen.getByText('32K or more').click();
|
||||
|
||||
await expect.element(screen.getByText(/alpha\s+8B/)).not.toBeInTheDocument();
|
||||
await expect.element(screen.getByText(/gamma\s+8B/)).toBeVisible();
|
||||
|
||||
// sorting re-runs once the cache answers
|
||||
await screen.getByTitle('Sort by context, lowest first').click();
|
||||
|
||||
const names = rowNames(screen.container).join(' | ');
|
||||
|
||||
expect(names.indexOf('gamma')).toBeLessThan(names.indexOf('beta'));
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
// The manager table is built from the store's option list, so these run in the
|
||||
// browser project: how the quants fold into rows, what context a row reports and
|
||||
// how the tracked downloads become rows of their own.
|
||||
|
||||
import {
|
||||
downloadGroups,
|
||||
groupModelQuants,
|
||||
modelContextLength,
|
||||
splitHiddenQuants
|
||||
} from '$lib/components/app/models/ModelsManager/utils';
|
||||
import { LOCAL_BACKEND_ID } from '$lib/constants';
|
||||
import { ModelDraftSidecar, ModelGroupKind, ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import type { ApiModelDataEntry, ModelOption } from '$lib/types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function option(model: string, id = model): ModelOption {
|
||||
return { capabilities: [], id, model, name: model };
|
||||
}
|
||||
|
||||
function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
status: { value: status }
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
modelsStore.routerModels = [];
|
||||
});
|
||||
|
||||
describe('groupModelQuants', () => {
|
||||
it('folds the quants of one repo into a single entry', () => {
|
||||
const groups = groupModelQuants([
|
||||
option('org/Qwen3-8B:Q4_K_M'),
|
||||
option('org/Qwen3-8B:Q8_0'),
|
||||
option('org/Llama-3-8B:Q4_K_M')
|
||||
]);
|
||||
|
||||
expect(groups.map((group) => [group.key, group.kind, group.quants.length])).toEqual([
|
||||
['org/Qwen3-8B', ModelGroupKind.QUANTS, 2],
|
||||
['org/Llama-3-8B', ModelGroupKind.QUANTS, 1]
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the first quant of a repo as the row it renders', () => {
|
||||
const groups = groupModelQuants([option('org/Qwen3-8B:Q4_K_M'), option('org/Qwen3-8B:Q8_0')]);
|
||||
|
||||
expect(groups[0].base.model).toBe('org/Qwen3-8B:Q4_K_M');
|
||||
});
|
||||
|
||||
it('keeps rows of the same id apart as variants', () => {
|
||||
const groups = groupModelQuants([
|
||||
option('org/Qwen3-8B:Q4_K_M', 'first'),
|
||||
option('org/Qwen3-8B:Q4_K_M', 'second')
|
||||
]);
|
||||
|
||||
expect(groups.map((group) => [group.key, group.kind])).toEqual([
|
||||
['org/Qwen3-8B::first', ModelGroupKind.VARIANTS],
|
||||
['org/Qwen3-8B::second', ModelGroupKind.VARIANTS]
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelContextLength', () => {
|
||||
it('reports the context of the listing', () => {
|
||||
expect(modelContextLength({ ...option('org/Qwen3-8B:Q4_K_M'), contextLength: 8192 })).toBe(
|
||||
8192
|
||||
);
|
||||
});
|
||||
|
||||
it('reports nothing while no source knows the context', () => {
|
||||
expect(modelContextLength(option('org/Qwen3-8B:Q4_K_M'))).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadGroups', () => {
|
||||
it('reuses the row of a download the router already lists', () => {
|
||||
const listed = option('org/Qwen3-8B:Q4_K_M');
|
||||
const groups = downloadGroups(
|
||||
[{ isPaused: false, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' }],
|
||||
[listed],
|
||||
() => []
|
||||
);
|
||||
|
||||
expect(groups[0].key).toBe('download::org/Qwen3-8B:Q4_K_M');
|
||||
expect(groups[0].base).toMatchObject(listed);
|
||||
});
|
||||
|
||||
it('stands the download in for a row of its own when nothing is listed', () => {
|
||||
const groups = downloadGroups(
|
||||
[{ isPaused: true, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' }],
|
||||
[],
|
||||
(repo) =>
|
||||
repo === 'org/Qwen3-8B' ? [{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo }] : []
|
||||
);
|
||||
|
||||
// the sub-lines carry the sidecars, so the inline badges stay empty
|
||||
expect(groups[0].base).toMatchObject({
|
||||
backendId: LOCAL_BACKEND_ID,
|
||||
draftSidecars: [],
|
||||
model: 'org/Qwen3-8B:Q4_K_M'
|
||||
});
|
||||
expect(groups[0].sidecarBadges).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: 'org/Qwen3-8B' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps one row per tracked download, sidecars included', () => {
|
||||
const main = { isPaused: false, progress: null, repoWithTag: 'org/Qwen3-8B:Q4_K_M' };
|
||||
const other = { isPaused: true, progress: null, repoWithTag: 'org/Qwen3-8B:Q8_0' };
|
||||
const groups = downloadGroups([main, other], [], () => []);
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
expect(groups.map((group) => group.key)).toEqual([
|
||||
'download::org/Qwen3-8B:Q4_K_M',
|
||||
'download::org/Qwen3-8B:Q8_0'
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('modelsStore.isModelRunning', () => {
|
||||
it('counts a loaded or sleeping model as running', () => {
|
||||
modelsStore.routerModels = [
|
||||
routerEntry('org/loaded', ServerModelStatus.LOADED),
|
||||
routerEntry('org/sleeping', ServerModelStatus.SLEEPING)
|
||||
];
|
||||
|
||||
expect(modelsStore.isModelRunning('org/loaded')).toBe(true);
|
||||
expect(modelsStore.isModelRunning('org/sleeping')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not count a loading, failed or unknown model as running', () => {
|
||||
modelsStore.routerModels = [
|
||||
routerEntry('org/loading', ServerModelStatus.LOADING),
|
||||
routerEntry('org/failed', ServerModelStatus.FAILED)
|
||||
];
|
||||
|
||||
expect(modelsStore.isModelRunning('org/loading')).toBe(false);
|
||||
expect(modelsStore.isModelRunning('org/failed')).toBe(false);
|
||||
expect(modelsStore.isModelRunning('org/unknown')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('splitHiddenQuants', () => {
|
||||
const entry = (repo: string, quants: string[]) => ({
|
||||
base: option(quants[0]),
|
||||
key: repo,
|
||||
kind: ModelGroupKind.QUANTS,
|
||||
quants: quants.map((model) => option(model))
|
||||
});
|
||||
|
||||
it('keeps the visible quants of a partly hidden repo in the local block', () => {
|
||||
const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M', 'org/Qwen3-8B:Q8_0']);
|
||||
const { hidden, local } = splitHiddenQuants([repo], (picked) => picked.model.endsWith('Q8_0'));
|
||||
|
||||
expect(hidden.map((group) => [group.key, group.quants.length])).toEqual([
|
||||
['org/Qwen3-8B::hidden', 1]
|
||||
]);
|
||||
expect(local.map((group) => [group.key, group.quants.length])).toEqual([['org/Qwen3-8B', 1]]);
|
||||
expect(local[0].base.model).toBe('org/Qwen3-8B:Q4_K_M');
|
||||
});
|
||||
|
||||
it('moves a repo whose quants are all hidden', () => {
|
||||
const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M', 'org/Qwen3-8B:Q8_0']);
|
||||
const { hidden, local } = splitHiddenQuants([repo], () => true);
|
||||
|
||||
expect(hidden[0].quants).toHaveLength(2);
|
||||
expect(local).toEqual([]);
|
||||
});
|
||||
|
||||
it('leaves a repo with no hidden quant alone', () => {
|
||||
const repo = entry('org/Qwen3-8B', ['org/Qwen3-8B:Q4_K_M']);
|
||||
const { hidden, local } = splitHiddenQuants([repo], () => false);
|
||||
|
||||
expect(hidden).toEqual([]);
|
||||
expect(local[0].key).toBe('org/Qwen3-8B');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
// Guards the hidden-model rule of the selector: a hidden model leaves every
|
||||
// section, while the model a conversation already uses still resolves, since it
|
||||
// can be hidden and selected at the same time.
|
||||
|
||||
import ModelsSelectorHookHarness from './components/ModelsSelectorHookHarness.svelte';
|
||||
import { ServerModelStatus, ServerRole } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ApiModelDataEntry, ModelOption } from '$lib/types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
function option(model: string): ModelOption {
|
||||
return { capabilities: [], id: model, model, name: model };
|
||||
}
|
||||
|
||||
function routerEntry(id: string, status: ServerModelStatus): ApiModelDataEntry {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
status: { value: status }
|
||||
};
|
||||
}
|
||||
|
||||
const VISIBLE = option('org/visible:Q4_K_M');
|
||||
const HIDDEN = option('org/hidden:Q4_K_M');
|
||||
const HIDDEN_LOADED = option('org/hidden-loaded:Q4_K_M');
|
||||
const HIDDEN_FAVORITE = option('org/hidden-favorite:Q4_K_M');
|
||||
|
||||
beforeEach(() => {
|
||||
modelsStore.models = [VISIBLE, HIDDEN, HIDDEN_LOADED, HIDDEN_FAVORITE];
|
||||
modelsStore.favoriteModelIds = new Set([HIDDEN_FAVORITE.model]);
|
||||
modelsStore.hiddenModelIds = new Set([HIDDEN.id, HIDDEN_LOADED.id, HIDDEN_FAVORITE.id]);
|
||||
modelsStore.routerModels = [routerEntry(HIDDEN_LOADED.model, ServerModelStatus.LOADED)];
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
});
|
||||
|
||||
describe('hidden models in the selector', () => {
|
||||
it('lists what is not hidden', () => {
|
||||
const screen = render(ModelsSelectorHookHarness);
|
||||
|
||||
expect(screen.component.listedIds()).toEqual([VISIBLE.id]);
|
||||
});
|
||||
|
||||
it('keeps a hidden favorite and a hidden loaded model out of the sections', () => {
|
||||
const screen = render(ModelsSelectorHookHarness);
|
||||
const listed = screen.component.listedIds();
|
||||
|
||||
expect(listed).not.toContain(HIDDEN_FAVORITE.id);
|
||||
expect(listed).not.toContain(HIDDEN_LOADED.id);
|
||||
});
|
||||
|
||||
it('reports an empty list when everything is hidden', () => {
|
||||
modelsStore.hiddenModelIds = new Set([
|
||||
VISIBLE.id,
|
||||
HIDDEN.id,
|
||||
HIDDEN_LOADED.id,
|
||||
HIDDEN_FAVORITE.id
|
||||
]);
|
||||
|
||||
const screen = render(ModelsSelectorHookHarness);
|
||||
|
||||
expect(screen.component.isEmpty()).toBe(true);
|
||||
});
|
||||
|
||||
it('still resolves the model the conversation uses when it is hidden', () => {
|
||||
const screen = render(ModelsSelectorHookHarness, {
|
||||
currentModel: () => HIDDEN.model
|
||||
});
|
||||
|
||||
expect(screen.component.shownModelId()).toBe(HIDDEN.id);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
// Guards the phone model picker: the selector serves a phone a bottom drawer
|
||||
// with the same search and sections the desktop menu shows.
|
||||
|
||||
import ModelsSelector from '$lib/components/app/models/ModelsSelector/ModelsSelector.svelte';
|
||||
import { ServerRole } from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
const option: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B:Q4_K_M',
|
||||
model: 'org/Qwen3-8B:Q4_K_M',
|
||||
name: 'Qwen3-8B'
|
||||
};
|
||||
const other: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B:Q8_0',
|
||||
model: 'org/Qwen3-8B:Q8_0',
|
||||
name: 'Qwen3-8B-Q8_0'
|
||||
};
|
||||
|
||||
describe('model picker drawer on a phone', () => {
|
||||
beforeEach(async () => {
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
// the picker mounts a fetch of its own: serve it the same two models, so
|
||||
// the list it rebuilds does not clear the seeded one mid-test
|
||||
vi.spyOn(ModelsService, 'list').mockResolvedValue({
|
||||
data: [{ id: option.model }, { id: other.model }]
|
||||
} as never);
|
||||
modelsStore.models = [option, other];
|
||||
await modelsStore.selectModelById(option.id);
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
modelsStore.models = [];
|
||||
vi.restoreAllMocks();
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('opens the picker in a drawer', async () => {
|
||||
const screen = render(ModelsSelector);
|
||||
|
||||
await screen.getByRole('button').click();
|
||||
|
||||
await expect.element(screen.getByPlaceholder('Search models...')).toBeVisible();
|
||||
await expect.element(screen.getByText('Local models')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
// Guards the draft badges a model row shows: a registered model records its draft
|
||||
// in its `--model-draft` args, which know the file's quant, and the listing reports
|
||||
// the kinds every cached model of the repo has on disk, which do not. The row shows
|
||||
// each kind once, and two quants of one repo keep their own drafts apart.
|
||||
|
||||
import { ModelAuxSidecar, ModelDraftSidecar, ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores';
|
||||
import type { ApiModelDataEntry } from '$lib/types/api';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const REPO = 'ggml-org/Qwen3.8-Flash-Next-GGUF';
|
||||
|
||||
/** Draft file of a cached model, as a preset records it in `--model-draft`. */
|
||||
function draftPath(quant: string): string {
|
||||
return `/cache/models--ggml-org--Qwen3.8-Flash-Next-GGUF/snapshots/${'a'.repeat(40)}/mtp-Qwen3.8-Flash-Next-${quant}.gguf`;
|
||||
}
|
||||
|
||||
/** A router entry; `args` stand in for the args of a loaded instance. */
|
||||
function entry(
|
||||
id: string,
|
||||
sidecars: string[] = [],
|
||||
args: string[] = [],
|
||||
status = ServerModelStatus.UNLOADED
|
||||
) {
|
||||
return {
|
||||
created: 0,
|
||||
id,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${id}`,
|
||||
sidecars,
|
||||
status: { args, value: status }
|
||||
} as unknown as ApiModelDataEntry;
|
||||
}
|
||||
|
||||
describe('draft sidecar badges', () => {
|
||||
beforeEach(() => {
|
||||
modelsStore.routerModels = [];
|
||||
});
|
||||
|
||||
it('shows one badge for a draft the args and the listing both report', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(
|
||||
`${REPO}:Q8_0`,
|
||||
[ModelAuxSidecar.MMPROJ, ModelDraftSidecar.MTP],
|
||||
['--model-draft', draftPath('Q8_0')]
|
||||
)
|
||||
];
|
||||
|
||||
// the args badge wins: it knows the quant the listing does not
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q8_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives each quant of a repo the draft it pulls itself', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(`${REPO}:IQ4_NL`, [], ['--model-draft', draftPath('Q4_0')]),
|
||||
entry(`${REPO}:Q8_0`, [], ['--model-draft', draftPath('Q8_0')])
|
||||
];
|
||||
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:IQ4_NL`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q4_0', repo: REPO }
|
||||
]);
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q8_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows one badge for a kind two cached quants report', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(`${REPO}:IQ4_NL`, [ModelDraftSidecar.MTP]),
|
||||
entry(`${REPO}:Q8_0`, [ModelDraftSidecar.MTP])
|
||||
];
|
||||
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:IQ4_NL`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: null, repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('marks the drafts of a download on its way as pending', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(
|
||||
`${REPO}:Q4_0`,
|
||||
[ModelAuxSidecar.MMPROJ, ModelDraftSidecar.MTP],
|
||||
[],
|
||||
ServerModelStatus.DOWNLOADING
|
||||
)
|
||||
];
|
||||
|
||||
// the kinds are not on disk yet, so the row reads them as in flight
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q4_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, pending: true, quant: null, repo: REPO }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps the on-disk badge of a kind a download also pulls', () => {
|
||||
modelsStore.routerModels = [
|
||||
entry(`${REPO}:IQ4_NL`, [ModelDraftSidecar.MTP], [], ServerModelStatus.INCOMPLETE),
|
||||
entry(`${REPO}:Q8_0`, [], ['--model-draft', draftPath('Q8_0')])
|
||||
];
|
||||
|
||||
expect(modelsStore.status.getDraftSidecars(`${REPO}:Q8_0`)).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: 'Q8_0', repo: REPO }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import TestWrapper from './components/TestWrapper.svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render page without throwing', async () => {
|
||||
// Basic smoke test - page should render without throwing errors.
|
||||
// API calls are mocked in vitest-setup-client.ts.
|
||||
await render(TestWrapper);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// Regression test: opening a chat-form picker must not scroll the
|
||||
// conversation to the top. Root cause: the list's scroll effect fired
|
||||
// scrollIntoView on the initial mount, before the popover was positioned,
|
||||
// so the browser scrolled every scrollable ancestor to reveal the row.
|
||||
|
||||
import PickerListScrollHarness from './components/PickerListScrollHarness.svelte';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
describe('ChatFormPickerList mount scroll', () => {
|
||||
it('does not scroll documentElement when the picker mounts', async () => {
|
||||
const screen = render(PickerListScrollHarness);
|
||||
|
||||
await tick();
|
||||
|
||||
document.documentElement.scrollTop = document.documentElement.scrollHeight;
|
||||
await tick();
|
||||
const before = document.documentElement.scrollTop;
|
||||
|
||||
expect(before).toBeGreaterThan(0);
|
||||
|
||||
screen.component.openPicker();
|
||||
await tick();
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
await tick();
|
||||
|
||||
const after = document.documentElement.scrollTop;
|
||||
|
||||
expect(after).toBe(before);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
// Guards the phone shape of the reasoning control: a phone picks the effort in a drawer,
|
||||
// since a dropdown of small rows is not a touch target.
|
||||
|
||||
import ChatFormActionReasoning from '$lib/components/app/chat/ChatForm/ChatFormActions/ChatFormActionReasoning.svelte';
|
||||
import { ServerRole } from '$lib/enums';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { page } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const PHONE = { height: 844, width: 390 };
|
||||
const DESKTOP = { height: 900, width: 1280 };
|
||||
|
||||
describe('reasoning control on a phone', () => {
|
||||
beforeEach(async () => {
|
||||
serverStore.role = ServerRole.ROUTER;
|
||||
await page.viewport(PHONE.width, PHONE.height);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await page.viewport(DESKTOP.width, DESKTOP.height);
|
||||
});
|
||||
|
||||
it('offers the effort levels in a drawer', async () => {
|
||||
const screen = render(ChatFormActionReasoning);
|
||||
|
||||
await screen.getByRole('button', { name: 'Reasoning effort' }).click();
|
||||
|
||||
await expect.element(screen.getByRole('button', { name: /^Off$/ })).toBeVisible();
|
||||
await expect.element(screen.getByRole('button', { name: /^Max/ })).toBeVisible();
|
||||
});
|
||||
|
||||
it('closes the drawer once a level is picked', async () => {
|
||||
const screen = render(ChatFormActionReasoning);
|
||||
|
||||
await screen.getByRole('button', { name: 'Reasoning effort' }).click();
|
||||
await screen.getByRole('button', { name: 'Low' }).click();
|
||||
|
||||
await expect.poll(() => screen.getByRole('button', { name: 'Low' }).query()).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { SANDBOX_TOOL_NAME } from '$lib/constants';
|
||||
import { SandboxService } from '$lib/services/sandbox.service';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const run = (code: string, timeoutMs?: number) =>
|
||||
SandboxService.executeTool(SANDBOX_TOOL_NAME, {
|
||||
code,
|
||||
...(timeoutMs !== undefined ? { timeout_ms: timeoutMs } : {})
|
||||
});
|
||||
|
||||
describe('sandbox service', () => {
|
||||
beforeEach(async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.config = {
|
||||
...settingsStore.config,
|
||||
symbolicMathEnabled: true
|
||||
};
|
||||
});
|
||||
|
||||
it('executes plain JavaScript', async () => {
|
||||
const reply = await run('return 1 + 1;');
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> 2');
|
||||
});
|
||||
|
||||
it('exposes nerdamer for symbolic computation', async () => {
|
||||
const reply = await run("return nerdamer.diff('sin(x)/x', 'x').toString();");
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('cos(x)');
|
||||
});
|
||||
|
||||
it('computes exact rational arithmetic', async () => {
|
||||
const reply = await run("return nerdamer('1/3 + 1/6').toString();");
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> 1/2');
|
||||
});
|
||||
|
||||
it('proves a polynomial identity symbolically', async () => {
|
||||
const reply = await run(
|
||||
"return nerdamer('expand((1+x*y)^3 - (1 + 3*x*y + 3*x^2*y^2 + x^3*y^3))').toString();"
|
||||
);
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> 0');
|
||||
});
|
||||
|
||||
it('blocks network egress in the worker via CSP', async () => {
|
||||
const reply = await run(
|
||||
"try { await fetch('https://example.com/'); return 'leaked'; } catch { return 'blocked'; }"
|
||||
);
|
||||
|
||||
expect(reply.isError).toBe(false);
|
||||
expect(reply.content).toContain('=> blocked');
|
||||
});
|
||||
|
||||
it('enforces the timeout on runaway code', async () => {
|
||||
const reply = await run('while (true) {}', 500);
|
||||
|
||||
expect(reply.isError).toBe(true);
|
||||
expect(reply.content).toContain('timed out');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// Guards the rename of `showModelOrgNameInTrigger` onto `showModelOrgName`.
|
||||
// The persisted config always holds the legacy key, so a value it carries moves
|
||||
// over, while a config that already set the new key keeps its own value. The
|
||||
// legacy key is removed so it does not stay orphaned in localStorage.
|
||||
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const MODEL_ORG_NAME_KEY_MIGRATION_ID = 'model-org-name-key-v1';
|
||||
|
||||
async function seedConfig(stored: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
|
||||
const migration = MigrationService.getMigrations().find(
|
||||
(m) => m.id === MODEL_ORG_NAME_KEY_MIGRATION_ID
|
||||
);
|
||||
|
||||
await migration?.run();
|
||||
settingsStore.initialize();
|
||||
}
|
||||
|
||||
function persisted(): Record<string, unknown> {
|
||||
return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
}
|
||||
|
||||
describe('showModelOrgNameInTrigger rename', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
MigrationService.resetState();
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('carries an enabled trigger over to the selector-wide key', async () => {
|
||||
await seedConfig({ showModelOrgNameInTrigger: true });
|
||||
expect(settingsStore.config.showModelOrgName).toBe(true);
|
||||
});
|
||||
|
||||
it('carries a disabled trigger over to the selector-wide key', async () => {
|
||||
await seedConfig({ showModelOrgNameInTrigger: false });
|
||||
expect(settingsStore.config.showModelOrgName).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps an explicit user preference over the legacy key', async () => {
|
||||
await seedConfig({ showModelOrgName: false, showModelOrgNameInTrigger: true });
|
||||
expect(settingsStore.config.showModelOrgName).toBe(false);
|
||||
});
|
||||
|
||||
it('drops the legacy key from the persisted config', async () => {
|
||||
await seedConfig({ showModelOrgNameInTrigger: true });
|
||||
expect(persisted().showModelOrgNameInTrigger).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves the selector-wide key on its default when nothing is stored', async () => {
|
||||
await seedConfig({});
|
||||
expect(settingsStore.config.showModelOrgName).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY, SETTING_CONFIG_DEFAULT } from '$lib/constants';
|
||||
import { ParameterSyncService } from '$lib/services/parameter-sync.service';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import type { SettingsConfigType } from '$lib/types';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
type Primitive = string | number | boolean;
|
||||
|
||||
const KEYS = Object.keys(SETTING_CONFIG_DEFAULT).filter(
|
||||
(k) => ['string', 'number', 'boolean'].includes(typeof SETTING_CONFIG_DEFAULT[k]) && k !== 'theme'
|
||||
);
|
||||
|
||||
function divergent(key: string, base: Primitive): Primitive {
|
||||
if (typeof base === 'boolean') return !base;
|
||||
|
||||
if (typeof base === 'number') return base + 7;
|
||||
|
||||
return `user-${key}`;
|
||||
}
|
||||
|
||||
function baselineFor(key: string, base: Primitive): Primitive {
|
||||
if (typeof base === 'boolean') return !base;
|
||||
|
||||
if (typeof base === 'number') return base + 42;
|
||||
|
||||
return `admin-${key}`;
|
||||
}
|
||||
|
||||
function mockProps(uiSettings: Record<string, Primitive>) {
|
||||
Object.defineProperty(serverStore, 'props', {
|
||||
configurable: true,
|
||||
get: () =>
|
||||
({
|
||||
default_generation_settings: { params: { temperature: 0.8 } },
|
||||
ui_settings: uiSettings
|
||||
}) as unknown as typeof serverStore.props
|
||||
});
|
||||
}
|
||||
|
||||
const setUser = (key: string, value: Primitive) =>
|
||||
settingsStore.updateConfig(key as keyof SettingsConfigType, value as never);
|
||||
const current = (key: string) => (settingsStore.config as Record<string, unknown>)[key];
|
||||
|
||||
describe('registry-wide invariants', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
it('I1: no load ever modifies a stored user value, for any key of any type', () => {
|
||||
settingsStore.initialize();
|
||||
const userValues: Record<string, Primitive> = {};
|
||||
|
||||
for (const key of KEYS) {
|
||||
userValues[key] = divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
setUser(key, userValues[key]);
|
||||
}
|
||||
|
||||
// simulated F5 + adverse admin baseline on every key, synced twice
|
||||
settingsStore.initialize();
|
||||
const adverse: Record<string, Primitive> = {};
|
||||
|
||||
for (const key of KEYS)
|
||||
adverse[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
mockProps(adverse);
|
||||
settingsStore.syncWithServerDefaults();
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
for (const key of KEYS) {
|
||||
expect(current(key), key).toBe(userValues[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it('first visit: the baseline applies for every key, false and 0 included', () => {
|
||||
settingsStore.initialize();
|
||||
const baseline: Record<string, Primitive> = {};
|
||||
|
||||
for (const key of KEYS)
|
||||
baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
mockProps(baseline);
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
for (const key of KEYS) {
|
||||
if (ParameterSyncService.canSyncParameter(key)) continue;
|
||||
|
||||
expect(current(key), key).toBe(baseline[key]);
|
||||
}
|
||||
});
|
||||
|
||||
it('I3: Reset returns every key to baseline when defined, factory default otherwise', () => {
|
||||
settingsStore.initialize();
|
||||
for (const key of KEYS) setUser(key, divergent(key, SETTING_CONFIG_DEFAULT[key] as Primitive));
|
||||
|
||||
const baseline: Record<string, Primitive> = {};
|
||||
|
||||
KEYS.filter((_, i) => i % 2 === 0).forEach((key) => {
|
||||
baseline[key] = baselineFor(key, SETTING_CONFIG_DEFAULT[key] as Primitive);
|
||||
});
|
||||
mockProps(baseline);
|
||||
|
||||
settingsStore.forceSyncWithServerDefaults();
|
||||
|
||||
for (const key of KEYS) {
|
||||
if (key in baseline) {
|
||||
expect(current(key), key).toBe(baseline[key]);
|
||||
} else if (ParameterSyncService.canSyncParameter(key)) {
|
||||
expect(current(key), key).toBe('');
|
||||
} else {
|
||||
expect(current(key), key).toBe(SETTING_CONFIG_DEFAULT[key]);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Guards the unfolding of `renderContentAsRawText` back onto the two
|
||||
// per-surface render keys. The single toggle carried user content and
|
||||
// thinking at once, so only the user key is restored from it and thinking
|
||||
// returns to its own default. The toggle is removed from the persisted
|
||||
// config so it does not stay orphaned in localStorage.
|
||||
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { MigrationService } from '$lib/services/migration.service';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const RENDER_KEYS_MIGRATION_ID = 'render-keys-unfold-v1';
|
||||
|
||||
async function seedConfig(stored: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(stored));
|
||||
|
||||
const migration = MigrationService.getMigrations().find((m) => m.id === RENDER_KEYS_MIGRATION_ID);
|
||||
|
||||
await migration?.run();
|
||||
settingsStore.initialize();
|
||||
}
|
||||
|
||||
function persisted(): Record<string, unknown> {
|
||||
return JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
}
|
||||
|
||||
describe('renderContentAsRawText unfolding', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
MigrationService.resetState();
|
||||
settingsStore.initialize();
|
||||
});
|
||||
|
||||
it('maps raw text to user content as plain text', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true });
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(false);
|
||||
});
|
||||
|
||||
it('maps markdown to user content as markdown', async () => {
|
||||
await seedConfig({ renderContentAsRawText: false });
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
|
||||
});
|
||||
|
||||
it('leaves thinking on its own default', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true });
|
||||
expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps an explicit user preference over the toggle', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true, renderUserContentAsMarkdown: true });
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
|
||||
});
|
||||
|
||||
it('drops the toggle from the persisted config', async () => {
|
||||
await seedConfig({ renderContentAsRawText: true });
|
||||
expect(persisted().renderContentAsRawText).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves both surfaces on markdown when nothing is stored', async () => {
|
||||
await seedConfig({});
|
||||
expect(settingsStore.config.renderUserContentAsMarkdown).toBe(true);
|
||||
expect(settingsStore.config.renderThinkingAsMarkdown).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
// A file tool call must show what it streamed even when it carries no
|
||||
// path: the path only names the file in the block title, so a call that
|
||||
// streams its body first (or a same-named tool with other arg names) used
|
||||
// to render an empty block.
|
||||
|
||||
import ChatMessageToolCallBlockEditFile from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockEditFile.svelte';
|
||||
import ChatMessageToolCallBlockWriteFile from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/ChatMessageToolCallBlockWriteFile.svelte';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const WRITE_FILE_BODY = 'body streamed without a path';
|
||||
const EDIT_FILE_NEW_TEXT = 'replacement text';
|
||||
|
||||
function makeSection(toolName: BuiltInTool, toolArgs: string): AgenticSection {
|
||||
return {
|
||||
content: '',
|
||||
toolArgs,
|
||||
toolName,
|
||||
toolResult: undefined,
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
};
|
||||
}
|
||||
|
||||
describe('file tool calls render their body without a path', () => {
|
||||
it('write_file shows the content it streamed', async () => {
|
||||
render(ChatMessageToolCallBlockWriteFile, {
|
||||
isStreaming: true,
|
||||
open: true,
|
||||
section: makeSection(BuiltInTool.SERVER_WRITE_FILE, `{"content":"${WRITE_FILE_BODY}"`)
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(WRITE_FILE_BODY);
|
||||
});
|
||||
|
||||
it('edit_file shows the diff it streamed', async () => {
|
||||
render(ChatMessageToolCallBlockEditFile, {
|
||||
isStreaming: true,
|
||||
open: true,
|
||||
section: makeSection(
|
||||
BuiltInTool.SERVER_EDIT_FILE,
|
||||
`{"edits":[{"old_text":"stale text","new_text":"${EDIT_FILE_NEW_TEXT}"}]}`
|
||||
)
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain(EDIT_FILE_NEW_TEXT);
|
||||
expect(document.body.textContent).toContain('stale text');
|
||||
});
|
||||
|
||||
it('write_file falls back to the raw args when no known content key is present', async () => {
|
||||
render(ChatMessageToolCallBlockWriteFile, {
|
||||
isStreaming: false,
|
||||
open: true,
|
||||
section: makeSection(BuiltInTool.SERVER_WRITE_FILE, '{"contents":"x"}')
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.body.textContent).toContain('contents');
|
||||
});
|
||||
|
||||
it('write_file renders an empty code block for a finished empty write', async () => {
|
||||
// content of '' used to fall through to the raw-args fallback
|
||||
const section = makeSection(BuiltInTool.SERVER_WRITE_FILE, '{"content":""}');
|
||||
|
||||
section.toolResult = '{"result":"File written","bytes":0}';
|
||||
|
||||
render(ChatMessageToolCallBlockWriteFile, { isStreaming: false, open: true, section });
|
||||
|
||||
await tick();
|
||||
|
||||
expect(document.querySelector('pre code.hljs')).toBeTruthy();
|
||||
expect(document.body.textContent).not.toContain('"content"');
|
||||
expect(document.body.textContent).toMatch(/0\s*bytes/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,212 @@
|
||||
// Guards the two Settings > Display toggles that drive the default expansion of
|
||||
// a tool call block.
|
||||
//
|
||||
// "Show tool call in progress" mirrors "Show thought in progress": a call is
|
||||
// expanded by default only while it is in flight and folds back on its own once
|
||||
// the result lands. "Always show tool call content" is the one that keeps
|
||||
// finished calls open and wins on its own. A manual toggle always overrides the
|
||||
// default, in both directions.
|
||||
|
||||
import ChatMessageAgenticContent from '$lib/components/app/chat/ChatMessages/ChatMessageAgenticContent.svelte';
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { settingsStore } from '$lib/stores';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { tick } from 'svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { userEvent } from 'vitest/browser';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
|
||||
const TOOL_NAME = 'custom_tool';
|
||||
const TOOL_CALL_ID = 'call-1';
|
||||
// Both markers sit inside the collapsible body, which CollapsibleContentBlock
|
||||
// only mounts while open - so their presence is exactly "the block is expanded".
|
||||
const PENDING_BODY = 'Waiting for result...';
|
||||
const RESULT_BODY = 'marker-tool-output';
|
||||
const TRIGGER_SELECTOR = '[data-slot="collapsible-trigger"]';
|
||||
|
||||
function assistantMessage(): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: 'assistant-1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: '{"path":"a.txt"}', name: TOOL_NAME }, id: TOOL_CALL_ID }
|
||||
]),
|
||||
type: MessageType.TEXT
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultMessage(): DatabaseMessage {
|
||||
return {
|
||||
...assistantMessage(),
|
||||
content: RESULT_BODY,
|
||||
id: 'tool-1',
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: TOOL_CALL_ID
|
||||
};
|
||||
}
|
||||
|
||||
function setSettings({
|
||||
alwaysShow,
|
||||
inProgress
|
||||
}: {
|
||||
alwaysShow: boolean;
|
||||
inProgress: boolean;
|
||||
}): void {
|
||||
settingsStore.updateMultipleConfig({
|
||||
[SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT]: alwaysShow,
|
||||
[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]: inProgress
|
||||
});
|
||||
}
|
||||
|
||||
function textOf(container: Element): string {
|
||||
return container.textContent ?? '';
|
||||
}
|
||||
|
||||
async function collapseBlock(screen: { container: Element }): Promise<void> {
|
||||
const trigger = screen.container.querySelector(TRIGGER_SELECTOR);
|
||||
|
||||
if (!(trigger instanceof HTMLButtonElement)) throw new Error('trigger not found');
|
||||
|
||||
await userEvent.click(trigger);
|
||||
await tick();
|
||||
}
|
||||
|
||||
describe('Show tool call in progress', () => {
|
||||
beforeEach(() => {
|
||||
setSettings({ alwaysShow: false, inProgress: false });
|
||||
});
|
||||
|
||||
it('expands a call that is still executing when the setting is on', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(container)).toContain(PENDING_BODY);
|
||||
});
|
||||
|
||||
it('keeps the call collapsed while executing when the setting is off', async () => {
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// The header is still there, only the body stays hidden.
|
||||
expect(textOf(container)).toContain(TOOL_NAME);
|
||||
expect(textOf(container)).not.toContain(PENDING_BODY);
|
||||
});
|
||||
|
||||
it('folds the call back when its result arrives', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// Open while it waits for the result.
|
||||
expect(textOf(screen.container)).toContain(PENDING_BODY);
|
||||
|
||||
// Same block, now finished: it must close on its own, no toggle given.
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: [toolResultMessage()]
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).toContain(TOOL_NAME);
|
||||
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
|
||||
expect(textOf(screen.container)).not.toContain(RESULT_BODY);
|
||||
});
|
||||
|
||||
it('keeps the manual choice once the result arrives', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
// Collapsed by hand while running, so the result must not reopen it.
|
||||
await collapseBlock(screen);
|
||||
|
||||
screen.rerender({
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: [toolResultMessage()]
|
||||
});
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).toContain(TOOL_NAME);
|
||||
expect(textOf(screen.container)).not.toContain(RESULT_BODY);
|
||||
});
|
||||
|
||||
it('still expands a finished call when "always show" is on', async () => {
|
||||
setSettings({ alwaysShow: true, inProgress: false });
|
||||
|
||||
const { container } = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: [toolResultMessage()]
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(container)).toContain(RESULT_BODY);
|
||||
});
|
||||
|
||||
it('lets a manual collapse win over the in-progress default', async () => {
|
||||
setSettings({ alwaysShow: false, inProgress: true });
|
||||
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).toContain(PENDING_BODY);
|
||||
|
||||
await collapseBlock(screen);
|
||||
|
||||
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
|
||||
});
|
||||
|
||||
it('lets a manual expand win over the collapsed default', async () => {
|
||||
const screen = render(ChatMessageAgenticContent, {
|
||||
isStreaming: true,
|
||||
message: assistantMessage(),
|
||||
toolMessages: []
|
||||
});
|
||||
|
||||
await tick();
|
||||
|
||||
expect(textOf(screen.container)).not.toContain(PENDING_BODY);
|
||||
|
||||
await collapseBlock(screen);
|
||||
|
||||
expect(textOf(screen.container)).toContain(PENDING_BODY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY } from '$lib/constants';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { settingsStore } from '$lib/stores/settings/index.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
function mockProps(
|
||||
uiSettings: Record<string, string | number | boolean>,
|
||||
params: Record<string, number> = { temperature: 0.8 }
|
||||
) {
|
||||
Object.defineProperty(serverStore, 'props', {
|
||||
configurable: true,
|
||||
get: () =>
|
||||
({
|
||||
default_generation_settings: { params },
|
||||
ui_settings: uiSettings
|
||||
}) as unknown as typeof serverStore.props
|
||||
});
|
||||
}
|
||||
|
||||
describe('server ui_settings application semantics', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.removeItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
});
|
||||
|
||||
it('applies the admin defaults once for a new user', () => {
|
||||
settingsStore.initialize();
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('applies the admin defaults when /props carries no sampling defaults', () => {
|
||||
settingsStore.initialize();
|
||||
// router mode: the main instance answers /props with empty params
|
||||
mockProps({ theme: 'dark' }, {});
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('never reapplies on later loads: the user config diverges freely', () => {
|
||||
settingsStore.initialize();
|
||||
settingsStore.updateConfig('theme', 'light');
|
||||
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||
|
||||
// simulated F5: config now exists in localStorage
|
||||
settingsStore.initialize();
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('light');
|
||||
expect(settingsStore.config.apiKey).toBe('sk-user-key');
|
||||
const stored = JSON.parse(localStorage.getItem(CONFIG_LOCALSTORAGE_KEY) ?? '{}');
|
||||
|
||||
expect(stored.apiKey).toBe('sk-user-key');
|
||||
});
|
||||
|
||||
it('keeps a value the user sets before the baseline is reachable', () => {
|
||||
settingsStore.initialize();
|
||||
// the splash is the only way in when the server runs with --api-key,
|
||||
// so the first user write lands before the first successful /props
|
||||
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||
mockProps({ apiKey: 'admin-placeholder', theme: 'dark' });
|
||||
|
||||
settingsStore.syncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.apiKey).toBe('sk-user-key');
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
});
|
||||
|
||||
it('Reset to Default reapplies the full baseline, api key included', () => {
|
||||
settingsStore.initialize();
|
||||
settingsStore.updateConfig('theme', 'light');
|
||||
settingsStore.updateConfig('apiKey', 'sk-user-key');
|
||||
mockProps({ apiKey: '', theme: 'dark' });
|
||||
|
||||
settingsStore.forceSyncWithServerDefaults();
|
||||
|
||||
expect(settingsStore.config.theme).toBe('dark');
|
||||
expect(settingsStore.config.apiKey).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncable scope (spec section 4)', () => {
|
||||
it('sampling params keep their live server twin, ui settings carry none', async () => {
|
||||
const { ParameterSyncService } = await import('$lib/services/parameter-sync.service');
|
||||
|
||||
expect(ParameterSyncService.canSyncParameter('temperature')).toBe(true);
|
||||
expect(ParameterSyncService.canSyncParameter('samplers')).toBe(true);
|
||||
expect(ParameterSyncService.canSyncParameter('theme')).toBe(false);
|
||||
expect(ParameterSyncService.canSyncParameter('systemMessage')).toBe(false);
|
||||
expect(ParameterSyncService.canSyncParameter('apiKey')).toBe(false);
|
||||
expect(ParameterSyncService.canSyncParameter('customCss')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Pins the contract that makes streaming cheap: updateMessageAtIndex mutates the
|
||||
// existing message object instead of replacing it.
|
||||
//
|
||||
// Replacing it (`{ ...old, ...updates }`) changes the array slot, which
|
||||
// invalidates every consumer that merely walks the list - ChatMessages'
|
||||
// `displayMessages` rebuilds entries for EVERY message in the conversation. That
|
||||
// made per-token cost scale with conversation length (1.26ms at 1 prior message
|
||||
// -> 3.07ms at 40). Mutating in place keeps it flat.
|
||||
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseMessage } from '$lib/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeMessage(id: string): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text'
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
describe('conversationsStore.updateMessageAtIndex', () => {
|
||||
it('mutates in place, preserving object identity', () => {
|
||||
conversationsStore.activeMessages = [makeMessage('a'), makeMessage('b')];
|
||||
const before = conversationsStore.activeMessages[1];
|
||||
|
||||
conversationsStore.updateMessageAtIndex(1, { content: 'hello' });
|
||||
|
||||
expect(conversationsStore.activeMessages[1].content).toBe('hello');
|
||||
expect(conversationsStore.activeMessages[1]).toBe(before);
|
||||
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
|
||||
it('leaves other messages and unrelated fields untouched', () => {
|
||||
conversationsStore.activeMessages = [makeMessage('a'), makeMessage('b')];
|
||||
const untouched = conversationsStore.activeMessages[0];
|
||||
|
||||
conversationsStore.updateMessageAtIndex(1, { content: 'x', model: 'm1' });
|
||||
|
||||
expect(conversationsStore.activeMessages[0]).toBe(untouched);
|
||||
expect(conversationsStore.activeMessages[0].content).toBe('');
|
||||
expect(conversationsStore.activeMessages[1].model).toBe('m1');
|
||||
expect(conversationsStore.activeMessages[1].id).toBe('b');
|
||||
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
|
||||
it('is a no-op for an index of -1 or out of range', () => {
|
||||
conversationsStore.activeMessages = [makeMessage('a')];
|
||||
|
||||
expect(() => conversationsStore.updateMessageAtIndex(-1, { content: 'x' })).not.toThrow();
|
||||
expect(() => conversationsStore.updateMessageAtIndex(9, { content: 'x' })).not.toThrow();
|
||||
expect(conversationsStore.activeMessages[0].content).toBe('');
|
||||
|
||||
conversationsStore.activeMessages = [];
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('PWA Service Worker', () => {
|
||||
test('service worker is registered', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const swURL = await page.evaluate(async () => {
|
||||
const registration = await Promise.race([
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - type inference differs from browser runtime
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Service worker registration failed: timeout')), 15000)
|
||||
)
|
||||
]);
|
||||
|
||||
// @ts-expect-error registration is of type unknown
|
||||
return registration.active?.scriptURL;
|
||||
});
|
||||
|
||||
expect(swURL).toBeTruthy();
|
||||
expect(swURL).toContain('/sw.js');
|
||||
});
|
||||
|
||||
test('service worker has precache configured', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
});
|
||||
|
||||
const swActive = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
|
||||
return reg.active?.scriptURL ?? null;
|
||||
});
|
||||
|
||||
expect(swActive).toBeTruthy();
|
||||
|
||||
const swResponse = await page.request.get(swActive!);
|
||||
const swContent = await swResponse.text();
|
||||
|
||||
// Precache contains SvelteKit content-hashed bundle paths
|
||||
expect(swContent).toMatch(/"_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"/);
|
||||
expect(swContent).toMatch(/"_app\/immutable\/assets\/bundle\.[a-zA-Z0-9_-]+\.css"/);
|
||||
expect(swContent).toMatch(/"manifest\.webmanifest"/);
|
||||
expect(swContent).toMatch(/"_app\/version\.json"/);
|
||||
// NavigationRoute is intentionally absent — server API endpoints
|
||||
// (e.g. /slots, /models) must not be intercepted by the PWA and
|
||||
// should return JSON directly from the server.
|
||||
expect(swContent).not.toMatch(/NavigationRoute/);
|
||||
expect(swContent).toMatch(/api-cache/);
|
||||
});
|
||||
|
||||
test('offline mode - page loads when offline after caching', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const offlinePage = await context.newPage();
|
||||
|
||||
await offlinePage.goto('/');
|
||||
await offlinePage.waitForLoadState('networkidle');
|
||||
|
||||
await offlinePage.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
});
|
||||
|
||||
await offlinePage.waitForTimeout(2000);
|
||||
|
||||
await context.setOffline(true);
|
||||
await offlinePage.goto('/');
|
||||
|
||||
const bodyText = await offlinePage.locator('body').textContent();
|
||||
|
||||
expect(bodyText).toBeTruthy();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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', './');
|
||||
expect(manifest).toHaveProperty('display', 'standalone');
|
||||
expect(manifest.icons).toBeTruthy();
|
||||
expect(manifest.icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
// SvelteKit outputs content-hashed bundle names in _app/immutable/
|
||||
expect(html).toMatch(/href="(\.\/|\/)_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"/);
|
||||
expect(html).toMatch(/href="(\.\/|\/)_app\/immutable\/assets\/bundle\.[a-zA-Z0-9_-]+\.css"/);
|
||||
expect(html).toMatch(/import\("(\.\/|\/)_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,255 @@
|
||||
<script lang="ts" module>
|
||||
import { JOKES_TABLE_MD, WIDE_CELL_TABLE_MD } from './fixtures/mobile-tables.js';
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte';
|
||||
import type { ChatMessageActions } from '$lib/types';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: ChatMessage,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
title: 'Components/ChatScreen/ChatMessage'
|
||||
});
|
||||
|
||||
const chatActions: ChatMessageActions = {
|
||||
continueAssistantMessage: () => {},
|
||||
copy: () => {},
|
||||
delete: () => {},
|
||||
editUserMessagePreserveResponses: () => {},
|
||||
editWithBranching: () => {},
|
||||
editWithReplacement: () => {},
|
||||
forkConversation: () => {},
|
||||
navigateToSibling: () => {},
|
||||
regenerateWithBranching: () => {}
|
||||
};
|
||||
|
||||
// Mock messages for different scenarios
|
||||
const userMessage: DatabaseMessage = {
|
||||
children: [],
|
||||
content: 'What is the meaning of life, the universe, and everything?',
|
||||
convId: 'conv-1',
|
||||
id: '1',
|
||||
parent: '',
|
||||
role: 'user',
|
||||
thinking: '',
|
||||
timestamp: Date.now() - 1000 * 60 * 5,
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
const assistantMessage: DatabaseMessage = {
|
||||
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: '',
|
||||
timestamp: Date.now() - 1000 * 60 * 3,
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
const assistantWithTable: DatabaseMessage = {
|
||||
children: [],
|
||||
content: `${JOKES_TABLE_MD}\n\n${WIDE_CELL_TABLE_MD}`,
|
||||
convId: 'conv-1',
|
||||
id: '7',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
timestamp: Date.now() - 1000 * 60 * 3,
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
const assistantWithReasoning: DatabaseMessage = {
|
||||
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.",
|
||||
timestamp: Date.now() - 1000 * 60 * 2,
|
||||
type: 'message'
|
||||
};
|
||||
const rawOutputMessage: DatabaseMessage = {
|
||||
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: '',
|
||||
timestamp: Date.now() - 1000 * 60,
|
||||
type: 'message'
|
||||
};
|
||||
|
||||
let processingMessage = $state({
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: '4',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
timestamp: 0, // No timestamp = processing
|
||||
type: 'message'
|
||||
});
|
||||
|
||||
let streamingMessage = $state({
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'conv-1',
|
||||
id: '5',
|
||||
parent: '1',
|
||||
role: 'assistant',
|
||||
thinking: '',
|
||||
timestamp: 0, // No timestamp = streaming
|
||||
type: 'message'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
message: userMessage
|
||||
}}
|
||||
name="User"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: assistantMessage
|
||||
}}
|
||||
name="Assistant"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: assistantWithTable
|
||||
}}
|
||||
name="AssistantWithTable"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: assistantWithReasoning
|
||||
}}
|
||||
name="AssistantWithReasoning"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: rawOutputMessage
|
||||
}}
|
||||
name="RawLlmOutput"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
message: streamingMessage
|
||||
}}
|
||||
asChild
|
||||
name="WithReasoningContent"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.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
|
||||
streamingMessage.thinking = reasoningChunk;
|
||||
|
||||
i += chunkSize;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
const regularText =
|
||||
"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
|
||||
streamingMessage.content = contentChunk;
|
||||
|
||||
i += chunkSize;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
streamingMessage.timestamp = Date.now();
|
||||
}}
|
||||
>
|
||||
<div class="w-[56rem]">
|
||||
<ChatMessage {chatActions} message={streamingMessage} />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
message: processingMessage
|
||||
}}
|
||||
name="Processing"
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings/index.svelte');
|
||||
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
// Import the chat store to simulate loading state
|
||||
const { chatStore } = await import('$lib/stores/chat/index.svelte');
|
||||
|
||||
// Set loading state to true to trigger the processing UI
|
||||
chatStore.isLoading = true;
|
||||
|
||||
// Simulate the processing state hook behavior
|
||||
// This will show the "Generating..." text and parameter details
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script lang="ts" module>
|
||||
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';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
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',
|
||||
preview: jpgAsset,
|
||||
size: 44891,
|
||||
type: 'image/jpeg'
|
||||
},
|
||||
{
|
||||
file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' }),
|
||||
id: '2',
|
||||
name: 'hf-logo.svg',
|
||||
preview: svgAsset,
|
||||
size: 1234,
|
||||
type: 'image/svg+xml'
|
||||
},
|
||||
{
|
||||
file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' }),
|
||||
id: '3',
|
||||
name: 'example.pdf',
|
||||
size: 351048,
|
||||
type: 'application/pdf'
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
name="Default"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const textarea = await canvas.findByRole('textbox');
|
||||
const submitButton = await canvas.findByRole('button', { name: 'Send' });
|
||||
|
||||
// Expect the input to be focused after the component is mounted
|
||||
await expect(textarea).toHaveFocus();
|
||||
|
||||
// Expect the submit button to be disabled
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
const text = 'What is the meaning of life?';
|
||||
|
||||
await userEvent.clear(textarea);
|
||||
await userEvent.type(textarea, text);
|
||||
|
||||
await expect(textarea).toHaveValue(text);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
|
||||
await expect(fileInput).not.toHaveAttribute('accept');
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} name="Loading" />
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
uploadedFiles: fileAttachments
|
||||
}}
|
||||
name="FileAttachments"
|
||||
play={async ({ canvas }) => {
|
||||
const jpgAttachment = canvas.getByAltText('1.jpg');
|
||||
const svgAttachment = canvas.getByAltText('hf-logo.svg');
|
||||
const pdfFileExtension = canvas.getByText('PDF');
|
||||
const pdfAttachment = canvas.getByText('example.pdf');
|
||||
const pdfSize = canvas.getByText('342.82 KB');
|
||||
|
||||
await expect(jpgAttachment).toBeInTheDocument();
|
||||
await expect(jpgAttachment).toHaveAttribute('src', jpgAsset);
|
||||
|
||||
await expect(svgAttachment).toBeInTheDocument();
|
||||
await expect(svgAttachment).toHaveAttribute('src', svgAsset);
|
||||
|
||||
await expect(pdfFileExtension).toBeInTheDocument();
|
||||
await expect(pdfAttachment).toBeInTheDocument();
|
||||
await expect(pdfSize).toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Introduction" />
|
||||
|
||||
# llama.cpp Web UI
|
||||
|
||||
Welcome to the **llama-ui** component library! This Storybook showcases the components used in the modern web interface for the llama-server.
|
||||
|
||||
## 🚀 About This Project
|
||||
|
||||
Llama UI is a modern web interface for the llama-server, built with SvelteKit and ShadCN UI. Features include:
|
||||
|
||||
- **Real-time chat conversations** with AI assistants
|
||||
- **Multi-conversation management** with persistent storage
|
||||
- **Advanced parameter tuning** for model behavior
|
||||
- **File upload support** for multimodal interactions
|
||||
- **Responsive design** that works on desktop and mobile
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
The UI is built using:
|
||||
|
||||
- **SvelteKit** - Modern web framework with excellent performance
|
||||
- **Tailwind CSS** - Utility-first CSS framework for rapid styling
|
||||
- **ShadCN/UI** - High-quality, accessible component library
|
||||
- **Lucide Icons** - Beautiful, consistent icon set
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
This Storybook serves as both documentation and a development environment for the UI components. Each story demonstrates:
|
||||
|
||||
- **Component variations** - Different states and configurations
|
||||
- **Interactive examples** - Live components you can interact with
|
||||
- **Usage patterns** - How components work together
|
||||
- **Styling consistency** - Unified design language
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
To explore the components:
|
||||
|
||||
1. **Browse the sidebar** to see all available components
|
||||
2. **Click on stories** to see different component states
|
||||
3. **Use the controls panel** to interact with component props
|
||||
4. **Check the docs tab** for detailed component information
|
||||
@@ -0,0 +1,139 @@
|
||||
<script lang="ts" module>
|
||||
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 { 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({
|
||||
component: MarkdownContent,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
title: 'Components/MarkdownContent'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: EMPTY_MD }} name="Empty" />
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: AI_TUTORIAL_MD }}
|
||||
name="AI Tutorial"
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: API_DOCS_MD }}
|
||||
name="API Documentation"
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: BLOG_POST_MD }}
|
||||
name="Technical Blog"
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: DATA_ANALYSIS_MD }}
|
||||
name="Data Analysis"
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: README_MD }}
|
||||
name="README file"
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', content: MATH_FORMULAS_MD }}
|
||||
name="Math Formulas"
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
content: `# URL Links Test
|
||||
|
||||
Here are some example URLs that should open in new tabs:
|
||||
|
||||
- [Hugging Face Homepage](https://huggingface.co)
|
||||
- [GitHub Repository](https://github.com/ggml-org/llama.cpp)
|
||||
- [OpenAI Website](https://openai.com)
|
||||
- [Google Search](https://www.google.com)
|
||||
|
||||
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.`
|
||||
}}
|
||||
name="URL Links"
|
||||
play={async (context) => {
|
||||
const { canvasElement } = context;
|
||||
|
||||
// Wait for component to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Find all links in the rendered content
|
||||
const links = (canvasElement as HTMLElement).querySelectorAll(
|
||||
'a[href]'
|
||||
) as NodeListOf<HTMLAnchorElement>;
|
||||
const linkList = Array.from(links) as HTMLAnchorElement[];
|
||||
|
||||
// Test that we have the expected number of links
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
|
||||
// Test each link for proper attributes
|
||||
links.forEach((link: HTMLAnchorElement) => {
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Test that external links have proper security attributes
|
||||
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
||||
expect(link.getAttribute('target')).toBe('_blank');
|
||||
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
// Test specific links exist
|
||||
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');
|
||||
|
||||
// Test inline links (auto-linked URLs)
|
||||
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`);
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ModelsManagerModelsTable from '$lib/components/app/models/ModelsManager/ModelsManagerModelsTable.svelte';
|
||||
import type {
|
||||
ModelQuantGroup,
|
||||
ModelsTableGroup
|
||||
} from '$lib/components/app/models/ModelsManager/utils';
|
||||
import { MODELS_TABLE_GROUP_LABELS } from '$lib/constants';
|
||||
import { ModelGroupKind, ModelsTableGroupKind } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
parameters: {
|
||||
layout: 'fullscreen'
|
||||
},
|
||||
title: 'Components/ModelsManager'
|
||||
});
|
||||
|
||||
const option = (model: string, name: string, contextLength?: number): ModelOption => ({
|
||||
capabilities: [],
|
||||
contextLength,
|
||||
id: model,
|
||||
model,
|
||||
name
|
||||
});
|
||||
|
||||
const repo = (base: ModelOption, quants: ModelOption[]): ModelQuantGroup => ({
|
||||
base,
|
||||
key: base.model.split(':')[0],
|
||||
kind: ModelGroupKind.QUANTS,
|
||||
quants
|
||||
});
|
||||
|
||||
const qwen = option('ggml-org/Qwen3.8-27B-GGUF:Q4_K_M', 'Qwen3.8-27B', 131_072);
|
||||
const qwenQ8 = option('ggml-org/Qwen3.8-27B-GGUF:Q8_0', 'Qwen3.8-27B', 131_072);
|
||||
const llama = option('unsloth/Llama-3.2-3B-GGUF:Q4_K_M', 'Llama-3.2-3B', 8192);
|
||||
const hiddenRepo = option('org/Experimental-MoE-GGUF:Q4_K_M', 'Experimental-MoE', 32_768);
|
||||
|
||||
const section = (
|
||||
kind: ModelsTableGroupKind,
|
||||
items: ModelQuantGroup[],
|
||||
key = kind
|
||||
): ModelsTableGroup => ({
|
||||
items,
|
||||
key,
|
||||
kind,
|
||||
label: MODELS_TABLE_GROUP_LABELS[kind]
|
||||
});
|
||||
|
||||
// the rows read the store for favorites, hidden ids and the load status
|
||||
modelsStore.models = [qwen, qwenQ8, llama, hiddenRepo];
|
||||
modelsStore.favoriteModelIds = new Set([llama.model]);
|
||||
modelsStore.hiddenModelIds = new Set([hiddenRepo.id]);
|
||||
modelsStore.routerModels = [
|
||||
{
|
||||
created: 0,
|
||||
id: qwen.model,
|
||||
in_cache: true,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
path: `/models/${qwen.model}`,
|
||||
status: { value: 'loaded' }
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let selectedId = $state<string | null>(null);
|
||||
|
||||
const isFavorite = (picked: ModelOption) => modelsStore.favoriteModelIds.has(picked.model);
|
||||
</script>
|
||||
|
||||
<Story name="Manager">
|
||||
<div class="h-[36rem] bg-background p-4">
|
||||
<ModelsManagerModelsTable
|
||||
groups={[
|
||||
section(ModelsTableGroupKind.LOADED, [repo(qwen, [qwen])]),
|
||||
section(ModelsTableGroupKind.FAVORITES, [repo(llama, [llama])]),
|
||||
section(ModelsTableGroupKind.LOCAL, [repo(qwen, [qwen, qwenQ8])]),
|
||||
section(ModelsTableGroupKind.HIDDEN, [repo(hiddenRepo, [hiddenRepo])])
|
||||
]}
|
||||
{isFavorite}
|
||||
onSelect={(picked: ModelOption) => (selectedId = picked.id)}
|
||||
{selectedId}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="QuantsFolded">
|
||||
<div class="h-[24rem] bg-background p-4">
|
||||
<ModelsManagerModelsTable
|
||||
groups={[section(ModelsTableGroupKind.LOCAL, [repo(qwen, [qwen, qwenQ8])])]}
|
||||
{isFavorite}
|
||||
onSelect={(picked: ModelOption) => (selectedId = picked.id)}
|
||||
{selectedId}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="Empty">
|
||||
<div class="h-[24rem] bg-background p-4">
|
||||
<ModelsManagerModelsTable
|
||||
groups={[]}
|
||||
{isFavorite}
|
||||
onSelect={(picked: ModelOption) => (selectedId = picked.id)}
|
||||
{selectedId}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,268 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ModelsSelectorList from '$lib/components/app/models/ModelsSelector/ModelsSelectorList.svelte';
|
||||
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelector/ModelsSelectorOption.svelte';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/utils';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
title: 'Components/ModelsSelector'
|
||||
});
|
||||
|
||||
const mockModel = (id: string, name: string, orgName?: string, tags?: string[]): ModelOption => ({
|
||||
capabilities: [],
|
||||
id,
|
||||
model: orgName ? `${orgName}/${name}` : name,
|
||||
name,
|
||||
parsedId: {
|
||||
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',
|
||||
path: `/models/${modelName}`,
|
||||
status: { value: status }
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let selectedModel = $state<string | null>(null);
|
||||
let activeId = $state<string | null>(null);
|
||||
|
||||
function mockModelsStore() {
|
||||
modelsStore.favoriteModelIds = new Set(['qwen2.5-7b', 'llama3.2-3b']);
|
||||
|
||||
// Mock router models with various statuses for ModelLoadedStates story
|
||||
modelsStore.routerModels = [
|
||||
mockRouterEntry('meta/Model (loading)', ServerModelStatus.LOADING),
|
||||
mockRouterEntry('meta/Model (loaded)', ServerModelStatus.LOADED),
|
||||
mockRouterEntry('meta/Model (sleeping)', ServerModelStatus.SLEEPING),
|
||||
mockRouterEntry('meta/Model (failed)', ServerModelStatus.FAILED)
|
||||
];
|
||||
}
|
||||
|
||||
mockModelsStore();
|
||||
|
||||
const loadedModels: ModelItem[] = [
|
||||
{ option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta') },
|
||||
{ option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai') }
|
||||
];
|
||||
|
||||
const favoriteModels: ModelItem[] = [
|
||||
{ option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen') },
|
||||
{ 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'])
|
||||
},
|
||||
{ option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google') },
|
||||
{ option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft') },
|
||||
{ option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']) },
|
||||
{ option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel') }
|
||||
];
|
||||
|
||||
const groupedOptions: GroupedModelOptions = {
|
||||
available: [
|
||||
{
|
||||
items: [availableModels[0]],
|
||||
orgName: 'deepseek'
|
||||
},
|
||||
{
|
||||
items: [availableModels[1]],
|
||||
orgName: 'google'
|
||||
},
|
||||
{
|
||||
items: [availableModels[2]],
|
||||
orgName: 'microsoft'
|
||||
},
|
||||
{
|
||||
items: [availableModels[3]],
|
||||
orgName: 'codellama'
|
||||
},
|
||||
{
|
||||
items: [availableModels[4]],
|
||||
orgName: 'intel'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
function handleSelect(modelId: string) {
|
||||
const opt = [...loadedModels, ...favoriteModels, ...availableModels].find(
|
||||
(m) => m.option.id === modelId
|
||||
);
|
||||
|
||||
if (opt) {
|
||||
selectedModel = opt.option.model;
|
||||
activeId = modelId;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Story name="List">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
{activeId}
|
||||
currentModel={selectedModel}
|
||||
favorites={favoriteModels}
|
||||
groups={groupedOptions}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="SingleLoaded">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
activeId={null}
|
||||
currentModel={null}
|
||||
groups={{ available: [] }}
|
||||
loaded={[loadedModels[0]]}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="WithFavoritesOnly">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
activeId={null}
|
||||
currentModel={null}
|
||||
favorites={favoriteModels}
|
||||
groups={{ available: [] }}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="ModelLoadedStates">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<div class="px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none">
|
||||
Server model states
|
||||
</div>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={false}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('model-idle', 'Model (idle)', 'meta')}
|
||||
/>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={false}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('model-loading', 'Model (loading)', 'meta')}
|
||||
/>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={false}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('model-loaded', 'Model (loaded)', 'meta')}
|
||||
/>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={false}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('model-sleeping', 'Model (sleeping)', 'meta')}
|
||||
/>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={false}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('model-failed', 'Model (failed)', 'meta')}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="ModelSelectedStates">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<div class="px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none">
|
||||
Selection states
|
||||
</div>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={false}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('normal-model', 'Normal Model', 'meta')}
|
||||
/>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={false}
|
||||
isSelected={true}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('selected-model', 'Selected Model', 'meta')}
|
||||
/>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={false}
|
||||
isHighlighted={true}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('highlighted-model', 'Highlighted Model', 'meta')}
|
||||
/>
|
||||
|
||||
<ModelsSelectorOption
|
||||
hideOrgName={true}
|
||||
isFav={true}
|
||||
isHighlighted={false}
|
||||
isSelected={false}
|
||||
onKeyDown={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onSelect={() => {}}
|
||||
option={mockModel('fav-model', 'Favorite Model', 'Qwen')}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,64 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import PwaRefreshAlert from '$lib/components/pwa/PwaRefreshAlert.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: PwaRefreshAlert,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
title: 'Components/PwaRefreshAlert'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
args={{ needRefresh: true, updateServiceWorker: () => console.log('reload') }}
|
||||
name="Default"
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{ needRefresh: false, updateServiceWorker: () => console.log('reload') }}
|
||||
name="Hidden"
|
||||
play={async ({ canvas }) => {
|
||||
const title = canvas.queryByText('Update available');
|
||||
|
||||
await expect(title).not.toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
needRefresh: true,
|
||||
updateServiceWorker: () => console.log('reload')
|
||||
}}
|
||||
name="ClickReload"
|
||||
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();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,109 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import SidebarNavigation from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte';
|
||||
import { waitFor } from 'storybook/test';
|
||||
import { screen } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: SidebarNavigation,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
title: 'Components/SidebarNavigation'
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Mock conversations for the sidebar
|
||||
const mockConversations: DatabaseConversation[] = [
|
||||
{
|
||||
currNode: 'msg-1',
|
||||
id: 'conv-1',
|
||||
lastModified: Date.now() - 1000 * 60 * 5, // 5 minutes ago
|
||||
name: 'Getting Started with AI'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-2',
|
||||
id: 'conv-2',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
|
||||
name: 'Python Programming Help'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-3',
|
||||
id: 'conv-3',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
|
||||
name: 'Creative Writing Ideas'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-4',
|
||||
id: 'conv-4',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 3, // 3 days ago
|
||||
name: 'This is a very long conversation title that should be truncated properly when displayed'
|
||||
},
|
||||
{
|
||||
currNode: 'msg-5',
|
||||
id: 'conv-5',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 7, // 1 week ago
|
||||
name: 'Math Problem Solving'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="Default"
|
||||
play={async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
waitFor(() =>
|
||||
setTimeout(() => {
|
||||
conversationsStore.conversations = mockConversations;
|
||||
}, 0)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div class="flex-column h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="SearchActive"
|
||||
play={async ({ userEvent }) => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
waitFor(() =>
|
||||
setTimeout(() => {
|
||||
conversationsStore.conversations = mockConversations;
|
||||
}, 0)
|
||||
);
|
||||
|
||||
// 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);
|
||||
}}
|
||||
>
|
||||
<div class="flex-column h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="Empty"
|
||||
play={async () => {
|
||||
// Mock empty conversations store
|
||||
const { conversationsStore } = await import('$lib/stores/conversations/index.svelte');
|
||||
|
||||
conversationsStore.conversations = [];
|
||||
}}
|
||||
>
|
||||
<div class="flex-column h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts" module>
|
||||
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({
|
||||
component: ActionIcon,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ActionIcon/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="SingleTabStop"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const before = await canvas.findByRole('button', { name: 'before' });
|
||||
const target = await canvas.findByRole('button', { name: 'Copy' });
|
||||
|
||||
before.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(target).toHaveFocus();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
|
||||
<ActionIcon icon={Copy} onclick={() => {}} tooltip="Copy" />
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatMessageStatistics from '$lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: ChatMessageStatistics,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ChatMessageStatistics/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
args={{
|
||||
agenticTimings: {
|
||||
llm: { predicted_ms: 1000, predicted_n: 200, prompt_ms: 500, prompt_n: 100 },
|
||||
toolCallsCount: 1,
|
||||
toolsMs: 500,
|
||||
turns: 1
|
||||
},
|
||||
hideSummary: false,
|
||||
isLive: false,
|
||||
predictedMs: 1000,
|
||||
predictedTokens: 200,
|
||||
promptMs: 500,
|
||||
promptTokens: 100
|
||||
}}
|
||||
name="ViewButtonsSingleTabStop"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const reading = await canvas.findByRole('button', { name: 'Reading' });
|
||||
const generation = await canvas.findByRole('button', { name: 'Generation' });
|
||||
const tools = await canvas.findByRole('button', { name: 'Tools' });
|
||||
const summary = await canvas.findByRole('button', { name: 'Summary' });
|
||||
|
||||
reading.focus();
|
||||
await expect(reading).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
await expect(generation).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
await expect(tools).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
await expect(summary).toHaveFocus();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,51 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
|
||||
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
|
||||
import { expect, screen, waitFor } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: ChatScreenForm,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ChatScreen/ChatScreenForm/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
name="AddButtonSingleTabStop"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const textarea = await canvas.findByRole('textbox');
|
||||
|
||||
await userEvent.clear(textarea);
|
||||
await userEvent.type(textarea, 'What is the meaning of life?');
|
||||
|
||||
const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
|
||||
|
||||
trigger.focus();
|
||||
await expect(trigger).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(trigger).not.toHaveFocus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
name="AddDropdownFocusesFirstEnabled"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
|
||||
|
||||
trigger.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await screen.findByRole('menu');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toHaveTextContent('Add files');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,52 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ModelsManagerModelRow from '$lib/components/app/models/ModelsManager/ModelsManagerModelRow.svelte';
|
||||
import { MODEL_ROW_GRID_CLASS } from '$lib/constants';
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ModelsManager/Accessibility'
|
||||
});
|
||||
|
||||
const option: ModelOption = {
|
||||
capabilities: [],
|
||||
id: 'org/Qwen3-8B:Q4_K_M',
|
||||
model: 'org/Qwen3-8B:Q4_K_M',
|
||||
name: 'Qwen3-8B'
|
||||
};
|
||||
|
||||
const ROW_NAME = /org\/Qwen3\s+8B/;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let selected = $state(false);
|
||||
</script>
|
||||
|
||||
<!-- The row answers Enter and Space on its own, so a keyboard user selects a model
|
||||
without reaching for the pointer. -->
|
||||
<Story
|
||||
name="RowKeyboardSelect"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const row = await canvas.findByRole('button', { name: ROW_NAME });
|
||||
|
||||
row.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
|
||||
await expect(row).toHaveAttribute('aria-pressed', 'true');
|
||||
}}
|
||||
>
|
||||
<div class={MODEL_ROW_GRID_CLASS + ' w-[40rem]'}>
|
||||
<ModelsManagerModelRow
|
||||
isFavorite={() => false}
|
||||
onDelete={() => {}}
|
||||
onSelect={() => (selected = true)}
|
||||
{option}
|
||||
{selected}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { ScrollCarousel } from '$lib/components/app';
|
||||
import { ScrollCarouselVariant } from '$lib/enums';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: ScrollCarousel,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/ScrollCarousel/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="ArrowsNotInTabOrderWhenNotScrollable"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const before = await canvas.findByRole('button', { name: 'before' });
|
||||
const after = await canvas.findByRole('button', { name: 'after' });
|
||||
const leftArrow = await canvas.findByRole('button', { name: 'Scroll left' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(leftArrow).toBeDisabled();
|
||||
});
|
||||
|
||||
before.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(after).toHaveFocus();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
|
||||
<ScrollCarousel class="w-96" variant={ScrollCarouselVariant.CENTER}>
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
</ScrollCarousel>
|
||||
|
||||
<button type="button">after</button>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="ArrowsInTabOrderWhenScrollable"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const before = await canvas.findByRole('button', { name: 'before' });
|
||||
const rightArrow = await canvas.findByRole('button', { name: 'Scroll right' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rightArrow).not.toBeDisabled();
|
||||
});
|
||||
|
||||
before.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(rightArrow).toHaveFocus();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
|
||||
<ScrollCarousel class="w-48" variant={ScrollCarouselVariant.CENTER}>
|
||||
{#each [...Array(20).keys()] as i (i)}
|
||||
<div class="h-12 w-24 shrink-0 bg-muted">{i}</div>
|
||||
{/each}
|
||||
</ScrollCarousel>
|
||||
</div>
|
||||
</Story>
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<script lang="ts" module>
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import SidebarNavigationConversationItem from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const mockForkedConversation: DatabaseConversation = {
|
||||
currNode: 'msg-2',
|
||||
forkedFromConversationId: 'conv-1',
|
||||
id: 'conv-2',
|
||||
lastModified: Date.now(),
|
||||
name: 'Forked Conversation'
|
||||
};
|
||||
|
||||
const { Story } = defineMeta({
|
||||
component: SidebarNavigationConversationItem,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev'],
|
||||
title: 'Components/SidebarNavigationConversationItem/Accessibility'
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
args={{ conversation: mockForkedConversation, depth: 1 }}
|
||||
name="ForkIconSingleTabStop"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const row = await canvas.findByRole('button', { name: /Forked Conversation/ });
|
||||
const forkIcon = await canvas.findByRole('link');
|
||||
|
||||
row.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(forkIcon).toHaveFocus();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,164 @@
|
||||
// AI Assistant Tutorial Response
|
||||
export const AI_TUTORIAL_MD = String.raw`
|
||||
# Building a Modern Chat Application with SvelteKit
|
||||
|
||||
I'll help you create a **production-ready chat application** using SvelteKit, TypeScript, and WebSockets. This implementation includes real-time messaging, user authentication, and message persistence.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
First, let's set up the project:
|
||||
|
||||
${'```'}bash
|
||||
npm create svelte@latest chat-app
|
||||
cd chat-app
|
||||
npm install
|
||||
npm install socket.io socket.io-client
|
||||
npm install @prisma/client prisma
|
||||
npm run dev
|
||||
${'```'}
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
${'```'}
|
||||
chat-app/
|
||||
├── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── +layout.svelte
|
||||
│ │ ├── +page.svelte
|
||||
│ │ └── api/
|
||||
│ │ └── socket/+server.ts
|
||||
│ ├── lib/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── ChatMessage.svelte
|
||||
│ │ │ └── ChatInput.svelte
|
||||
│ │ └── stores/
|
||||
│ │ └── chat.ts
|
||||
│ └── app.html
|
||||
├── prisma/
|
||||
│ └── schema.prisma
|
||||
└── package.json
|
||||
${'```'}
|
||||
|
||||
## 💻 Implementation
|
||||
|
||||
### WebSocket Server
|
||||
|
||||
${'```'}typescript
|
||||
// src/lib/server/socket.ts
|
||||
import { Server } from 'socket.io';
|
||||
import type { ViteDevServer } from 'vite';
|
||||
|
||||
export function initializeSocketIO(server: ViteDevServer) {
|
||||
const io = new Server(server.httpServer || server, {
|
||||
cors: {
|
||||
origin: process.env.ORIGIN || 'http://localhost:5173',
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('User connected:', socket.id);
|
||||
|
||||
socket.on('message', async (data) => {
|
||||
// Broadcast to all clients
|
||||
io.emit('new-message', {
|
||||
id: crypto.randomUUID(),
|
||||
userId: socket.id,
|
||||
content: data.content,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('User disconnected:', socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Client Store
|
||||
|
||||
${'```'}typescript
|
||||
// src/lib/stores/chat.ts
|
||||
import { writable } from 'svelte/store';
|
||||
import io from 'socket.io-client';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function createChatStore() {
|
||||
const { subscribe, update } = writable<Message[]>([]);
|
||||
let socket: ReturnType<typeof io>;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
connect: () => {
|
||||
socket = io('http://localhost:5173');
|
||||
|
||||
socket.on('new-message', (message: Message) => {
|
||||
update(messages => [...messages, message]);
|
||||
});
|
||||
},
|
||||
sendMessage: (content: string) => {
|
||||
if (socket && content.trim()) {
|
||||
socket.emit('message', { content });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const chatStore = createChatStore();
|
||||
${'```'}
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
✅ **Real-time messaging** with WebSockets
|
||||
✅ **Message persistence** using Prisma + PostgreSQL
|
||||
✅ **Type-safe** with TypeScript
|
||||
✅ **Responsive UI** for all devices
|
||||
✅ **Auto-reconnection** on connection loss
|
||||
|
||||
## 📊 Performance Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Message Latency** | < 50ms |
|
||||
| **Concurrent Users** | 10,000+ |
|
||||
| **Messages/Second** | 5,000+ |
|
||||
| **Uptime** | 99.9% |
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
${'```'}env
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/chat"
|
||||
JWT_SECRET="your-secret-key"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
${'```'}
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
Deploy to production using Docker:
|
||||
|
||||
${'```'}dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
${'```'}
|
||||
|
||||
---
|
||||
|
||||
*Need help? Check the [documentation](https://kit.svelte.dev) or [open an issue](https://github.com/sveltejs/kit/issues)*
|
||||
`;
|
||||
@@ -0,0 +1,160 @@
|
||||
// API Documentation
|
||||
export const API_DOCS_MD = String.raw`
|
||||
# REST API Documentation
|
||||
|
||||
## 🔐 Authentication
|
||||
|
||||
All API requests require authentication using **Bearer tokens**. Include your API key in the Authorization header:
|
||||
|
||||
${'```'}http
|
||||
GET /api/v1/users
|
||||
Host: api.example.com
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
Content-Type: application/json
|
||||
${'```'}
|
||||
|
||||
## 📍 Endpoints
|
||||
|
||||
### Users API
|
||||
|
||||
#### **GET** /api/v1/users
|
||||
|
||||
Retrieve a paginated list of users.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| page | integer | 1 | Page number |
|
||||
| limit | integer | 20 | Items per page |
|
||||
| sort | string | "created_at" | Sort field |
|
||||
| order | string | "desc" | Sort order |
|
||||
|
||||
**Response:** 200 OK
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "usr_1234567890",
|
||||
"email": "user@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "admin",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 156,
|
||||
"pages": 8
|
||||
}
|
||||
}
|
||||
${'```'}
|
||||
|
||||
#### **POST** /api/v1/users
|
||||
|
||||
Create a new user account.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"email": "newuser@example.com",
|
||||
"password": "SecurePassword123!",
|
||||
"name": "Jane Smith",
|
||||
"role": "user"
|
||||
}
|
||||
${'```'}
|
||||
|
||||
**Response:** 201 Created
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"id": "usr_9876543210",
|
||||
"email": "newuser@example.com",
|
||||
"name": "Jane Smith",
|
||||
"role": "user",
|
||||
"created_at": "2024-01-21T09:15:00Z"
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Error Responses
|
||||
|
||||
The API returns errors in a consistent format:
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Invalid request parameters",
|
||||
"details": [
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Email format is invalid"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
| Tier | Requests/Hour | Burst |
|
||||
|------|--------------|-------|
|
||||
| **Free** | 1,000 | 100 |
|
||||
| **Pro** | 10,000 | 500 |
|
||||
| **Enterprise** | Unlimited | - |
|
||||
|
||||
**Headers:**
|
||||
- X-RateLimit-Limit
|
||||
- X-RateLimit-Remaining
|
||||
- X-RateLimit-Reset
|
||||
|
||||
### Webhooks
|
||||
|
||||
Configure webhooks to receive real-time events:
|
||||
|
||||
${'```'}javascript
|
||||
// Webhook payload
|
||||
{
|
||||
"event": "user.created",
|
||||
"timestamp": "2024-01-21T09:15:00Z",
|
||||
"data": {
|
||||
"id": "usr_9876543210",
|
||||
"email": "newuser@example.com"
|
||||
},
|
||||
"signature": "sha256=abcd1234..."
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### SDK Examples
|
||||
|
||||
**JavaScript/TypeScript:**
|
||||
|
||||
${'```'}typescript
|
||||
import { ApiClient } from '@example/api-sdk';
|
||||
|
||||
const client = new ApiClient({
|
||||
apiKey: process.env.API_KEY
|
||||
});
|
||||
|
||||
const users = await client.users.list({
|
||||
page: 1,
|
||||
limit: 20
|
||||
});
|
||||
${'```'}
|
||||
|
||||
**Python:**
|
||||
|
||||
${'```'}python
|
||||
from example_api import Client
|
||||
|
||||
client = Client(api_key=os.environ['API_KEY'])
|
||||
users = client.users.list(page=1, limit=20)
|
||||
${'```'}
|
||||
|
||||
---
|
||||
|
||||
📚 [Full API Reference](https://api.example.com/docs) | 💬 [Support](https://support.example.com)
|
||||
`;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 798 KiB |
Binary file not shown.
@@ -0,0 +1,8 @@
|
||||
<svg width="256" height="256" viewBox="0 0 256 256" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M230.721 172.7C230.183 170.673 229.313 168.75 228.146 167.008C228.396 166.091 228.587 165.159 228.714 164.217C229.543 158.241 227.471 152.77 223.567 148.537C221.452 146.225 219.185 144.698 216.784 143.761C218.36 137.018 219.157 130.117 219.161 123.193C219.161 120.03 218.982 116.932 218.682 113.88C218.526 112.356 218.337 110.836 218.115 109.32C217.428 104.847 216.408 100.431 215.064 96.11C214.183 93.2707 213.164 90.476 212.01 87.736C210.281 83.6782 208.262 79.75 205.969 75.982C204.465 73.475 202.827 71.0508 201.062 68.72C200.197 67.543 199.296 66.3938 198.358 65.274C195.58 61.898 192.561 58.7277 189.325 55.788C188.25 54.7997 187.145 53.8453 186.01 52.926C184.893 51.9943 183.751 51.0927 182.586 50.222C180.241 48.4766 177.818 46.8392 175.324 45.315C161.543 36.945 145.382 32.145 128.109 32.145C77.817 32.145 37.057 72.907 37.057 123.196C37.055 130.208 37.867 137.196 39.477 144.02C37.317 144.958 35.247 146.42 33.327 148.535C29.424 152.766 27.351 158.217 28.18 164.193C28.306 165.142 28.495 166.082 28.747 167.006C27.5811 168.749 26.7117 170.673 26.174 172.7C24.974 177.261 25.369 181.374 26.894 184.978C25.236 189.688 25.65 194.704 27.809 199.065C29.379 202.25 31.626 204.714 34.396 206.916C37.689 209.534 41.811 211.758 46.783 213.892C52.715 216.422 59.956 218.799 63.249 219.671C71.755 221.873 79.911 223.269 88.177 223.337C99.954 223.446 110.096 220.677 117.357 213.59C120.924 214.027 124.515 214.246 128.109 214.244C131.906 214.236 135.699 213.997 139.467 213.529C146.711 220.661 156.892 223.455 168.712 223.343C176.977 223.277 185.133 221.881 193.617 219.676C196.932 218.804 204.17 216.427 210.105 213.897C215.077 211.76 219.199 209.536 222.514 206.922C225.263 204.719 227.508 202.256 229.079 199.071C231.26 194.709 231.652 189.693 230.017 184.983C231.527 181.379 231.92 177.257 230.721 172.7ZM222.281 184.673C223.952 187.844 224.059 191.427 222.585 194.764C220.349 199.821 214.795 203.805 204.008 208.082C197.3 210.742 191.158 212.443 191.104 212.458C182.232 214.759 174.208 215.928 167.262 215.928C155.76 215.928 147.201 212.754 141.773 206.486C132.594 208.05 123.222 208.103 114.026 206.644C108.591 212.808 100.081 215.928 88.676 215.928C81.729 215.928 73.706 214.759 64.833 212.458C64.779 212.443 58.639 210.742 51.929 208.082C41.143 203.805 35.587 199.824 33.352 194.764C31.878 191.427 31.985 187.844 33.656 184.673C33.81 184.378 33.976 184.091 34.153 183.813C33.1516 182.309 32.4799 180.61 32.182 178.827C31.8842 177.045 31.967 175.22 32.425 173.472C33.089 170.949 34.46 168.851 36.322 167.344C35.425 165.87 34.8365 164.23 34.592 162.522C34.056 158.808 35.289 155.1 38.062 152.076C40.222 149.723 43.275 148.428 46.655 148.428H46.745C44.1965 140.259 42.9044 131.75 42.913 123.193C42.913 76.522 80.749 38.683 127.427 38.683C174.104 38.683 211.94 76.518 211.94 123.193C211.947 131.773 210.646 140.304 208.081 148.492C208.489 148.452 208.889 148.432 209.282 148.431C212.662 148.431 215.716 149.726 217.874 152.079C220.647 155.1 221.881 158.811 221.344 162.525C221.1 164.233 220.511 165.873 219.615 167.347C221.477 168.854 222.849 170.952 223.512 173.475C223.97 175.223 224.053 177.048 223.755 178.831C223.458 180.613 222.786 182.312 221.784 183.816C221.961 184.091 222.129 184.378 222.281 184.673Z" fill="white"/>
|
||||
<path d="M221.784 183.816C222.786 182.312 223.458 180.613 223.756 178.831C224.053 177.048 223.97 175.223 223.512 173.475C222.848 170.952 221.476 168.854 219.615 167.347C220.512 165.873 221.1 164.233 221.344 162.525C221.881 158.811 220.648 155.103 217.874 152.079C215.716 149.726 212.662 148.431 209.282 148.431C208.889 148.431 208.489 148.452 208.081 148.492C210.643 140.304 211.942 131.774 211.933 123.195C211.933 76.5231 174.097 38.6851 127.424 38.6851C80.75 38.6851 42.9099 76.5191 42.9099 123.195C42.9015 131.752 44.1936 140.261 46.742 148.43H46.6519C43.2719 148.43 40.219 149.724 38.06 152.077C35.287 155.098 34.0529 158.81 34.5899 162.523C34.8346 164.231 35.4231 165.872 36.3199 167.346C34.4579 168.852 33.086 170.95 32.422 173.473C31.9642 175.222 31.8817 177.047 32.1799 178.83C32.4781 180.612 33.1501 182.312 34.1519 183.816C33.9739 184.094 33.8099 184.381 33.6549 184.676C31.9849 187.847 31.877 191.43 33.352 194.767C35.588 199.824 41.1419 203.808 51.9289 208.085C58.6359 210.745 64.779 212.446 64.833 212.461C73.705 214.762 81.729 215.931 88.675 215.931C100.081 215.931 108.591 212.811 114.026 206.647C123.222 208.106 132.594 208.052 141.773 206.489C147.201 212.757 155.76 215.931 167.262 215.931C174.208 215.931 182.232 214.762 191.103 212.461C191.158 212.446 197.298 210.745 204.008 208.085C214.795 203.808 220.35 199.824 222.585 194.767C224.059 191.43 223.952 187.847 222.281 184.676C222.129 184.379 221.961 184.091 221.784 183.816ZM110.137 196.997C109.669 197.815 109.168 198.614 108.635 199.391C107.23 201.448 105.382 203.02 103.237 204.188C99.1369 206.424 93.947 207.205 88.675 207.205C80.346 207.205 71.808 205.256 67.023 204.015C66.787 203.954 37.689 195.735 41.373 188.739C41.993 187.562 43.0129 187.092 44.2979 187.092C49.4849 187.092 58.9299 194.816 62.9889 194.816C63.8959 194.816 64.5359 194.43 64.7969 193.488C66.5269 187.284 38.5039 184.676 40.8639 175.692C41.2799 174.102 42.41 173.456 43.998 173.456C50.856 173.455 66.248 185.516 69.467 185.516C69.714 185.516 69.8909 185.443 69.9869 185.291C70.0009 185.268 70.015 185.246 70.028 185.222C71.539 182.727 70.6719 180.913 60.3209 174.573L59.3269 173.968C47.9359 167.074 39.9409 162.925 44.4879 157.975C45.0109 157.404 45.7529 157.151 46.6539 157.151C47.7219 157.151 49.0149 157.508 50.4389 158.108C56.4549 160.645 64.793 167.564 68.276 170.581C68.8239 171.057 69.3683 171.538 69.9089 172.022C69.9089 172.022 74.319 176.608 76.985 176.608C77.599 176.608 78.1199 176.366 78.4729 175.768C80.364 172.58 60.9099 157.838 59.8129 151.755C59.0689 147.634 60.3349 145.546 62.6749 145.546C63.7879 145.546 65.1459 146.02 66.6449 146.971C71.2949 149.922 80.2729 165.35 83.5599 171.352C84.6619 173.363 86.5429 174.213 88.2379 174.213C91.6009 174.213 94.2299 170.87 88.5459 166.622C80.0029 160.23 83.001 149.782 87.078 149.139C87.252 149.111 87.4279 149.097 87.6029 149.097C91.3109 149.097 92.9459 155.486 92.9459 155.486C92.9459 155.486 97.7399 167.524 105.975 175.753C113.447 183.222 114.491 189.351 110.137 196.997ZM136.766 198.407L136.339 198.458L135.611 198.541C135.228 198.581 134.844 198.619 134.459 198.654L134.084 198.688L133.741 198.717L133.255 198.756L132.718 198.795L132.182 198.83L132.063 198.838C131.923 198.846 131.783 198.855 131.641 198.862L131.462 198.872C131.296 198.881 131.13 198.889 130.962 198.896L130.381 198.921L129.854 198.939L129.502 198.949H129.323C129.213 198.949 129.104 198.955 128.994 198.956H128.82C128.71 198.956 128.601 198.956 128.491 198.961L128.043 198.967H127.418C126.927 198.967 126.437 198.962 125.949 198.952L125.553 198.943C125.44 198.943 125.327 198.938 125.216 198.934L124.796 198.922L124.275 198.902L123.805 198.881L123.684 198.876L123.237 198.853C123.112 198.846 122.989 198.84 122.865 198.831L122.576 198.814C122.213 198.791 121.85 198.766 121.487 198.738L121.107 198.707C120.947 198.695 120.787 198.68 120.628 198.666C120.441 198.65 120.254 198.632 120.067 198.614C119.754 198.585 119.441 198.553 119.128 198.519H119.113C123.683 188.324 121.372 178.802 112.137 169.575C106.08 163.526 102.051 154.594 101.215 152.633C99.5229 146.828 95.045 140.375 87.608 140.375C86.979 140.375 86.351 140.425 85.73 140.523C82.472 141.036 79.624 142.911 77.592 145.733C75.396 143.002 73.262 140.831 71.332 139.605C68.422 137.76 65.5179 136.824 62.6889 136.824C59.1579 136.824 56.0019 138.274 53.8019 140.904L53.7459 140.971C53.7039 140.798 53.6639 140.625 53.6229 140.451L53.6179 140.428C53.1992 138.638 52.8477 136.833 52.5639 135.016C52.5639 135.004 52.5639 134.992 52.5579 134.98C52.5359 134.843 52.5159 134.705 52.4949 134.568C52.4334 134.162 52.3757 133.755 52.3219 133.348C52.2979 133.163 52.2719 132.978 52.2489 132.793L52.1809 132.238C52.1589 132.053 52.1409 131.885 52.1209 131.709L52.115 131.665C52.0351 130.945 51.9651 130.225 51.9049 129.503L51.8829 129.226L51.8479 128.754C51.8379 128.625 51.8279 128.495 51.8209 128.365C51.8209 128.334 51.8159 128.304 51.8149 128.275C51.7895 127.913 51.7678 127.55 51.7499 127.187C51.7399 126.998 51.7299 126.81 51.7219 126.62L51.7019 126.124L51.6969 125.974L5Line truncated
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M203.21 123.685V123.194C203.21 81.34 169.292 47.411 127.435 47.411C85.5791 47.411 51.648 81.342 51.648 123.194V123.358C51.646 123.467 51.645 123.576 51.648 123.685C51.6529 123.848 51.6546 124.011 51.653 124.174L51.6581 124.534L51.661 124.663C51.661 124.723 51.6631 124.782 51.6651 124.842C51.6681 124.937 51.67 125.033 51.67 125.128L51.681 125.517L51.697 125.974L51.702 126.124L51.722 126.597V126.62C51.73 126.805 51.7401 126.989 51.7491 127.173L51.75 127.187C51.76 127.375 51.7701 127.564 51.7821 127.753C51.7921 127.927 51.802 128.101 51.815 128.275L51.8171 128.306C51.8258 128.455 51.8358 128.605 51.847 128.754L51.85 128.794L51.883 129.226L51.8861 129.254C51.8921 129.338 51.898 129.422 51.906 129.503C51.9658 130.224 52.0355 130.945 52.1151 131.664L52.12 131.709L52.181 132.238L52.2491 132.793L52.299 133.17L52.322 133.347C52.3753 133.755 52.433 134.162 52.495 134.568L52.4991 134.595L52.558 134.979C52.8435 136.808 53.1971 138.626 53.618 140.429L53.6231 140.451L53.655 140.586L53.746 140.971L53.802 140.904C56.002 138.274 59.158 136.824 62.689 136.824C65.519 136.824 68.4221 137.76 71.3321 139.605C73.2621 140.831 75.3961 143.002 77.5921 145.733C79.6241 142.911 82.4721 141.035 85.7301 140.523C86.3513 140.425 86.9792 140.376 87.6081 140.375C95.0441 140.375 99.523 146.828 101.215 152.633C102.051 154.594 106.08 163.526 112.156 169.568C121.392 178.795 123.703 188.316 119.132 198.511H119.148C119.459 198.546 119.772 198.578 120.087 198.607C120.274 198.625 120.46 198.643 120.648 198.659L120.714 198.665L121.127 198.7L121.507 198.73C121.869 198.758 122.232 198.784 122.596 198.807L122.885 198.824L123.114 198.838L123.256 198.846L123.703 198.869L123.825 198.874L124.294 198.895L124.816 198.915L125.235 198.927L125.305 198.929C125.394 198.933 125.483 198.936 125.572 198.936L125.668 198.939C126.258 198.953 126.847 198.96 127.437 198.959H128.063L128.51 198.954C128.62 198.949 128.729 198.949 128.84 198.949H129.014L129.165 198.945C129.224 198.943 129.283 198.941 129.343 198.941H129.522L129.873 198.932L130.401 198.914L130.982 198.888C131.15 198.882 131.316 198.873 131.482 198.865L131.661 198.854L131.927 198.84L132.083 198.831L132.201 198.823L132.738 198.788L133.274 198.749L133.761 198.71L134.103 198.681L134.479 198.647C135.107 198.591 135.733 198.525 136.359 198.45L136.786 198.399C132.287 188.247 134.616 178.767 143.813 169.577C149.876 163.519 153.905 154.587 154.745 152.625C156.438 146.821 160.914 140.368 168.352 140.368C168.981 140.368 169.61 140.418 170.231 140.516C173.486 141.028 176.334 142.904 178.369 145.726C180.566 142.996 182.699 140.823 184.63 139.597C187.539 137.753 190.445 136.817 193.272 136.817C196.388 136.817 199.212 137.947 201.345 140.02C201.384 139.851 201.422 139.682 201.459 139.512L201.568 139.006C201.607 138.821 201.646 138.636 201.683 138.451C201.749 138.124 201.815 137.797 201.878 137.467C201.944 137.125 202.007 136.781 202.067 136.437L202.098 136.251C202.117 136.141 202.135 136.031 202.156 135.92C202.19 135.748 202.218 135.576 202.246 135.402L202.257 135.336L202.328 134.883L202.398 134.424V134.42C202.449 134.081 202.497 133.742 202.542 133.403L202.553 133.319L202.616 132.841L202.667 132.433L202.757 131.629L202.792 131.306L202.801 131.218C202.82 131.044 202.838 130.87 202.854 130.696V130.682C202.867 130.544 202.881 130.405 202.893 130.266C202.964 129.478 203.024 128.686 203.072 127.891C203.081 127.761 203.088 127.63 203.096 127.499V127.493L203.122 127.002L203.128 126.892C203.144 126.56 203.158 126.228 203.169 125.896V125.884L203.174 125.754C203.179 125.645 203.183 125.535 203.183 125.425L203.185 125.381C203.189 125.278 203.193 125.172 203.193 125.067L203.196 124.977C203.199 124.872 203.202 124.768 203.202 124.663L203.204 124.574C203.207 124.441 203.21 124.307 203.21 124.174V123.685ZM108.638 199.391C114.64 190.59 114.214 183.984 105.98 175.754C97.7441 167.523 92.951 155.487 92.951 155.487C92.951 155.487 91.1621 148.496 87.0821 149.138C83.0021 149.78 80.0091 160.227 88.5521 166.622C97.0941 173.017 86.8521 177.353 83.5641 171.352C80.2761 165.35 71.299 149.923 66.645 146.972C61.991 144.021 58.718 145.675 59.815 151.757C60.36 154.776 65.4281 159.929 70.1631 164.743C74.9671 169.627 79.428 174.163 78.474 175.768C76.581 178.955 69.9141 172.023 69.9141 172.023C69.9141 172.023 49.038 153.025 44.494 157.976C40.304 162.539 46.765 166.418 56.7211 172.397C57.5671 172.905 58.4391 173.429 59.3321 173.969C70.7231 180.865 71.609 182.684 69.992 185.293C69.395 186.257 65.582 183.968 60.892 181.153C52.897 176.352 42.3551 170.023 40.8661 175.688C39.5781 180.591 47.334 183.595 54.368 186.32C60.228 188.59 65.5881 190.666 64.7991 193.484C63.9821 196.406 59.5531 193.969 54.7121 191.305C49.2771 188.314 43.3221 185.038 41.3731 188.735C37.6901 195.725 66.7831 203.954 67.0231 204.015C76.4231 206.453 100.295 211.619 108.638 199.391ZM147.303 199.391C141.301 190.59 141.727 183.984 149.962 175.754C158.197 167.523 162.99 155.487 162.99 155.487C162.99 155.487 164.779 148.496 168.859 149.138C172.939 149Line truncated
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M152.047 102.567C153.229 102.985 154.108 104.257 154.944 105.468C156.074 107.104 157.126 108.627 158.74 107.769C160.644 106.756 162.205 105.202 163.225 103.302C164.246 101.402 164.681 99.2427 164.475 97.096C164.321 95.4908 163.813 93.9398 162.987 92.5548C162.161 91.1697 161.038 89.985 159.7 89.0862C158.361 88.1874 156.839 87.5968 155.245 87.3569C153.65 87.117 152.022 87.2339 150.478 87.699C148.934 88.1639 147.513 88.9653 146.316 90.0455C145.119 91.1257 144.176 92.4578 143.556 93.946C142.936 95.4342 142.653 97.0415 142.728 98.652C142.804 100.263 143.235 101.836 143.992 103.26C144.74 104.667 146.4 104.003 148.152 103.302C149.525 102.753 150.956 102.181 152.047 102.567ZM100.672 102.567C99.49 102.985 98.611 104.258 97.775 105.468C96.645 107.105 95.592 108.627 93.979 107.769C91.5845 106.501 89.7482 104.386 88.8278 101.838C87.9075 99.2895 87.9692 96.4896 89.0008 93.9841C90.0324 91.4786 91.9601 89.4471 94.408 88.2855C96.856 87.1239 99.6488 86.9156 102.242 87.701C104.307 88.3228 106.141 89.5427 107.513 91.2065C108.885 92.8704 109.732 94.9035 109.949 97.049C110.165 99.1945 109.74 101.356 108.728 103.26C107.979 104.667 106.319 104.003 104.567 103.303C103.193 102.753 101.764 102.181 100.672 102.567ZM144.099 149.318C152.242 142.903 155.233 132.429 155.233 125.977C155.233 120.877 151.802 122.482 146.309 125.202L145.999 125.355C140.957 127.852 134.245 131.177 126.877 131.177C119.508 131.177 112.796 127.852 107.755 125.354C102.084 122.545 98.527 120.783 98.527 125.978C98.527 132.634 101.709 143.563 110.443 149.912C111.596 147.573 113.219 145.497 115.211 143.813C117.202 142.129 119.52 140.874 122.018 140.126C122.89 139.866 123.788 141.367 124.707 142.904C125.594 144.386 126.501 145.902 127.423 145.902C128.406 145.902 129.371 144.408 130.314 142.95C131.299 141.425 132.26 139.94 133.189 140.237C137.864 141.738 141.775 144.993 144.099 149.318Z" fill="#32343D"/>
|
||||
<path d="M144.097 149.317C139.856 152.659 134.219 154.9 126.878 154.9C119.981 154.9 114.587 152.922 110.443 149.911C111.596 147.572 113.219 145.495 115.211 143.812C117.202 142.128 119.52 140.873 122.018 140.125C123.73 139.614 125.545 145.901 127.423 145.901C129.433 145.901 131.37 139.655 133.189 140.236C137.863 141.738 141.773 144.993 144.097 149.317Z" fill="#FF323D"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.2 111.64C80.2312 112.288 79.1173 112.687 77.9572 112.801C76.7971 112.916 75.6267 112.742 74.55 112.295C73.6893 111.94 72.9072 111.418 72.2488 110.759C71.5903 110.101 71.0684 109.319 70.713 108.458C70.267 107.381 70.0935 106.211 70.2082 105.051C70.3228 103.891 70.7219 102.777 71.37 101.808C72.1488 100.642 73.2558 99.7333 74.5512 99.1967C75.8466 98.6601 77.272 98.5197 78.6471 98.7935C80.0223 99.0672 81.2853 99.7427 82.2764 100.734C83.2675 101.726 83.9422 102.99 84.215 104.365C84.4883 105.74 84.3477 107.165 83.8113 108.46C83.2748 109.755 82.3654 110.861 81.2 111.64ZM182.613 111.64C181.644 112.288 180.53 112.687 179.37 112.801C178.209 112.916 177.039 112.742 175.962 112.295C175.101 111.939 174.319 111.418 173.661 110.759C173.003 110.101 172.481 109.319 172.125 108.458C171.68 107.381 171.507 106.211 171.621 105.051C171.736 103.891 172.135 102.777 172.782 101.808C173.364 100.936 174.133 100.205 175.032 99.6658C175.931 99.1269 176.938 98.7942 177.981 98.6917C179.025 98.5891 180.078 98.7193 181.064 99.0728C182.051 99.4264 182.947 99.9944 183.688 100.736C184.68 101.727 185.355 102.99 185.628 104.365C185.902 105.74 185.761 107.165 185.224 108.46C184.687 109.755 183.779 110.861 182.613 111.64Z" fill="#FFAD03"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,125 @@
|
||||
// Blog Post Content
|
||||
export const BLOG_POST_MD = String.raw`
|
||||
# Understanding Rust's Ownership System
|
||||
|
||||
*Published on March 15, 2024 • 8 min read*
|
||||
|
||||
Rust's ownership system is one of its most distinctive features, enabling memory safety without garbage collection. In this post, we'll explore how ownership works and why it's revolutionary for systems programming.
|
||||
|
||||
## What is Ownership?
|
||||
|
||||
Ownership is a set of rules that governs how Rust manages memory. These rules are checked at compile time, ensuring memory safety without runtime overhead.
|
||||
|
||||
### The Three Rules of Ownership
|
||||
|
||||
1. **Each value has a single owner**
|
||||
2. **There can only be one owner at a time**
|
||||
3. **When the owner goes out of scope, the value is dropped**
|
||||
|
||||
## Memory Management Without GC
|
||||
|
||||
Traditional approaches to memory management:
|
||||
|
||||
- **Manual management** (C/C++): Error-prone, leads to bugs
|
||||
- **Garbage collection** (Java, Python): Runtime overhead
|
||||
- **Ownership** (Rust): Compile-time safety, zero runtime cost
|
||||
|
||||
## Basic Examples
|
||||
|
||||
### Variable Scope
|
||||
|
||||
${'```'}rust
|
||||
fn main() {
|
||||
let s = String::from("hello"); // s comes into scope
|
||||
|
||||
// s is valid here
|
||||
println!("{}", s);
|
||||
|
||||
} // s goes out of scope and is dropped
|
||||
${'```'}
|
||||
|
||||
### Move Semantics
|
||||
|
||||
${'```'}rust
|
||||
fn main() {
|
||||
let s1 = String::from("hello");
|
||||
let s2 = s1; // s1 is moved to s2
|
||||
|
||||
// println!("{}", s1); // ❌ ERROR: s1 is no longer valid
|
||||
println!("{}", s2); // ✅ OK: s2 owns the string
|
||||
}
|
||||
${'```'}
|
||||
|
||||
## Borrowing and References
|
||||
|
||||
Instead of transferring ownership, you can **borrow** values:
|
||||
|
||||
### Immutable References
|
||||
|
||||
${'```'}rust
|
||||
fn calculate_length(s: &String) -> usize {
|
||||
s.len() // s is a reference, doesn't own the String
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let s1 = String::from("hello");
|
||||
let len = calculate_length(&s1); // Borrow s1
|
||||
println!("Length of '{}' is {}", s1, len); // s1 still valid
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Mutable References
|
||||
|
||||
${'```'}rust
|
||||
fn main() {
|
||||
let mut s = String::from("hello");
|
||||
|
||||
let r1 = &mut s;
|
||||
r1.push_str(", world");
|
||||
println!("{}", r1);
|
||||
|
||||
// let r2 = &mut s; // ❌ ERROR: cannot borrow twice
|
||||
}
|
||||
${'```'}
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Dangling References
|
||||
|
||||
${'```'}rust
|
||||
fn dangle() -> &String { // ❌ ERROR: missing lifetime specifier
|
||||
let s = String::from("hello");
|
||||
&s // s will be dropped, leaving a dangling reference
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### ✅ Solution
|
||||
|
||||
${'```'}rust
|
||||
fn no_dangle() -> String {
|
||||
let s = String::from("hello");
|
||||
s // Ownership is moved out
|
||||
}
|
||||
${'```'}
|
||||
|
||||
## Benefits
|
||||
|
||||
- ✅ **No null pointer dereferences**
|
||||
- ✅ **No data races**
|
||||
- ✅ **No use-after-free**
|
||||
- ✅ **No memory leaks**
|
||||
|
||||
## Conclusion
|
||||
|
||||
Rust's ownership system eliminates entire classes of bugs at compile time. While it has a learning curve, the benefits in safety and performance are worth it.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [The Rust Book - Ownership](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html)
|
||||
- [Rust by Example - Ownership](https://doc.rust-lang.org/rust-by-example/scope/move.html)
|
||||
- [Rustlings Exercises](https://github.com/rust-lang/rustlings)
|
||||
|
||||
---
|
||||
|
||||
*Questions? Reach out on [Twitter](https://twitter.com/rustlang) or join the [Rust Discord](https://discord.gg/rust-lang)*
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
// Data Analysis Report
|
||||
export const DATA_ANALYSIS_MD = String.raw`
|
||||
# Q4 2024 Business Analytics Report
|
||||
|
||||
*Executive Summary • Generated on January 15, 2025*
|
||||
|
||||
## 📊 Key Performance Indicators
|
||||
|
||||
${'```'}
|
||||
Daily Active Users (DAU): 1.2M (+65% YoY)
|
||||
Monthly Active Users (MAU): 4.5M (+48% YoY)
|
||||
User Retention (Day 30): 68% (+12pp YoY)
|
||||
Average Session Duration: 24min (+35% YoY)
|
||||
${'```'}
|
||||
|
||||
## 🎯 Product Performance
|
||||
|
||||
### Feature Adoption Rates
|
||||
|
||||
1. **AI Assistant**: 78% of users (↑ from 45%)
|
||||
2. **Collaboration Tools**: 62% of users (↑ from 38%)
|
||||
3. **Analytics Dashboard**: 54% of users (↑ from 31%)
|
||||
4. **Mobile App**: 41% of users (↑ from 22%)
|
||||
|
||||
### Customer Satisfaction
|
||||
|
||||
| Metric | Q4 2024 | Q3 2024 | Change |
|
||||
|--------|---------|---------|--------|
|
||||
| **NPS Score** | 72 | 68 | +4 |
|
||||
| **CSAT** | 4.6/5 | 4.4/5 | +0.2 |
|
||||
| **Support Tickets** | 2,340 | 2,890 | -19% |
|
||||
| **Resolution Time** | 4.2h | 5.1h | -18% |
|
||||
|
||||
## 💰 Revenue Metrics
|
||||
|
||||
### Monthly Recurring Revenue (MRR)
|
||||
|
||||
- **Current MRR**: $2.8M (+42% YoY)
|
||||
- **New MRR**: $340K
|
||||
- **Expansion MRR**: $180K
|
||||
- **Churned MRR**: $95K
|
||||
- **Net New MRR**: $425K
|
||||
|
||||
### Customer Acquisition
|
||||
|
||||
${'```'}
|
||||
Cost per Acquisition (CAC): $127 (-23% YoY)
|
||||
Customer Lifetime Value: $1,840 (+31% YoY)
|
||||
LTV:CAC Ratio: 14.5:1
|
||||
Payback Period: 3.2 months
|
||||
${'```'}
|
||||
|
||||
## 🌍 Geographic Performance
|
||||
|
||||
### Revenue by Region
|
||||
|
||||
1. **North America**: 45% ($1.26M)
|
||||
2. **Europe**: 32% ($896K)
|
||||
3. **Asia-Pacific**: 18% ($504K)
|
||||
4. **Other**: 5% ($140K)
|
||||
|
||||
### Growth Opportunities
|
||||
|
||||
- **APAC**: 89% YoY growth potential
|
||||
- **Latin America**: Emerging market entry
|
||||
- **Middle East**: Enterprise expansion
|
||||
|
||||
## 📱 Channel Performance
|
||||
|
||||
### Traffic Sources
|
||||
|
||||
| Channel | Sessions | Conversion | Revenue |
|
||||
|---------|----------|------------|---------|
|
||||
| **Organic Search** | 45% | 3.2% | $1.1M |
|
||||
| **Direct** | 28% | 4.1% | $850K |
|
||||
| **Social Media** | 15% | 2.8% | $420K |
|
||||
| **Paid Ads** | 12% | 5.5% | $430K |
|
||||
|
||||
### Marketing ROI
|
||||
|
||||
- **Content Marketing**: 340% ROI
|
||||
- **Email Campaigns**: 280% ROI
|
||||
- **Social Media**: 190% ROI
|
||||
- **Paid Search**: 220% ROI
|
||||
|
||||
## 🔍 User Behavior Analysis
|
||||
|
||||
### Session Patterns
|
||||
|
||||
- **Peak Hours**: 9-11 AM, 2-4 PM EST
|
||||
- **Mobile Usage**: 67% of sessions
|
||||
- **Average Pages/Session**: 4.8
|
||||
- **Bounce Rate**: 23% (↓ from 31%)
|
||||
|
||||
### Feature Usage Heatmap
|
||||
|
||||
Most used features in order:
|
||||
1. Dashboard (89% of users)
|
||||
2. Search (76% of users)
|
||||
3. Reports (64% of users)
|
||||
4. Settings (45% of users)
|
||||
5. Integrations (32% of users)
|
||||
|
||||
## 💡 Recommendations
|
||||
|
||||
1. **Invest** in AI capabilities (+$2M budget)
|
||||
2. **Expand** sales team in APAC region
|
||||
3. **Improve** onboarding to reduce churn
|
||||
4. **Launch** enterprise security features
|
||||
|
||||
## Appendix
|
||||
|
||||
### Methodology
|
||||
|
||||
Data collected from:
|
||||
- Internal analytics (Amplitude)
|
||||
- Customer surveys (n=2,450)
|
||||
- Financial systems (NetSuite)
|
||||
- Market research (Gartner)
|
||||
|
||||
---
|
||||
|
||||
*Report prepared by Data Analytics Team • [View Interactive Dashboard](https://analytics.example.com)*
|
||||
`;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Empty state
|
||||
export const EMPTY_MD = '';
|
||||
@@ -0,0 +1,221 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
// Math Formulas Content
|
||||
export const MATH_FORMULAS_MD = String.raw`
|
||||
# Mathematical Formulas and Expressions
|
||||
|
||||
This document demonstrates various mathematical notation and formulas that can be rendered using LaTeX syntax in markdown.
|
||||
|
||||
## Basic Arithmetic
|
||||
|
||||
### Addition and Summation
|
||||
$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$
|
||||
|
||||
## Algebra
|
||||
|
||||
### Quadratic Formula
|
||||
The solutions to $ax^2 + bx + c = 0$ are:
|
||||
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
|
||||
|
||||
### Binomial Theorem
|
||||
$$(x + y)^n = \sum_{k=0}^{n} \binom{n}{k} x^{n-k} y^k$$
|
||||
|
||||
## Calculus
|
||||
|
||||
### Derivatives
|
||||
The derivative of $f(x) = x^n$ is:
|
||||
$$f'(x) = nx^{n-1}$$
|
||||
|
||||
### Integration
|
||||
$$\int_a^b f(x) \, dx = F(b) - F(a)$$
|
||||
|
||||
### Fundamental Theorem of Calculus
|
||||
$$\frac{d}{dx} \int_a^x f(t) \, dt = f(x)$$
|
||||
|
||||
## Linear Algebra
|
||||
|
||||
### Matrix Multiplication
|
||||
If $A$ is an $m \times n$ matrix and $B$ is an $n \times p$ matrix, then:
|
||||
$$C_{ij} = \sum_{k=1}^{n} A_{ik} B_{kj}$$
|
||||
|
||||
### Eigenvalues and Eigenvectors
|
||||
For a square matrix $A$, if $Av = \lambda v$ for some non-zero vector $v$, then:
|
||||
- $\lambda$ is an eigenvalue
|
||||
- $v$ is an eigenvector
|
||||
|
||||
## Statistics and Probability
|
||||
|
||||
### Normal Distribution
|
||||
The probability density function is:
|
||||
$$f(x) = \frac{1}{\sigma\sqrt{2\pi}} e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^2}$$
|
||||
|
||||
### Bayes' Theorem
|
||||
$$P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)}$$
|
||||
|
||||
### Central Limit Theorem
|
||||
For large $n$, the sample mean $\bar{X}$ is approximately:
|
||||
$$\bar{X} \sim N\left(\mu, \frac{\sigma^2}{n}\right)$$
|
||||
|
||||
## Trigonometry
|
||||
|
||||
### Pythagorean Identity
|
||||
$$\sin^2\theta + \cos^2\theta = 1$$
|
||||
|
||||
### Euler's Formula
|
||||
$$e^{i\theta} = \cos\theta + i\sin\theta$$
|
||||
|
||||
### Taylor Series for Sine
|
||||
$$\sin x = \sum_{n=0}^{\infty} \frac{(-1)^n}{(2n+1)!} x^{2n+1} = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots$$
|
||||
|
||||
## Complex Analysis
|
||||
|
||||
### Complex Numbers
|
||||
A complex number can be written as:
|
||||
$$z = a + bi = r e^{i\theta}$$
|
||||
|
||||
where $r = |z| = \sqrt{a^2 + b^2}$ and $\theta = \arg(z)$
|
||||
|
||||
### Cauchy-Riemann Equations
|
||||
For a function $f(z) = u(x,y) + iv(x,y)$ to be analytic:
|
||||
$$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \quad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}$$
|
||||
|
||||
## Differential Equations
|
||||
|
||||
### First-order Linear ODE
|
||||
$$\frac{dy}{dx} + P(x)y = Q(x)$$
|
||||
|
||||
Solution: $y = e^{-\int P(x)dx}\left[\int Q(x)e^{\int P(x)dx}dx + C\right]$
|
||||
|
||||
### Heat Equation
|
||||
$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2}$$
|
||||
|
||||
## Number Theory
|
||||
|
||||
### Prime Number Theorem
|
||||
$$\pi(x) \sim \frac{x}{\ln x}$$
|
||||
|
||||
where $\pi(x)$ is the number of primes less than or equal to $x$.
|
||||
|
||||
### Fermat's Last Theorem
|
||||
For $n > 2$, there are no positive integers $a$, $b$, and $c$ such that:
|
||||
$$a^n + b^n = c^n$$
|
||||
|
||||
## Set Theory
|
||||
|
||||
### De Morgan's Laws
|
||||
$$\overline{A \cup B} = \overline{A} \cap \overline{B}$$
|
||||
$$\overline{A \cap B} = \overline{A} \cup \overline{B}$$
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Riemann Zeta Function
|
||||
$$\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s} = \prod_{p \text{ prime}} \frac{1}{1-p^{-s}}$$
|
||||
|
||||
### Maxwell's Equations
|
||||
$$\nabla \cdot \mathbf{E} = \frac{\rho}{\epsilon_0}$$
|
||||
$$\nabla \cdot \mathbf{B} = 0$$
|
||||
$$\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}$$
|
||||
$$\nabla \times \mathbf{B} = \mu_0\mathbf{J} + \mu_0\epsilon_0\frac{\partial \mathbf{E}}{\partial t}$$
|
||||
|
||||
### Schrödinger Equation
|
||||
$$i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \hat{H}\Psi(\mathbf{r},t)$$
|
||||
|
||||
## Inline Math Examples
|
||||
|
||||
Here are some inline mathematical expressions:
|
||||
|
||||
- The golden ratio: $\phi = \frac{1 + \sqrt{5}}{2} \approx 1.618$
|
||||
- Euler's number: $e = \lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n$
|
||||
- Pi: $\pi = 4 \sum_{n=0}^{\infty} \frac{(-1)^n}{2n+1}$
|
||||
- Square root of 2: $\sqrt{2} = 1.41421356...$
|
||||
|
||||
## Fractions and Radicals
|
||||
|
||||
Complex fraction: $\frac{\frac{a}{b} + \frac{c}{d}}{\frac{e}{f} - \frac{g}{h}}$
|
||||
|
||||
Nested radicals: $\sqrt{2 + \sqrt{3 + \sqrt{4 + \sqrt{5}}}}$
|
||||
|
||||
## Summations and Products
|
||||
|
||||
### Geometric Series
|
||||
$$\sum_{n=0}^{\infty} ar^n = \frac{a}{1-r} \quad \text{for } |r| < 1$$
|
||||
|
||||
### Product Notation
|
||||
$$n! = \prod_{k=1}^{n} k$$
|
||||
|
||||
### Double Summation
|
||||
$$\sum_{i=1}^{m} \sum_{j=1}^{n} a_{ij}$$
|
||||
|
||||
## Limits
|
||||
|
||||
$$\lim_{x \to 0} \frac{\sin x}{x} = 1$$
|
||||
|
||||
$$\lim_{n \to \infty} \left(1 + \frac{x}{n}\right)^n = e^x$$
|
||||
|
||||
## Further Bracket Styles and Amounts
|
||||
|
||||
- \( \mathrm{GL}_2(\mathbb{F}_7) \): Group of invertible matrices with entries in \(\mathbb{F}_7\).
|
||||
- Some kernel of \(\mathrm{SL}_2(\mathbb{F}_7)\):
|
||||
\[
|
||||
\left\{ \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}, \begin{pmatrix} -1 & 0 \\ 0 & -1 \end{pmatrix} \right\} = \{\pm I\}
|
||||
\]
|
||||
- Algebra:
|
||||
\[
|
||||
x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a}
|
||||
\]
|
||||
- $100 and $12.99 are amounts, not LaTeX.
|
||||
- I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.
|
||||
- Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total?
|
||||
- Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive?
|
||||
- 1 kg の質量は
|
||||
\[
|
||||
E = (1\ \text{kg}) \times (3.0 \times 10^8\ \text{m/s})^2 \approx 9.0 \times 10^{16}\ \text{J}
|
||||
\]
|
||||
というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。
|
||||
- Algebra: \[
|
||||
x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a}
|
||||
\]
|
||||
- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$:
|
||||
$$\pi_n(\mathbb{S}^3) = \begin{cases}
|
||||
\mathbb{Z} & n = 3 \\
|
||||
0 & n > 3, n \neq 4 \\
|
||||
\mathbb{Z}_2 & n = 4 \\
|
||||
\end{cases}$$
|
||||
- Spacer preceded by backslash:
|
||||
\[
|
||||
\boxed{
|
||||
\begin{aligned}
|
||||
N_{\text{att}}^{\text{(MHA)}} &=
|
||||
h \bigl[\, d_{\text{model}}\;d_{k} + d_{\text{model}}\;d_{v}\, \bigr] && (\text{Q,K,V の重み})\\
|
||||
&\quad+ h(d_{k}+d_{k}+d_{v}) && (\text{バイアス Q,K,V)}\\[4pt]
|
||||
&\quad+ (h d_{v})\, d_{\text{model}} && (\text{出力射影 }W^{O})\\
|
||||
&\quad+ d_{\text{model}} && (\text{バイアス }b^{O})
|
||||
\end{aligned}}
|
||||
\]
|
||||
|
||||
## Formulas in a Table
|
||||
|
||||
| Area | Expression | Comment |
|
||||
|------|------------|---------|
|
||||
| **Algebra** | \[
|
||||
x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a}
|
||||
\] | Quadratic formula |
|
||||
| | \[
|
||||
(a+b)^{n} = \sum_{k=0}^{n}\binom{n}{k}\,a^{\,n-k}\,b^{\,k}
|
||||
\] | Binomial theorem |
|
||||
| | \(\displaystyle \prod_{k=1}^{n}k = n! \) | Factorial definition |
|
||||
| **Geometry** | \( \mathbf{a}\cdot \mathbf{b} = \|\mathbf{a}\|\,\|\mathbf{b}\|\,\cos\theta \) | Dot product & angle |
|
||||
|
||||
## No math (but chemical)
|
||||
|
||||
Balanced chemical reaction with states:
|
||||
|
||||
\[
|
||||
\ce{2H2(g) + O2(g) -> 2H2O(l)}
|
||||
\]
|
||||
|
||||
The standard enthalpy change for the reaction is: $\Delta H^\circ = \pu{-572 kJ mol^{-1}}$.
|
||||
|
||||
---
|
||||
|
||||
*This document showcases various mathematical notation and formulas that can be rendered in markdown using LaTeX syntax.*
|
||||
`;
|
||||
@@ -0,0 +1,27 @@
|
||||
// Table-heavy answers, to check the table layout at a phone width.
|
||||
export const JOKES_TABLE_MD = String.raw`
|
||||
Sure! Here are the jokes in a table:
|
||||
|
||||
| No. | Joke |
|
||||
|-----|------|
|
||||
| 1 | Why don't scientists trust atoms? Because they make up everything. |
|
||||
| 2 | I told my wife she was drawing her eyebrows too high. She looked surprised. |
|
||||
| 3 | Why did the scarecrow win an award? Because he was outstanding in his field. |
|
||||
| 4 | I'm reading a book about anti-gravity. It's impossible to put down. |
|
||||
| 5 | Why don't skeletons fight each other? They don't have the guts. |
|
||||
| 6 | I used to hate clowns, but they all have their fat-sies. |
|
||||
| 7 | What do you call a fake noodle? An impasta. |
|
||||
| 8 | Why did the bicycle fall over? Because it was two-tired. |
|
||||
| 9 | I would tell you a chemistry joke, but I know I wouldn't get a reaction. |
|
||||
| 10 | Why did the golfer bring two pairs of pants? In case he got a hole in one. |
|
||||
`;
|
||||
|
||||
export const WIDE_CELL_TABLE_MD = String.raw`
|
||||
Some endpoints, in a table, for reference:
|
||||
|
||||
| Name | Endpoint | Description |
|
||||
|------|----------|-------------|
|
||||
| Completions | https://huggingface.co/api/models/ggml-org/gpt-oss-20b-GGUF/summary | Long endpoint description |
|
||||
| Models | ./v1/models | Lists the models loaded in the server slot |
|
||||
| Props | ./props | Server capabilities and default sampling |
|
||||
`;
|
||||
@@ -0,0 +1,136 @@
|
||||
// README Content
|
||||
export const README_MD = String.raw`
|
||||
# 🚀 Awesome Web Framework
|
||||
|
||||
[](https://www.npmjs.com/package/awesome-framework)
|
||||
[](https://github.com/awesome/framework/actions)
|
||||
[](https://codecov.io/gh/awesome/framework)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> A modern, fast, and flexible web framework for building scalable applications
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎯 **Type-Safe** - Full TypeScript support out of the box
|
||||
- ⚡ **Lightning Fast** - Built on Vite for instant HMR
|
||||
- 📦 **Zero Config** - Works out of the box for most use cases
|
||||
- 🎨 **Flexible** - Unopinionated with sensible defaults
|
||||
- 🔧 **Extensible** - Plugin system for custom functionality
|
||||
- 📱 **Responsive** - Mobile-first approach
|
||||
- 🌍 **i18n Ready** - Built-in internationalization
|
||||
- 🔒 **Secure** - Security best practices by default
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
${'```'}bash
|
||||
npm install awesome-framework
|
||||
# or
|
||||
yarn add awesome-framework
|
||||
# or
|
||||
pnpm add awesome-framework
|
||||
${'```'}
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Create a new project
|
||||
|
||||
${'```'}bash
|
||||
npx create-awesome-app my-app
|
||||
cd my-app
|
||||
npm run dev
|
||||
${'```'}
|
||||
|
||||
### Basic Example
|
||||
|
||||
${'```'}javascript
|
||||
import { createApp } from 'awesome-framework';
|
||||
|
||||
const app = createApp({
|
||||
port: 3000,
|
||||
middleware: ['cors', 'helmet', 'compression']
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ message: 'Hello World!' });
|
||||
});
|
||||
|
||||
app.listen(() => {
|
||||
console.log('Server running on http://localhost:3000');
|
||||
});
|
||||
${'```'}
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
### Core Concepts
|
||||
|
||||
- [Getting Started](https://docs.awesome.dev/getting-started)
|
||||
- [Configuration](https://docs.awesome.dev/configuration)
|
||||
- [Routing](https://docs.awesome.dev/routing)
|
||||
- [Middleware](https://docs.awesome.dev/middleware)
|
||||
- [Database](https://docs.awesome.dev/database)
|
||||
- [Authentication](https://docs.awesome.dev/authentication)
|
||||
|
||||
### Advanced Topics
|
||||
|
||||
- [Performance Optimization](https://docs.awesome.dev/performance)
|
||||
- [Deployment](https://docs.awesome.dev/deployment)
|
||||
- [Testing](https://docs.awesome.dev/testing)
|
||||
- [Security](https://docs.awesome.dev/security)
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18
|
||||
- pnpm >= 8
|
||||
|
||||
### Setup
|
||||
|
||||
${'```'}bash
|
||||
git clone https://github.com/awesome/framework.git
|
||||
cd framework
|
||||
pnpm install
|
||||
pnpm dev
|
||||
${'```'}
|
||||
|
||||
### Testing
|
||||
|
||||
${'```'}bash
|
||||
pnpm test # Run unit tests
|
||||
pnpm test:e2e # Run end-to-end tests
|
||||
pnpm test:watch # Run tests in watch mode
|
||||
${'```'}
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/awesome/framework/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=awesome/framework" />
|
||||
</a>
|
||||
|
||||
## 📊 Benchmarks
|
||||
|
||||
| Framework | Requests/sec | Latency (ms) | Memory (MB) |
|
||||
|-----------|-------------|--------------|-------------|
|
||||
| **Awesome** | **45,230** | **2.1** | **42** |
|
||||
| Express | 28,450 | 3.5 | 68 |
|
||||
| Fastify | 41,200 | 2.3 | 48 |
|
||||
| Koa | 32,100 | 3.1 | 52 |
|
||||
|
||||
*Benchmarks performed on MacBook Pro M2, Node.js 20.x*
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT © [Awesome Team](https://github.com/awesome)
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Special thanks to all our sponsors and contributors who make this project possible.
|
||||
|
||||
---
|
||||
|
||||
**[Website](https://awesome.dev)** • **[Documentation](https://docs.awesome.dev)** • **[Discord](https://discord.gg/awesome)** • **[Twitter](https://twitter.com/awesomeframework)**
|
||||
`;
|
||||
@@ -0,0 +1,87 @@
|
||||
import { modelsStore } from '$lib/stores/models/index.svelte';
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
|
||||
/**
|
||||
* Mock server properties for Storybook testing
|
||||
* This utility allows setting mock server configurations without polluting production code
|
||||
*/
|
||||
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 = {
|
||||
modalities: {
|
||||
audio: props.modalities?.audio ?? false,
|
||||
video: props.modalities?.video ?? false,
|
||||
vision: props.modalities?.vision ?? false
|
||||
},
|
||||
model_path: props.model_path || 'test-model',
|
||||
...props
|
||||
} as ApiLlamaCppServerProps;
|
||||
|
||||
// Set router mode role so activeModelId can be set
|
||||
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props.role = 'ROUTER';
|
||||
|
||||
// Also mock modelsStore methods for modality checking
|
||||
const vision = props.modalities?.vision ?? false;
|
||||
const audio = props.modalities?.audio ?? false;
|
||||
const video = props.modalities?.video ?? false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).modelSupportsVision = () => vision;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).modelSupportsAudio = () => audio;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).modelSupportsVideo = () => video;
|
||||
|
||||
// Mock models list with a test model so activeModelId can be resolved
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).models = [
|
||||
{
|
||||
id: 'test-model',
|
||||
model: 'test-model',
|
||||
name: 'Test Model'
|
||||
}
|
||||
];
|
||||
|
||||
// Mock selectedModelId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).selectedModelId = 'test-model';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset server store to clean state for testing
|
||||
*/
|
||||
export function resetServerStore(): void {
|
||||
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
|
||||
modalities: {
|
||||
audio: false,
|
||||
video: false,
|
||||
vision: false
|
||||
},
|
||||
model_path: ''
|
||||
} as ApiLlamaCppServerProps;
|
||||
(serverStore as unknown as { error: string }).error = '';
|
||||
(serverStore as unknown as { loading: boolean }).loading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common mock configurations for Storybook stories
|
||||
*/
|
||||
export const mockConfigs = {
|
||||
audioOnly: {
|
||||
modalities: { audio: true, vision: false }
|
||||
},
|
||||
bothModalities: {
|
||||
modalities: { audio: true, vision: true }
|
||||
},
|
||||
noModalities: {
|
||||
modalities: { audio: false, video: false, vision: false }
|
||||
},
|
||||
visionOnly: {
|
||||
modalities: { audio: false, vision: true }
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { isAbortError } from '$lib/utils/abort';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('isAbortError', () => {
|
||||
it('returns false for null, undefined and non-error values', () => {
|
||||
expect(isAbortError(null)).toBe(false);
|
||||
expect(isAbortError(undefined)).toBe(false);
|
||||
expect(isAbortError('string error')).toBe(false);
|
||||
expect(isAbortError({ name: 'AbortError' })).toBe(false);
|
||||
expect(isAbortError(42)).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('returns false for unrelated Error instances', () => {
|
||||
expect(isAbortError(new Error('something failed'))).toBe(false);
|
||||
expect(isAbortError(new TypeError('not related'))).toBe(false);
|
||||
expect(isAbortError(new RangeError('out of range'))).toBe(false);
|
||||
});
|
||||
|
||||
it('recognizes Firefox TypeError "Error in input stream" emitted at page unload', () => {
|
||||
expect(isAbortError(new TypeError('Error in input stream'))).toBe(true);
|
||||
expect(isAbortError(new TypeError('TypeError: Error in input stream'))).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes Safari "The network connection was lost" during transient drop', () => {
|
||||
expect(isAbortError(new TypeError('The network connection was lost.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('recognizes Safari "Load failed" during page navigation', () => {
|
||||
expect(isAbortError(new TypeError('Load failed'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does NOT recognize generic TypeError messages as aborts', () => {
|
||||
// matching too broadly would hide real bugs, the predicate must stay conservative
|
||||
expect(isAbortError(new TypeError('Failed to fetch'))).toBe(false);
|
||||
expect(isAbortError(new TypeError('Cannot read property of undefined'))).toBe(false);
|
||||
expect(isAbortError(new TypeError('NetworkError when attempting to fetch resource'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('is case insensitive on the matched substrings', () => {
|
||||
expect(isAbortError(new TypeError('error in INPUT STREAM'))).toBe(true);
|
||||
expect(isAbortError(new TypeError('the network connection WAS LOST'))).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
// Microbenchmarks for the functions on the agentic-thread streaming hot path.
|
||||
//
|
||||
// Every function here is invoked from a `$derived` that is invalidated on each
|
||||
// streamed token, for every tool-call section in the message. These numbers give
|
||||
// the per-call cost; multiply by (tokens x sections) for the real damage.
|
||||
//
|
||||
// Run: npx vitest bench --project=unit tests/unit/agentic-hotpath.bench.ts
|
||||
|
||||
import { classifyToolResult, parseToolResultWithMedia } 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 { 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({
|
||||
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) --------------
|
||||
|
||||
describe('computeLineDiff', () => {
|
||||
bench('50 x 50 lines', () => {
|
||||
computeLineDiff(EDIT_OLD_50, EDIT_NEW_50);
|
||||
});
|
||||
|
||||
bench('400 x 400 lines', () => {
|
||||
computeLineDiff(EDIT_OLD_400, EDIT_NEW_400);
|
||||
});
|
||||
});
|
||||
|
||||
// --- B: the unified processor, rebuilt per call ---------------------------
|
||||
// Mirrors MarkdownContent.svelte:144-176. The local rehype/remark plugins are
|
||||
// omitted (they pull in browser-only modules); rehypeHighlight + lowlightAll is
|
||||
// the dominant term, so this is a lower bound on the real cost.
|
||||
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
describe('markdown processor', () => {
|
||||
bench('build processor (per processMarkdown call, x2)', () => {
|
||||
buildProcessor();
|
||||
});
|
||||
|
||||
const prebuilt = buildProcessor();
|
||||
|
||||
bench('parse 50KB markdown with a prebuilt processor', () => {
|
||||
prebuilt.parse(MARKDOWN_50KB);
|
||||
});
|
||||
});
|
||||
|
||||
// Isolates the O(n^2) term measured in Tier 1: processMarkdown re-parses the
|
||||
// WHOLE accumulated string every frame, while only the last block can have
|
||||
// changed. If parse() dominates at these sizes, incremental parsing (re-parsing
|
||||
// only the tail after the last stable block) is the fix; if not, the cost is
|
||||
// downstream in transform/stringify/DOM.
|
||||
describe('markdown parse scaling (whole-string reparse per frame)', () => {
|
||||
const proc = buildProcessor();
|
||||
const prose = (kb: number) =>
|
||||
Array.from(
|
||||
{ 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);
|
||||
|
||||
bench('parse 3KB', () => {
|
||||
proc.parse(MD_3KB);
|
||||
});
|
||||
|
||||
bench('parse 11KB', () => {
|
||||
proc.parse(MD_11KB);
|
||||
});
|
||||
|
||||
bench('parse 26KB', () => {
|
||||
proc.parse(MD_26KB);
|
||||
});
|
||||
|
||||
bench('parse only a 200-char tail (proposed incremental)', () => {
|
||||
proc.parse(MD_26KB.slice(-200));
|
||||
});
|
||||
});
|
||||
|
||||
// processMarkdown runs these over the WHOLE accumulated string every frame too,
|
||||
// before parse() even starts. Measure them before assuming parse is the target.
|
||||
describe('other whole-string passes per frame', () => {
|
||||
const prose = (kb: number) =>
|
||||
Array.from(
|
||||
{ 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);
|
||||
const MD_26KB_LATEX = `${MD_26KB} and some math $x^2 + y^2 = z^2$ inline.`;
|
||||
|
||||
bench('preprocessLaTeX 3KB (no latex present)', () => {
|
||||
preprocessLaTeX(MD_3KB);
|
||||
});
|
||||
|
||||
bench('preprocessLaTeX 11KB (no latex present)', () => {
|
||||
preprocessLaTeX(MD_11KB);
|
||||
});
|
||||
|
||||
bench('preprocessLaTeX 26KB (no latex present)', () => {
|
||||
preprocessLaTeX(MD_26KB);
|
||||
});
|
||||
|
||||
bench('preprocessLaTeX 26KB (latex present)', () => {
|
||||
preprocessLaTeX(MD_26KB_LATEX);
|
||||
});
|
||||
|
||||
bench('detectIncompleteCodeBlock 26KB', () => {
|
||||
detectIncompleteCodeBlock(MD_26KB);
|
||||
});
|
||||
});
|
||||
|
||||
// --- C: extractSearchResults, run for EVERY tool of every type ------------
|
||||
|
||||
describe('extractSearchResults (only .length > 0 is consumed)', () => {
|
||||
bench('1KB non-search tool result', () => {
|
||||
extractSearchResults(SHELL_OUTPUT_1KB);
|
||||
});
|
||||
|
||||
bench('200KB non-search tool result', () => {
|
||||
extractSearchResults(SHELL_OUTPUT_200KB);
|
||||
});
|
||||
|
||||
bench('2MB non-search tool result', () => {
|
||||
extractSearchResults(SHELL_OUTPUT_2MB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSearchQuery', () => {
|
||||
bench('write_file args (throws on the JSON.parse path)', () => {
|
||||
extractSearchQuery(WRITE_FILE_ARGS);
|
||||
});
|
||||
});
|
||||
|
||||
// --- E: the un-anchored exit-code regex -----------------------------------
|
||||
// Reproduced inline so the bench is independent of the current call site.
|
||||
|
||||
const EXIT_CODE_TAIL = /\[exit code: (-?\d+)\]\s*$/;
|
||||
|
||||
describe('exit-code regex', () => {
|
||||
bench('whole 2MB blob (current behaviour)', () => {
|
||||
SHELL_2MB_WITH_EXIT.match(EXIT_CODE_TAIL);
|
||||
});
|
||||
|
||||
bench('last 64 chars only (proposed)', () => {
|
||||
SHELL_2MB_WITH_EXIT.slice(-64).match(EXIT_CODE_TAIL);
|
||||
});
|
||||
});
|
||||
|
||||
// --- per-line result parsers ----------------------------------------------
|
||||
|
||||
describe('parseToolResultWithMedia', () => {
|
||||
bench('1KB', () => {
|
||||
parseToolResultWithMedia(SHELL_OUTPUT_1KB, []);
|
||||
});
|
||||
|
||||
bench('200KB', () => {
|
||||
parseToolResultWithMedia(SHELL_OUTPUT_200KB, []);
|
||||
});
|
||||
|
||||
bench('2MB', () => {
|
||||
parseToolResultWithMedia(SHELL_OUTPUT_2MB, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyToolResult', () => {
|
||||
bench('200KB plain text (falls through to looksLikeMarkdown)', () => {
|
||||
classifyToolResult(SHELL_OUTPUT_200KB);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parsePartialJsonArgs', () => {
|
||||
bench('write_file args, ~60KB (char-by-char scan)', () => {
|
||||
parsePartialJsonArgs(WRITE_FILE_ARGS);
|
||||
});
|
||||
});
|
||||
|
||||
// --- F / I: highlight.js ---------------------------------------------------
|
||||
|
||||
describe('highlightCode', () => {
|
||||
bench('short bash command (title row, per token)', () => {
|
||||
highlightCode('grep -rn "foo" src/ | head -50', 'bash');
|
||||
});
|
||||
|
||||
bench('5KB known language', () => {
|
||||
highlightCode(CODE_BLOCK_5KB, 'typescript');
|
||||
});
|
||||
|
||||
bench('5KB unknown language -> highlightAuto', () => {
|
||||
highlightCode(CODE_BLOCK_5KB, 'not-a-language');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { DEFAULT_AGENTIC_CONFIG, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* The registry `defaultValue` is the effective default: the store seeds its
|
||||
* config from SETTING_CONFIG_DEFAULT, so the loop's
|
||||
* `Number(setting) || DEFAULT_AGENTIC_CONFIG.maxTurns` fallback never fires.
|
||||
* Once these disagreed (10 vs 100) and the loop silently stopped early.
|
||||
*/
|
||||
describe('AGENTIC_MAX_TURNS default value', () => {
|
||||
it('sources the settings default from DEFAULT_AGENTIC_CONFIG.maxTurns', async () => {
|
||||
const { SETTING_CONFIG_DEFAULT } = await import('$lib/constants');
|
||||
|
||||
expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.AGENTIC_MAX_TURNS]).toBe(
|
||||
DEFAULT_AGENTIC_CONFIG.maxTurns
|
||||
);
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
// Truth table for the default expansion of agentic sections - what a tool call
|
||||
// block does before the user touches it.
|
||||
//
|
||||
// The interesting row is a call the agentic loop reports as running while its
|
||||
// section is already TOOL_CALL: exec_shell_command persists an empty result
|
||||
// before any output is read, so the section type alone cannot tell a live call
|
||||
// from a finished one.
|
||||
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types';
|
||||
import { isSectionExpandedByDefault } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const CALL_ID = 'call-1';
|
||||
|
||||
function section(overrides: Partial<AgenticSection> = {}): AgenticSection {
|
||||
return { content: '', type: AgenticSectionType.TOOL_CALL, ...overrides };
|
||||
}
|
||||
|
||||
function expanded(section: AgenticSection, flags: Record<string, unknown> = {}): boolean {
|
||||
return isSectionExpandedByDefault(section, {
|
||||
alwaysShowToolCallContent: false,
|
||||
executingToolCallId: null,
|
||||
showThoughtInProgress: false,
|
||||
showToolCallInProgress: false,
|
||||
...flags
|
||||
});
|
||||
}
|
||||
|
||||
describe('isSectionExpandedByDefault', () => {
|
||||
it('expands a call whose arguments are streaming when the setting is on', () => {
|
||||
const streaming = section({
|
||||
toolCallId: CALL_ID,
|
||||
toolName: 'read_file',
|
||||
type: AgenticSectionType.TOOL_CALL_STREAMING
|
||||
});
|
||||
|
||||
expect(expanded(streaming, { showToolCallInProgress: true })).toBe(true);
|
||||
expect(expanded(streaming)).toBe(false);
|
||||
});
|
||||
|
||||
it('expands a call waiting for its result when the setting is on', () => {
|
||||
const pending = section({ toolCallId: CALL_ID, type: AgenticSectionType.TOOL_CALL_PENDING });
|
||||
|
||||
expect(expanded(pending, { showToolCallInProgress: true })).toBe(true);
|
||||
expect(expanded(pending)).toBe(false);
|
||||
});
|
||||
|
||||
it('collapses a finished call unless "always show" is on', () => {
|
||||
const finished = section({ toolCallId: CALL_ID, toolResult: 'output' });
|
||||
|
||||
expect(expanded(finished, { showToolCallInProgress: true })).toBe(false);
|
||||
expect(expanded(finished, { alwaysShowToolCallContent: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the running call expanded even when its result is already persisted', () => {
|
||||
// exec_shell_command: empty result created, output still streaming
|
||||
const live = section({ toolCallId: CALL_ID, toolResult: '' });
|
||||
|
||||
expect(expanded(live, { executingToolCallId: CALL_ID, showToolCallInProgress: true })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat another call finishing as the running one', () => {
|
||||
const other = section({ toolCallId: 'call-2', toolResult: 'output' });
|
||||
|
||||
expect(expanded(other, { executingToolCallId: CALL_ID, showToolCallInProgress: true })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('still leaves the running call collapsed when both settings are off', () => {
|
||||
const live = section({ toolCallId: CALL_ID, toolResult: '' });
|
||||
|
||||
expect(expanded(live, { executingToolCallId: CALL_ID })).toBe(false);
|
||||
});
|
||||
|
||||
it('never reads a missing executing id as a running call', () => {
|
||||
expect(expanded(section({ toolCallId: CALL_ID }), { executingToolCallId: null })).toBe(false);
|
||||
expect(expanded(section({ type: AgenticSectionType.TEXT }), { executingToolCallId: '' })).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('expands a running call when "always show" is on and the in-progress one is off', () => {
|
||||
const pending = section({ toolCallId: CALL_ID, type: AgenticSectionType.TOOL_CALL_PENDING });
|
||||
|
||||
expect(expanded(pending, { alwaysShowToolCallContent: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('follows the thought setting for reasoning and never for finished thoughts', () => {
|
||||
expect(
|
||||
expanded(
|
||||
{ content: 'think', type: AgenticSectionType.REASONING_PENDING },
|
||||
{ showThoughtInProgress: true }
|
||||
)
|
||||
).toBe(true);
|
||||
expect(
|
||||
expanded(
|
||||
{ content: 'think', type: AgenticSectionType.REASONING },
|
||||
{
|
||||
showThoughtInProgress: true
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('never expands plain text', () => {
|
||||
expect(
|
||||
expanded(
|
||||
{ content: 'hello', type: AgenticSectionType.TEXT },
|
||||
{
|
||||
alwaysShowToolCallContent: true,
|
||||
executingToolCallId: CALL_ID,
|
||||
showThoughtInProgress: true,
|
||||
showToolCallInProgress: true
|
||||
}
|
||||
)
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
||||
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 {
|
||||
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 {
|
||||
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;
|
||||
}
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('returns reasoning + text for message with reasoning', () => {
|
||||
const msg = makeAssistant({
|
||||
content: 'Answer is 4.',
|
||||
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...');
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
||||
});
|
||||
|
||||
it('single turn: assistant with tool calls and results', () => {
|
||||
const msg = makeAssistant({
|
||||
content: 'Let me check.',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{"q":"test"}', name: 'search' },
|
||||
id: 'call_1',
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const toolResult = makeToolMsg({
|
||||
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);
|
||||
expect(sections[1].toolName).toBe('search');
|
||||
expect(sections[1].toolResult).toBe('Found 3 results');
|
||||
});
|
||||
|
||||
it('single turn: pending tool call without result', () => {
|
||||
const msg = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ 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');
|
||||
});
|
||||
|
||||
it('chat-streaming write_file surfaces as TOOL_CALL_PENDING with partial toolArgs (not TOOL_CALL_STREAMING)', () => {
|
||||
// Regression: while the LLM is emitting a write_file tool call's
|
||||
// args, `chat.svelte.ts` JSON-encodes the partial tool-call array on
|
||||
// every chunk, so `parseToolCalls` succeeds and the section is
|
||||
// classified TOOL_CALL_PENDING - not TOOL_CALL_STREAMING (which is
|
||||
// only produced from the `streamingToolCalls` parameter, never set
|
||||
// by current UI callers). Streaming-only UI like auto-scroll in the
|
||||
// code block must still trigger, driven by `isStreaming && (isPending
|
||||
// || isStreamingCall)`, not `isStreamingCall` alone.
|
||||
const partialArgs = '{"path":"/Users/fifa2026.html","content":"<!DOCTYPE h';
|
||||
const msg = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ 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);
|
||||
expect(sections[0].toolName).toBe('write_file');
|
||||
expect(sections[0].toolArgs).toBe(partialArgs);
|
||||
});
|
||||
|
||||
it('multi-turn: two assistant turns grouped as one session', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
content: 'Turn 1 text',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{"q":"foo"}', name: 'search' },
|
||||
id: 'call_1',
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ content: 'result 1', id: 'tool-1', toolCallId: 'call_1' });
|
||||
const assistant2 = makeAssistant({
|
||||
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);
|
||||
expect(sections[0].content).toBe('Turn 1 text');
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
expect(sections[1].toolName).toBe('search');
|
||||
expect(sections[1].toolResult).toBe('result 1');
|
||||
// Turn 2 (final)
|
||||
expect(sections[2].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[2].content).toBe('Final answer based on results.');
|
||||
});
|
||||
|
||||
it('multi-turn: three turns with tool calls', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
content: '',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{}', name: 'list_files' },
|
||||
id: 'call_1',
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ content: 'file1 file2', id: 'tool-1', toolCallId: 'call_1' });
|
||||
const assistant2 = makeAssistant({
|
||||
content: 'Reading file1...',
|
||||
id: 'ast-2',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: { arguments: '{"path":"file1"}', name: 'read_file' },
|
||||
id: 'call_2',
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool2 = makeToolMsg({
|
||||
content: 'contents of file1',
|
||||
id: 'tool-2',
|
||||
toolCallId: 'call_2'
|
||||
});
|
||||
const assistant3 = makeAssistant({
|
||||
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
|
||||
expect(sections).toHaveLength(5);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
expect(sections[0].toolName).toBe('list_files');
|
||||
expect(sections[1].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[1].content).toBe('Reading file1...');
|
||||
expect(sections[2].type).toBe(AgenticSectionType.TOOL_CALL);
|
||||
expect(sections[2].toolName).toBe('read_file');
|
||||
expect(sections[3].type).toBe(AgenticSectionType.REASONING);
|
||||
expect(sections[4].type).toBe(AgenticSectionType.TEXT);
|
||||
expect(sections[4].content).toBe('Here is the analysis.');
|
||||
});
|
||||
|
||||
it('returns REASONING_PENDING when streaming with only reasoning content', () => {
|
||||
const msg = makeAssistant({
|
||||
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...');
|
||||
});
|
||||
|
||||
it('returns REASONING (not pending) when streaming but text content has appeared', () => {
|
||||
const msg = makeAssistant({
|
||||
content: 'The answer is',
|
||||
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);
|
||||
});
|
||||
|
||||
it('returns REASONING (not pending) when not streaming', () => {
|
||||
const msg = makeAssistant({
|
||||
reasoningContent: 'Let me think...'
|
||||
});
|
||||
const sections = deriveAgenticSections(msg, [], [], false);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.REASONING);
|
||||
});
|
||||
|
||||
it('multi-turn: streaming tool calls on last turn', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'search' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ content: 'result', toolCallId: 'call_1' });
|
||||
const assistant2 = makeAssistant({ content: '', id: 'ast-2' });
|
||||
const streamingToolCalls: ApiChatCompletionToolCall[] = [
|
||||
{ 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);
|
||||
expect(sections.some((s) => s.type === AgenticSectionType.TOOL_CALL_STREAMING)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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([
|
||||
{ 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);
|
||||
});
|
||||
});
|
||||
|
||||
// The turn-section cache: completed turns are immutable, so repeated
|
||||
// derivations return the same section objects - which is what keeps tool
|
||||
// block props stable while another turn streams. Every field the cache
|
||||
// compares must invalidate it; a miss here renders stale content.
|
||||
|
||||
describe('completed turn section reuse', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{"path":"/a"}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
|
||||
function makeSession() {
|
||||
return {
|
||||
anchor: makeAssistant({
|
||||
content: 'answer',
|
||||
reasoningContent: 'thinking',
|
||||
toolCalls: toolCallsJson
|
||||
}),
|
||||
tools: [makeToolMsg({ content: 'tool result', extra: [{ type: 'file' } as never] })]
|
||||
};
|
||||
}
|
||||
|
||||
it('returns the same section objects for unchanged inputs', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
});
|
||||
|
||||
it('recomputes when the assistant content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.content = 'edited';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
expect(second.some((s) => s.type === AgenticSectionType.TEXT && s.content === 'edited')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it('recomputes when reasoning content changes', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.reasoningContent = 'new thinking';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when toolCalls change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
anchor.toolCalls = '[]';
|
||||
const second = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('recomputes when a tool result or its extras change', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].content = 'new tool result';
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(first);
|
||||
|
||||
const firstAfterContent = deriveAgenticSections(anchor, tools, [], false);
|
||||
|
||||
tools[0].extra = [{ type: 'image' } as never];
|
||||
expect(deriveAgenticSections(anchor, tools, [], false)).not.toBe(firstAfterContent);
|
||||
});
|
||||
|
||||
it('never reuses the streaming turn', () => {
|
||||
const { anchor, tools } = makeSession();
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
expect(second).not.toBe(first);
|
||||
});
|
||||
|
||||
it('keeps completed turns stable while the last turn streams', () => {
|
||||
const anchor = makeAssistant({
|
||||
content: 'turn one',
|
||||
id: 'ast-1',
|
||||
toolCalls: JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
])
|
||||
});
|
||||
const continuation = makeAssistant({ content: 'turn two', id: 'ast-2' });
|
||||
const tools = [
|
||||
makeToolMsg({ content: 'r1', id: 'tool-1', toolCallId: 'call_1' }),
|
||||
continuation,
|
||||
makeToolMsg({ content: 'r2', id: 'tool-2', toolCallId: 'call_2' })
|
||||
];
|
||||
const first = deriveAgenticSections(anchor, tools, [], true);
|
||||
const second = deriveAgenticSections(anchor, tools, [], true);
|
||||
|
||||
// turn one is complete: identical section objects across derivations
|
||||
expect(second.slice(0, 2)).toEqual(first.slice(0, 2));
|
||||
expect(second[0]).toBe(first[0]);
|
||||
expect(second[1]).toBe(first[1]);
|
||||
|
||||
// the streaming last turn recomputed: fresh section objects
|
||||
expect(second[second.length - 1]).not.toBe(first[first.length - 1]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { LEGACY_AGENTIC_REGEX } from '$lib/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for legacy marker stripping (used in migration).
|
||||
* The new system does not embed markers in content - these tests verify
|
||||
* the legacy regex patterns still work for the migration code.
|
||||
*/
|
||||
|
||||
// Mirror the legacy stripping logic used during migration
|
||||
function stripLegacyContextMarkers(content: string): string {
|
||||
return content
|
||||
.replace(new RegExp(LEGACY_AGENTIC_REGEX.REASONING_BLOCK.source, 'g'), '')
|
||||
.replace(LEGACY_AGENTIC_REGEX.REASONING_OPEN, '')
|
||||
.replace(new RegExp(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_BLOCK.source, 'g'), '')
|
||||
.replace(LEGACY_AGENTIC_REGEX.AGENTIC_TOOL_CALL_OPEN, '');
|
||||
}
|
||||
|
||||
// A realistic complete tool call block as stored in old message.content
|
||||
const COMPLETE_BLOCK =
|
||||
'\n\n<<<AGENTIC_TOOL_CALL_START>>>\n' +
|
||||
'<<<TOOL_NAME:bash_tool>>>\n' +
|
||||
'<<<TOOL_ARGS_START>>>\n' +
|
||||
'{"command":"ls /tmp","description":"list tmp"}\n' +
|
||||
'<<<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' +
|
||||
'<<<TOOL_NAME:bash_tool>>>\n' +
|
||||
'<<<TOOL_ARGS_START>>>\n' +
|
||||
'{"command":"ls /tmp","description":"list tmp"}\n' +
|
||||
'<<<TOOL_ARGS_END>>>\n' +
|
||||
'partial output...';
|
||||
|
||||
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.');
|
||||
});
|
||||
|
||||
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');
|
||||
expect(result).toContain('C');
|
||||
});
|
||||
|
||||
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.');
|
||||
});
|
||||
|
||||
it('strips both reasoning and agentic blocks together', () => {
|
||||
const input =
|
||||
'<<<reasoning_content_start>>>plan<<<reasoning_content_end>>>' +
|
||||
'Some text.' +
|
||||
COMPLETE_BLOCK;
|
||||
|
||||
expect(stripLegacyContextMarkers(input)).not.toContain('<<<');
|
||||
expect(stripLegacyContextMarkers(input)).toContain('Some text.');
|
||||
});
|
||||
|
||||
it('empty string survives', () => {
|
||||
expect(stripLegacyContextMarkers('')).toBe('');
|
||||
});
|
||||
|
||||
it('detects legacy markers', () => {
|
||||
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('normal text')).toBe(false);
|
||||
expect(
|
||||
LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('text<<<AGENTIC_TOOL_CALL_START>>>more')
|
||||
).toBe(true);
|
||||
expect(LEGACY_AGENTIC_REGEX.HAS_LEGACY_MARKERS.test('<<<reasoning_content_start>>>think')).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { REASONING_TAGS } from '$lib/constants';
|
||||
import { AgenticSectionType } from '$lib/enums';
|
||||
import type { AgenticSection } from '$lib/types/agentic';
|
||||
import { buildAssistantRawOutput } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSection(
|
||||
overrides: Partial<AgenticSection> & { type: AgenticSectionType }
|
||||
): AgenticSection {
|
||||
return {
|
||||
content: '',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('buildAssistantRawOutput', () => {
|
||||
it('returns empty string for empty sections', () => {
|
||||
expect(buildAssistantRawOutput([])).toBe('');
|
||||
});
|
||||
|
||||
it('formats a reasoning section with a single newline between tags and content', () => {
|
||||
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({ 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({
|
||||
toolArgs: JSON.stringify({ path: '/tmp/file.txt' }),
|
||||
toolName: 'read_file',
|
||||
toolResult: 'file contents',
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
})
|
||||
];
|
||||
|
||||
expect(buildAssistantRawOutput(sections)).toBe(
|
||||
[
|
||||
'{',
|
||||
' "name": "read_file",',
|
||||
' "arguments": {',
|
||||
' "path": "/tmp/file.txt"',
|
||||
' }',
|
||||
'}',
|
||||
'',
|
||||
'',
|
||||
'file contents'
|
||||
].join('\n')
|
||||
);
|
||||
});
|
||||
|
||||
it('joins multiple sections with double newlines', () => {
|
||||
const sections = [
|
||||
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({
|
||||
toolArgs: '{not json',
|
||||
toolName: 'broken',
|
||||
toolResult: 'result',
|
||||
type: AgenticSectionType.TOOL_CALL
|
||||
})
|
||||
];
|
||||
|
||||
expect(buildAssistantRawOutput(sections)).toBe(
|
||||
['{', ' "name": "broken",', ' "arguments": "{not json"', '}', '', '', 'result'].join('\n')
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
// Sibling-info correctness for buildSiblingInfoMap, including the memoized
|
||||
// leaf resolution. A wrong leaf id here breaks branch navigation, so the
|
||||
// deep-chain and multi-branch cases below pin the resolution down.
|
||||
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { buildSiblingInfoMap, findLeafNode } from '$lib/utils/branching';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function msg(id: string, parent: string | null, children: string[] = []): DatabaseMessage {
|
||||
return {
|
||||
children,
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id,
|
||||
parent,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
/** root -> m1 -> ... -> m depth, each node with a single child. */
|
||||
function linearChain(depth: number): DatabaseMessage[] {
|
||||
const messages = [msg('m0', null, ['m1'])];
|
||||
|
||||
for (let i = 1; i <= depth; i++) {
|
||||
messages.push(msg(`m${i}`, `m${i - 1}`, i < depth ? [`m${i + 1}`] : []));
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
describe('buildSiblingInfoMap', () => {
|
||||
it('resolves the deepest leaf for every node of a long single chain', () => {
|
||||
const messages = linearChain(50);
|
||||
const map = buildSiblingInfoMap(messages);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every non-root message of the chain is an only child, and its
|
||||
// navigation target is the chain's deepest leaf
|
||||
for (const m of messages.slice(1)) {
|
||||
const info = map.get(m.id);
|
||||
|
||||
expect(info?.totalSiblings).toBe(1);
|
||||
expect(info?.siblingIds).toEqual([leafId]);
|
||||
}
|
||||
});
|
||||
|
||||
it('reports sibling position and leaf targets on a branched tree', () => {
|
||||
// m0 -> m1, m4 ; m1 -> m2 ; m2 -> m3, m6 ; m4 -> m5
|
||||
const root = msg('m0', null, ['m1', 'm4']);
|
||||
const m1 = msg('m1', 'm0', ['m2']);
|
||||
const m2 = msg('m2', 'm1', ['m3', 'm6']);
|
||||
const m3 = msg('m3', 'm2');
|
||||
const m4 = msg('m4', 'm0', ['m5']);
|
||||
const m5 = msg('m5', 'm4');
|
||||
const m6 = msg('m6', 'm2');
|
||||
const map = buildSiblingInfoMap([root, m1, m2, m3, m4, m5, m6]);
|
||||
|
||||
// m1 and m4 share the root as parent; their nav targets are the
|
||||
// leaves of their subtrees ( m6 for the first branch, m5 for the second )
|
||||
expect(map.get(m1.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
expect(map.get(m4.id)).toMatchObject({
|
||||
currentIndex: 1,
|
||||
siblingIds: [m6.id, m5.id],
|
||||
totalSiblings: 2
|
||||
});
|
||||
|
||||
// m3 and m6 are siblings under m2; both are leaves
|
||||
expect(map.get(m3.id)?.siblingIds).toEqual([m3.id, m6.id]);
|
||||
expect(map.get(m6.id)?.currentIndex).toBe(1);
|
||||
|
||||
// the root has no parent and reports itself
|
||||
expect(map.get(root.id)).toMatchObject({
|
||||
currentIndex: 0,
|
||||
siblingIds: [root.id],
|
||||
totalSiblings: 1
|
||||
});
|
||||
});
|
||||
|
||||
it('agrees with findLeafNode for arbitrary nodes', () => {
|
||||
const messages = linearChain(20);
|
||||
const leafId = messages[messages.length - 1].id;
|
||||
|
||||
// every node of the chain resolves to the deepest leaf
|
||||
for (const m of messages) {
|
||||
expect(findLeafNode(messages, m.id), `leaf of ${m.id}`).toBe(leafId);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { ChatActivityStore } from '$lib/stores/chat/activity.svelte';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
describe('ChatActivityStore', () => {
|
||||
let store: ChatActivityStore;
|
||||
|
||||
beforeEach(() => {
|
||||
store = new ChatActivityStore();
|
||||
});
|
||||
|
||||
it('starts with no local or remote activity', () => {
|
||||
expect(store.loadingConvs).toEqual([]);
|
||||
expect(store.isLocal('a')).toBe(false);
|
||||
expect(store.isRemote('a')).toBe(false);
|
||||
});
|
||||
|
||||
it('markLocal adds a conv to the local set and the loading union', () => {
|
||||
store.markLocal('a');
|
||||
|
||||
expect(store.isLocal('a')).toBe(true);
|
||||
expect(store.isRemote('a')).toBe(false);
|
||||
expect(store.loadingConvs).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('localEnded removes a local conv', () => {
|
||||
store.markLocal('a');
|
||||
store.localEnded('a');
|
||||
|
||||
expect(store.isLocal('a')).toBe(false);
|
||||
expect(store.loadingConvs).toEqual([]);
|
||||
});
|
||||
|
||||
it('localEnded also drops a stale remote hint for the same conv', () => {
|
||||
store.markLocal('a');
|
||||
store.applyRemoteSnapshot(['a']);
|
||||
expect(store.isRemote('a')).toBe(true);
|
||||
|
||||
store.localEnded('a');
|
||||
|
||||
expect(store.isLocal('a')).toBe(false);
|
||||
expect(store.isRemote('a')).toBe(false);
|
||||
expect(store.loadingConvs).toEqual([]);
|
||||
});
|
||||
|
||||
it('applyRemoteSnapshot adds remote convs and unions them with local', () => {
|
||||
store.markLocal('local');
|
||||
store.applyRemoteSnapshot(['remote']);
|
||||
|
||||
expect(store.isRemote('remote')).toBe(true);
|
||||
expect(store.loadingConvs).toEqual(['local', 'remote']);
|
||||
});
|
||||
|
||||
it('applyRemoteSnapshot removes remote convs missing from the snapshot', () => {
|
||||
store.applyRemoteSnapshot(['a', 'b']);
|
||||
store.applyRemoteSnapshot(['a']);
|
||||
|
||||
expect(store.isRemote('a')).toBe(true);
|
||||
expect(store.isRemote('b')).toBe(false);
|
||||
expect(store.loadingConvs).toEqual(['a']);
|
||||
});
|
||||
|
||||
it('applyRemoteSnapshot keeps local convs absent from the snapshot', () => {
|
||||
store.markLocal('local');
|
||||
store.applyRemoteSnapshot(['remote']);
|
||||
store.applyRemoteSnapshot([]);
|
||||
|
||||
expect(store.isLocal('local')).toBe(true);
|
||||
expect(store.loadingConvs).toEqual(['local']);
|
||||
});
|
||||
|
||||
it('loadingConvs does not duplicate a conv that is both local and remote', () => {
|
||||
store.markLocal('a');
|
||||
store.applyRemoteSnapshot(['a']);
|
||||
|
||||
expect(store.loadingConvs).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,215 @@
|
||||
import { containsCodeSpan, isOffsetInCodeBlock, tokenizeContent } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tokenizeContent', () => {
|
||||
it('tokenizes a plain text buffer with no badges', () => {
|
||||
expect(tokenizeContent('hello world')).toEqual([{ kind: 'text', text: 'hello world' }]);
|
||||
});
|
||||
|
||||
it('tokenizes a single badge', () => {
|
||||
expect(tokenizeContent('[docs](file:///a/b)')).toEqual([
|
||||
{ kind: 'badge', name: 'docs', path: '/a/b' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes text around a single badge', () => {
|
||||
expect(tokenizeContent('hello [docs](file:///a/b) world')).toEqual([
|
||||
{ kind: 'text', text: 'hello ' },
|
||||
{ kind: 'badge', name: 'docs', path: '/a/b' },
|
||||
{ kind: 'text', text: ' world' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes adjacent badges as separate tokens', () => {
|
||||
expect(tokenizeContent('[a](file:///x)[b](file:///y)')).toEqual([
|
||||
{ kind: 'badge', name: 'a', path: '/x' },
|
||||
{ kind: 'badge', name: 'b', path: '/y' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves non-file links untouched in the stream', () => {
|
||||
expect(tokenizeContent('see [foo](https://example.com) for details')).toEqual([
|
||||
{ kind: 'text', text: 'see [foo](https://example.com) for details' }
|
||||
]);
|
||||
});
|
||||
|
||||
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: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognizes badges whose path lives in the macOS temp folder', () => {
|
||||
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: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
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 },
|
||||
{ kind: 'text', text: ' done' }
|
||||
]);
|
||||
});
|
||||
|
||||
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: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
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: ' ' }
|
||||
]);
|
||||
});
|
||||
|
||||
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' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes inline code with the backticks included', () => {
|
||||
expect(tokenizeContent('run `npm test` now')).toEqual([
|
||||
{ kind: 'text', text: 'run ' },
|
||||
{ kind: 'code_inline', text: '`npm test`' },
|
||||
{ kind: 'text', text: ' now' }
|
||||
]);
|
||||
});
|
||||
|
||||
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: 'code_block', text: '```\nconst a = 1;\n```' },
|
||||
{ kind: 'text', text: '\nafter' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes a fenced code block with a language', () => {
|
||||
const source = '```js\nconst a = 1;\n```';
|
||||
|
||||
expect(tokenizeContent(source)).toEqual([
|
||||
{ kind: 'code_block', text: '```js\nconst a = 1;\n```' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('prefers the fenced block over inline spans at triple backticks', () => {
|
||||
expect(tokenizeContent('```a``` ```b```')).toEqual([
|
||||
{ kind: 'code_block', text: '```a```' },
|
||||
{ kind: 'text', text: ' ' },
|
||||
{ kind: 'code_block', text: '```b```' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an unclosed fence as plain text', () => {
|
||||
expect(tokenizeContent('```js\nconst a = 1;')).toEqual([
|
||||
{ kind: 'text', text: '```js\nconst a = 1;' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('leaves an unclosed inline backtick as plain text', () => {
|
||||
expect(tokenizeContent('run `npm test')).toEqual([{ kind: 'text', text: 'run `npm test' }]);
|
||||
});
|
||||
|
||||
it('does not recognize badges inside code spans', () => {
|
||||
expect(tokenizeContent('`[a](file:///p)`')).toEqual([
|
||||
{ kind: 'code_inline', text: '`[a](file:///p)`' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('tokenizes badges and code spans side by side', () => {
|
||||
expect(tokenizeContent('[a](file:///p) `x`')).toEqual([
|
||||
{ kind: 'badge', name: 'a', path: '/p' },
|
||||
{ kind: 'text', text: ' ' },
|
||||
{ kind: 'code_inline', text: '`x`' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('containsCodeSpan', () => {
|
||||
it('detects inline code', () => {
|
||||
expect(containsCodeSpan('run `npm test` now')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a fenced block with a language', () => {
|
||||
expect(containsCodeSpan('```js\nconst a = 1;\n```')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects a fenced block without a language', () => {
|
||||
expect(containsCodeSpan('```\ncode\n```')).toBe(true);
|
||||
});
|
||||
|
||||
it('ignores unclosed fences and lone backticks', () => {
|
||||
expect(containsCodeSpan('```js\nconst a = 1;')).toBe(false);
|
||||
expect(containsCodeSpan('run `npm test')).toBe(false);
|
||||
expect(containsCodeSpan('``')).toBe(false);
|
||||
});
|
||||
|
||||
it('ignores plain text and mention links', () => {
|
||||
expect(containsCodeSpan('hello world')).toBe(false);
|
||||
expect(containsCodeSpan('[a](file:///p)')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isOffsetInCodeBlock', () => {
|
||||
const BLOCK = '```js\nconst a = 1;\n```';
|
||||
|
||||
it('is false with no fences in the buffer', () => {
|
||||
expect(isOffsetInCodeBlock('hello world', 5)).toBe(false);
|
||||
expect(isOffsetInCodeBlock('run `npm test` now', 10)).toBe(false);
|
||||
});
|
||||
|
||||
it('is true right after the opening fence, before any content', () => {
|
||||
expect(isOffsetInCodeBlock('```', 3)).toBe(true);
|
||||
expect(isOffsetInCodeBlock('```js', 5)).toBe(true);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('is true inside a closed block and false outside it', () => {
|
||||
expect(isOffsetInCodeBlock(BLOCK, 6)).toBe(true);
|
||||
expect(isOffsetInCodeBlock(BLOCK, 0)).toBe(false);
|
||||
expect(isOffsetInCodeBlock(BLOCK, BLOCK.length)).toBe(false);
|
||||
expect(isOffsetInCodeBlock(BLOCK + '\nafter', BLOCK.length + 5)).toBe(false);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
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
|
||||
const BADGE = '[docs](file:///a/b)';
|
||||
const SOURCE = `hello ${BADGE} world foo`;
|
||||
const BADGE_START = 6;
|
||||
const BADGE_END = 25;
|
||||
|
||||
describe('badgeAwareWordJump', () => {
|
||||
it('returns null when the buffer has no badge', () => {
|
||||
expect(badgeAwareWordJump('hello world', 0, 'forward')).toBeNull();
|
||||
expect(badgeAwareWordJump('hello world', 11, 'backward')).toBeNull();
|
||||
});
|
||||
|
||||
it('jumps forward onto a badge landing at its end, not the next word', () => {
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'forward')).toBe(BADGE_END);
|
||||
});
|
||||
|
||||
it('jumps forward from the space before a badge landing at its end', () => {
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_START - 1, 'forward')).toBe(BADGE_END);
|
||||
});
|
||||
|
||||
it('jumps backward over a badge landing at its start', () => {
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_END, 'backward')).toBe(BADGE_START);
|
||||
});
|
||||
|
||||
it('jumps backward from the next word onto the badge start', () => {
|
||||
// caret at the start of "world"
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_END + 1, 'backward')).toBe(BADGE_START);
|
||||
});
|
||||
|
||||
it('returns null for jumps that cross no badge', () => {
|
||||
// forward over "hello" only
|
||||
expect(badgeAwareWordJump(SOURCE, 0, 'forward')).toBeNull();
|
||||
// backward over "foo" only
|
||||
expect(badgeAwareWordJump(SOURCE, SOURCE.length, 'backward')).toBeNull();
|
||||
// backward away from the badge (over "hello")
|
||||
expect(badgeAwareWordJump(SOURCE, BADGE_START, 'backward')).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
expect(badgeAwareWordJump(source, 14, 'backward')).toBe(0);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
describe('leadingBadgeEdgeOffset', () => {
|
||||
it('returns 0 when the caret sits exactly at a leading badge end', () => {
|
||||
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns null when the caret is anywhere else', () => {
|
||||
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, 0)).toBeNull();
|
||||
expect(leadingBadgeEdgeOffset(`${BADGE} rest`, BADGE.length + 2)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the buffer does not start with a badge', () => {
|
||||
expect(leadingBadgeEdgeOffset(SOURCE, BADGE_END)).toBeNull();
|
||||
expect(leadingBadgeEdgeOffset('', 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,140 @@
|
||||
import { classifyToolResult } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('classifyToolResult', () => {
|
||||
describe('text', () => {
|
||||
it('returns text for undefined input', () => {
|
||||
expect(classifyToolResult(undefined)).toBe('text');
|
||||
});
|
||||
|
||||
it('returns text for empty string', () => {
|
||||
expect(classifyToolResult('')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns text for whitespace-only input', () => {
|
||||
expect(classifyToolResult(' \n ')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns text for plain prose', () => {
|
||||
expect(classifyToolResult('Hello, this is just some text.')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns text for shell-style line listings', () => {
|
||||
expect(classifyToolResult('file1.java\nfile2.java\nfile3.java\n')).toBe('text');
|
||||
});
|
||||
|
||||
it('returns text when a brace-like string is not valid JSON', () => {
|
||||
expect(classifyToolResult('{key: value}')).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('json', () => {
|
||||
it('classifies a flat JSON object', () => {
|
||||
expect(classifyToolResult('{"key": "value", "n": 42}')).toBe('json');
|
||||
});
|
||||
|
||||
it('classifies a JSON array', () => {
|
||||
expect(classifyToolResult('["a", "b", "c"]')).toBe('json');
|
||||
});
|
||||
|
||||
it('classifies a nested JSON array', () => {
|
||||
expect(classifyToolResult('[[1, 2], [3, 4]]')).toBe('json');
|
||||
});
|
||||
|
||||
it('classifies a pretty-printed JSON object', () => {
|
||||
expect(classifyToolResult('{\n "key": "value"\n}')).toBe('json');
|
||||
});
|
||||
|
||||
it('classifies a deeply nested JSON payload', () => {
|
||||
const nested = JSON.stringify({ items: [{ id: 1, tags: ['a', 'b'] }] }, null, 2);
|
||||
|
||||
expect(classifyToolResult(nested)).toBe('json');
|
||||
});
|
||||
|
||||
it('prefers JSON over inner markdown markers when the content starts with a brace', () => {
|
||||
// A JSON object whose inner strings contain link syntax still
|
||||
// reads as JSON because the leading `{` parses cleanly -
|
||||
// `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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('markdown', () => {
|
||||
it('classifies an ATX header line', () => {
|
||||
expect(classifyToolResult('# Title\n\nSome text below.')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies a fenced code block', () => {
|
||||
expect(classifyToolResult('```json\n{"key": "v"}\n```')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies a tilde-fenced code block', () => {
|
||||
expect(classifyToolResult('~~~bash\nls -la\n~~~')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies a markdown link', () => {
|
||||
expect(classifyToolResult('See [docs](https://example.com) for more.')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies bold text', () => {
|
||||
expect(classifyToolResult('This is **very important**.')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies a bulleted list', () => {
|
||||
expect(classifyToolResult('- item one\n- item two\n- item three')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies an ordered list', () => {
|
||||
expect(classifyToolResult('1. first step\n2. second step\n3. third step')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies a blockquote', () => {
|
||||
expect(classifyToolResult('> quoted text\n> second line')).toBe('markdown');
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('classifies nested markdown headings', () => {
|
||||
expect(classifyToolResult('## Section\n\n### Subsection\n')).toBe('markdown');
|
||||
});
|
||||
|
||||
it('classifies combined markdown markers in one document', () => {
|
||||
const md = [
|
||||
'# Heading',
|
||||
'',
|
||||
'A paragraph with a [link](https://example.com) and **bold text**.',
|
||||
'',
|
||||
'- bullet item',
|
||||
'- another bullet',
|
||||
'',
|
||||
'| col1 | col2 |',
|
||||
'| ----- | ----- |',
|
||||
'| a | b |'
|
||||
].join('\n');
|
||||
|
||||
expect(classifyToolResult(md)).toBe('markdown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('precedence', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,421 @@
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import {
|
||||
formatMessageForClipboard,
|
||||
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 = [
|
||||
{
|
||||
base64Url: 'data:image/png;base64,...',
|
||||
name: 'image.png',
|
||||
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 = [
|
||||
{
|
||||
base64Url: 'data:image/png;base64,...',
|
||||
name: 'image.png',
|
||||
type: AttachmentType.IMAGE as const
|
||||
},
|
||||
{
|
||||
content: 'Text content',
|
||||
name: 'file.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
base64Data: 'data:application/pdf;base64,...',
|
||||
content: 'PDF content',
|
||||
name: 'doc.pdf',
|
||||
processedAsImages: false,
|
||||
type: AttachmentType.PDF as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello', extras);
|
||||
|
||||
expect(result).toContain('"file.txt"');
|
||||
expect(result).not.toContain('image.png');
|
||||
expect(result).not.toContain('doc.pdf');
|
||||
});
|
||||
|
||||
it('formats message with text attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
content: 'File 1 content',
|
||||
name: 'file1.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
content: 'File 2 content',
|
||||
name: 'file2.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello world', extras);
|
||||
|
||||
expect(result).toContain('"Hello world"');
|
||||
expect(result).toContain('"type": "TEXT"');
|
||||
expect(result).toContain('"name": "file1.txt"');
|
||||
expect(result).toContain('"content": "File 1 content"');
|
||||
expect(result).toContain('"name": "file2.txt"');
|
||||
});
|
||||
|
||||
it('handles content with quotes and special characters', () => {
|
||||
const content = 'Hello "world" with\nnewline';
|
||||
const extras = [
|
||||
{
|
||||
content: 'Test content',
|
||||
name: 'test.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard(content, extras);
|
||||
|
||||
// Should be valid JSON
|
||||
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 = [
|
||||
{
|
||||
content: 'Legacy content',
|
||||
name: 'legacy.txt',
|
||||
type: AttachmentType.LEGACY_CONTEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello', extras);
|
||||
|
||||
expect(result).toContain('"type": "TEXT"');
|
||||
expect(result).not.toContain('"context"');
|
||||
});
|
||||
|
||||
it('handles attachment content with special characters', () => {
|
||||
const extras = [
|
||||
{
|
||||
content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;',
|
||||
name: 'code.js',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('Check this code', extras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
expect(parsed.textAttachments[0].content).toBe(
|
||||
'const x = "hello\\nworld";\nconst y = `template ${var}`;'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles unicode characters in content and attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
content: '日本語テスト 🎉 émojis',
|
||||
name: 'unicode.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('Привет мир 👋', extras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
expect(parsed.message).toBe('Привет мир 👋');
|
||||
expect(parsed.textAttachments[0].content).toBe('日本語テスト 🎉 émojis');
|
||||
});
|
||||
|
||||
it('formats as plain text when asPlainText is true', () => {
|
||||
const extras = [
|
||||
{
|
||||
content: 'File 1 content',
|
||||
name: 'file1.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
content: 'File 2 content',
|
||||
name: 'file2.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello world', extras, true);
|
||||
|
||||
expect(result).toBe('Hello world\n\nFile 1 content\n\nFile 2 content');
|
||||
});
|
||||
|
||||
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 = [
|
||||
{
|
||||
content: 'Test content',
|
||||
name: 'test.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello', extras, true);
|
||||
|
||||
expect(result).not.toContain('"type"');
|
||||
expect(result).not.toContain('[');
|
||||
expect(result).toBe('Hello\n\nTest content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseClipboardContent', () => {
|
||||
it('returns plain text as message when not in special format', () => {
|
||||
const result = parseClipboardContent('Hello world');
|
||||
|
||||
expect(result.message).toBe('Hello world');
|
||||
expect(result.textAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles empty string input', () => {
|
||||
const result = parseClipboardContent('');
|
||||
|
||||
expect(result.message).toBe('');
|
||||
expect(result.textAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles whitespace-only input', () => {
|
||||
const result = parseClipboardContent(' \n\t ');
|
||||
|
||||
expect(result.message).toBe(' \n\t ');
|
||||
expect(result.textAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns plain text as message when starts with quote but invalid format', () => {
|
||||
const result = parseClipboardContent('"Unclosed quote');
|
||||
|
||||
expect(result.message).toBe('"Unclosed quote');
|
||||
expect(result.textAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(result.textAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('parses message with text attachments', () => {
|
||||
const input = `"Hello world"
|
||||
[
|
||||
{"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');
|
||||
expect(result.textAttachments).toHaveLength(2);
|
||||
expect(result.textAttachments[0].name).toBe('file1.txt');
|
||||
expect(result.textAttachments[0].content).toBe('File 1 content');
|
||||
expect(result.textAttachments[1].name).toBe('file2.txt');
|
||||
expect(result.textAttachments[1].content).toBe('File 2 content');
|
||||
});
|
||||
|
||||
it('handles escaped quotes in message', () => {
|
||||
const input = `"Hello \\"world\\" with quotes"
|
||||
[
|
||||
{"type":"TEXT","name":"file.txt","content":"test"}
|
||||
]`;
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello "world" with quotes');
|
||||
expect(result.textAttachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('handles newlines in message', () => {
|
||||
const input = `"Hello\\nworld"
|
||||
[
|
||||
{"type":"TEXT","name":"file.txt","content":"test"}
|
||||
]`;
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello\nworld');
|
||||
expect(result.textAttachments).toHaveLength(1);
|
||||
});
|
||||
|
||||
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');
|
||||
expect(result.textAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('filters out invalid attachment objects', () => {
|
||||
const input = `"Hello"
|
||||
[
|
||||
{"type":"TEXT","name":"valid.txt","content":"valid"},
|
||||
{"type":"INVALID","name":"invalid.txt","content":"invalid"},
|
||||
{"name":"missing-type.txt","content":"missing"},
|
||||
{"type":"TEXT","content":"missing name"}
|
||||
]`;
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello');
|
||||
expect(result.textAttachments).toHaveLength(1);
|
||||
expect(result.textAttachments[0].name).toBe('valid.txt');
|
||||
});
|
||||
|
||||
it('handles empty attachments array', () => {
|
||||
const input = '"Hello"\n[]';
|
||||
const result = parseClipboardContent(input);
|
||||
|
||||
expect(result.message).toBe('Hello');
|
||||
expect(result.textAttachments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('roundtrips correctly with formatMessageForClipboard', () => {
|
||||
const originalContent = 'Hello "world" with\nspecial characters';
|
||||
const originalExtras = [
|
||||
{
|
||||
content: 'Content with\nnewlines and "quotes"',
|
||||
name: 'file1.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
},
|
||||
{
|
||||
content: 'Another file',
|
||||
name: 'file2.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard(originalContent, originalExtras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
expect(parsed.message).toBe(originalContent);
|
||||
expect(parsed.textAttachments).toHaveLength(2);
|
||||
expect(parsed.textAttachments[0].name).toBe('file1.txt');
|
||||
expect(parsed.textAttachments[0].content).toBe('Content with\nnewlines and "quotes"');
|
||||
expect(parsed.textAttachments[1].name).toBe('file2.txt');
|
||||
expect(parsed.textAttachments[1].content).toBe('Another file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasClipboardAttachments', () => {
|
||||
it('returns false for plain text', () => {
|
||||
expect(hasClipboardAttachments('Hello world')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(hasClipboardAttachments('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for quoted string without attachments', () => {
|
||||
expect(hasClipboardAttachments('"Hello world"')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for valid format with attachments', () => {
|
||||
const input = `"Hello"
|
||||
[{"type":"TEXT","name":"file.txt","content":"test"}]`;
|
||||
|
||||
expect(hasClipboardAttachments(input)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for format with empty attachments array', () => {
|
||||
const input = '"Hello"\n[]';
|
||||
|
||||
expect(hasClipboardAttachments(input)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for malformed JSON', () => {
|
||||
expect(hasClipboardAttachments('"Hello"\n[broken')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roundtrip edge cases', () => {
|
||||
it('preserves empty message with attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
content: 'Content only',
|
||||
name: 'file.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('', extras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
expect(parsed.message).toBe('');
|
||||
expect(parsed.textAttachments).toHaveLength(1);
|
||||
expect(parsed.textAttachments[0].content).toBe('Content only');
|
||||
});
|
||||
|
||||
it('preserves attachment with empty content', () => {
|
||||
const extras = [
|
||||
{
|
||||
content: '',
|
||||
name: 'empty.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard('Message', extras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
expect(parsed.message).toBe('Message');
|
||||
expect(parsed.textAttachments).toHaveLength(1);
|
||||
expect(parsed.textAttachments[0].content).toBe('');
|
||||
});
|
||||
|
||||
it('preserves multiple backslashes', () => {
|
||||
const content = 'Path: C:\\\\Users\\\\test\\\\file.txt';
|
||||
const extras = [
|
||||
{
|
||||
content: 'D:\\\\Data\\\\file',
|
||||
name: 'path.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard(content, extras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
expect(parsed.message).toBe(content);
|
||||
expect(parsed.textAttachments[0].content).toBe('D:\\\\Data\\\\file');
|
||||
});
|
||||
|
||||
it('preserves tabs and various whitespace', () => {
|
||||
const content = 'Line1\t\tTabbed\n Spaced\r\nCRLF';
|
||||
const extras = [
|
||||
{
|
||||
content: '\t\t\n\n ',
|
||||
name: 'whitespace.txt',
|
||||
type: AttachmentType.TEXT as const
|
||||
}
|
||||
];
|
||||
const formatted = formatMessageForClipboard(content, extras);
|
||||
const parsed = parseClipboardContent(formatted);
|
||||
|
||||
expect(parsed.message).toBe(content);
|
||||
expect(parsed.textAttachments[0].content).toBe('\t\t\n\n ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
import { highlightCode, splitGluedClosingCodeFences, trimCodePadding } from '$lib/utils/code';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('trimCodePadding', () => {
|
||||
it('removes a single leading newline', () => {
|
||||
expect(trimCodePadding('\nfunction foo() {}')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('removes multiple leading newlines', () => {
|
||||
expect(trimCodePadding('\n\n\nfunction foo() {}')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('removes whitespace-only leading lines', () => {
|
||||
expect(trimCodePadding('\n \n\t\nfunction foo() {}')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('removes a single trailing newline', () => {
|
||||
expect(trimCodePadding('function foo() {}\n')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('removes multiple trailing newlines', () => {
|
||||
expect(trimCodePadding('function foo() {}\n\n\n')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('removes whitespace-only trailing lines', () => {
|
||||
expect(trimCodePadding('function foo() {}\n \n\t\n')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('removes newlines on both sides at once', () => {
|
||||
expect(trimCodePadding('\nfunction foo() {}\n')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('preserves internal blank lines', () => {
|
||||
expect(trimCodePadding('\nfunction foo() {\n\n return 1;\n}\n')).toBe(
|
||||
'function foo() {\n\n return 1;\n}'
|
||||
);
|
||||
});
|
||||
|
||||
it('drops a leading whitespace-only line but keeps following code intact', () => {
|
||||
expect(trimCodePadding(' \nfunction foo() {}')).toBe('function foo() {}');
|
||||
});
|
||||
|
||||
it('passes through already-trimmed input unchanged', () => {
|
||||
expect(trimCodePadding('function foo() {}')).toBe('function foo() {}');
|
||||
expect(trimCodePadding('function foo() {\n return 1;\n}')).toBe(
|
||||
'function foo() {\n return 1;\n}'
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty string when input is whitespace only', () => {
|
||||
expect(trimCodePadding('\n\n\n')).toBe('');
|
||||
expect(trimCodePadding('\n \n\t\n')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlightCode', () => {
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(highlightCode('', 'javascript')).toBe('');
|
||||
});
|
||||
|
||||
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>');
|
||||
});
|
||||
});
|
||||
|
||||
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/)"
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it('leaves content without fences untouched', () => {
|
||||
expect(splitGluedClosingCodeFences('hello world')).toBe('hello world');
|
||||
});
|
||||
|
||||
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'
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a still-open fence untouched', () => {
|
||||
const input = '```ts\nlet foo = 1;';
|
||||
|
||||
expect(splitGluedClosingCodeFences(input)).toBe(input);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
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', () => {
|
||||
expect(findCommandToken('hello /prompt')).toBeNull();
|
||||
expect(findCommandToken('')).toBeNull();
|
||||
expect(findCommandToken('prompt')).toBeNull();
|
||||
});
|
||||
|
||||
it('parses a bare slash', () => {
|
||||
expect(findCommandToken('/')).toEqual({ args: '', end: 1, name: '' });
|
||||
});
|
||||
|
||||
it('parses a command name with no args', () => {
|
||||
expect(findCommandToken('/prompt')).toEqual({ args: '', end: 7, name: 'prompt' });
|
||||
});
|
||||
|
||||
it('parses a command name followed by a space', () => {
|
||||
expect(findCommandToken('/prompt ')).toEqual({ args: '', end: 8, name: 'prompt' });
|
||||
});
|
||||
|
||||
it('parses args after the command name', () => {
|
||||
expect(findCommandToken('/prompt rev')).toEqual({ args: 'rev', end: 11, name: 'prompt' });
|
||||
});
|
||||
|
||||
it('parses multi-word args', () => {
|
||||
expect(findCommandToken('/prompt review code ')).toEqual({
|
||||
args: ' review code ',
|
||||
end: 22,
|
||||
name: 'prompt'
|
||||
});
|
||||
});
|
||||
|
||||
it('treats the whole run as the name when there is no space', () => {
|
||||
expect(findCommandToken('/promptx')).toEqual({ args: '', end: 8, name: 'promptx' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeCommandDismissSnapshot', () => {
|
||||
it('returns null when there is no command token', () => {
|
||||
expect(takeCommandDismissSnapshot('hello')).toBeNull();
|
||||
});
|
||||
|
||||
it('captures the name and args', () => {
|
||||
expect(takeCommandDismissSnapshot('/prompt rev')).toEqual({
|
||||
args: 'rev',
|
||||
name: 'prompt'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import { DiffLineKind } from '$lib/enums';
|
||||
import { computeLineDiff, type DiffLine, renderUnifiedDiff } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('computeLineDiff', () => {
|
||||
it('returns empty for two empty inputs', () => {
|
||||
expect(computeLineDiff('', '')).toEqual([]);
|
||||
});
|
||||
|
||||
it('marks every line as removed for an empty new text', () => {
|
||||
expect(computeLineDiff('a\nb\nc', '')).toEqual([
|
||||
{ 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', newLine: 1, text: 'a' },
|
||||
{ kind: 'add', newLine: 2, text: 'b' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('detects a single-line replace', () => {
|
||||
expect(computeLineDiff('old', 'new')).toEqual([
|
||||
{ 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', 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' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves interleaved context around an isolated replace', () => {
|
||||
// Multi-line context around a one-line change -> the diff should
|
||||
// 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', 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', 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', 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', 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', newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: 'context', newLine: 2, oldLine: 2, text: 'b' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps line numbers monotonic across mixed add/remove/context', () => {
|
||||
const oldText = ['l1', 'l2', 'l3', 'l4', 'l5'].join('\n');
|
||||
const newText = ['l1', 'l2-EDIT', 'l3', 'l4-NEW', 'l5'].join('\n');
|
||||
const diff = computeLineDiff(oldText, newText);
|
||||
|
||||
// Walk the diff: every oldLine must increase strictly, and every
|
||||
// newLine must increase strictly. Lines missing one side (add or
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderUnifiedDiff', () => {
|
||||
it('returns empty string for empty diff', () => {
|
||||
expect(renderUnifiedDiff([])).toBe('');
|
||||
});
|
||||
|
||||
it('prefixes each line with `+`, `-`, or a single space', () => {
|
||||
const lines: DiffLine[] = [
|
||||
{ kind: DiffLineKind.CONTEXT, text: 'ctx' },
|
||||
{ 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, newLine: 1, oldLine: 1, text: 'a' },
|
||||
{ kind: DiffLineKind.ADD, newLine: 2, text: 'b' }
|
||||
];
|
||||
|
||||
expect(renderUnifiedDiff(lines)).toBe(' a\n+b');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
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.
|
||||
*
|
||||
* The classifier walks the persisted message history to decide which of three
|
||||
* resume paths a Continue click should take:
|
||||
*
|
||||
* A. append_text -> plain text assistant turn, resume with
|
||||
* continue_final_message.
|
||||
* B. rerun_turn -> assistant turn with tool_calls but no tool results yet,
|
||||
* the stream was cut mid turn and the tool_calls are
|
||||
* unrecoverable as a token level continuation. Drop the
|
||||
* target and rerun from the previous history.
|
||||
* C. next_turn -> assistant turn with tool_calls that were already
|
||||
* resolved by trailing tool results. Hand the history
|
||||
* back to the agentic loop so it starts the next turn.
|
||||
*/
|
||||
|
||||
let nextId = 0;
|
||||
|
||||
function makeMsg(role: MessageRole, opts: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
nextId++;
|
||||
|
||||
return {
|
||||
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([{ function: { arguments: args, name }, id, type: 'function' }]);
|
||||
}
|
||||
|
||||
describe('classifyContinueIntent', () => {
|
||||
it('returns append_text for a plain text assistant turn at the tail', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'hello' }),
|
||||
makeMsg(MessageRole.ASSISTANT, { content: 'hi there' })
|
||||
];
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
});
|
||||
|
||||
it('returns append_text for a plain text assistant turn in the middle', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'q1' }),
|
||||
makeMsg(MessageRole.ASSISTANT, { content: 'a1' }),
|
||||
makeMsg(MessageRole.USER, { content: 'q2' }),
|
||||
makeMsg(MessageRole.ASSISTANT, { content: 'a2' })
|
||||
];
|
||||
|
||||
expect(classifyContinueIntent(messages, 1)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
});
|
||||
|
||||
it('returns rerun_turn when the assistant has tool_calls without results', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'list files' }),
|
||||
makeMsg(MessageRole.ASSISTANT, {
|
||||
content: '',
|
||||
toolCalls: toolCall('call_1', 'bash_tool', '{"command":"ls"}')
|
||||
})
|
||||
];
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.RERUN_TURN, truncateAfter: 0 });
|
||||
});
|
||||
|
||||
it('returns next_turn when trailing tool results resolve the tool_calls', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'list files' }),
|
||||
makeMsg(MessageRole.ASSISTANT, {
|
||||
content: '',
|
||||
toolCalls: toolCall('call_1', 'bash_tool')
|
||||
}),
|
||||
makeMsg(MessageRole.TOOL, { content: 'file1\nfile2', toolCallId: 'call_1' })
|
||||
];
|
||||
const intent = classifyContinueIntent(messages, 1);
|
||||
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 2 });
|
||||
});
|
||||
|
||||
it('next_turn keeps all consecutive trailing tool results, not just one', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'do many things' }),
|
||||
makeMsg(MessageRole.ASSISTANT, {
|
||||
content: '',
|
||||
toolCalls: JSON.stringify([
|
||||
{ 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 });
|
||||
});
|
||||
|
||||
it('next_turn stops at the first non tool message after the target', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'go' }),
|
||||
makeMsg(MessageRole.ASSISTANT, {
|
||||
content: '',
|
||||
toolCalls: toolCall('call_1', 'a')
|
||||
}),
|
||||
makeMsg(MessageRole.TOOL, { content: 'r1', toolCallId: 'call_1' }),
|
||||
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
|
||||
// the user message to grab the dangling late tool.
|
||||
expect(intent).toEqual({ kind: ContinueIntentKind.NEXT_TURN, truncateAfter: 2 });
|
||||
});
|
||||
|
||||
it('returns append_text when toolCalls is set but parses to empty array', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'q' }),
|
||||
makeMsg(MessageRole.ASSISTANT, { content: 'a', toolCalls: '[]' })
|
||||
];
|
||||
|
||||
expect(classifyContinueIntent(messages, 1)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
});
|
||||
|
||||
it('returns append_text when toolCalls is malformed JSON', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'q' }),
|
||||
makeMsg(MessageRole.ASSISTANT, { content: 'a', toolCalls: '{not json' })
|
||||
];
|
||||
|
||||
expect(classifyContinueIntent(messages, 1)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
});
|
||||
|
||||
it('returns append_text defensively when idx points at a non assistant message', () => {
|
||||
const messages = [
|
||||
makeMsg(MessageRole.USER, { content: 'q' }),
|
||||
makeMsg(MessageRole.ASSISTANT, { content: 'a' })
|
||||
];
|
||||
|
||||
expect(classifyContinueIntent(messages, 0)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
});
|
||||
|
||||
it('returns append_text defensively when idx is out of bounds', () => {
|
||||
const messages = [makeMsg(MessageRole.ASSISTANT, { content: 'a' })];
|
||||
|
||||
expect(classifyContinueIntent(messages, 5)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
expect(classifyContinueIntent([], 0)).toEqual({ kind: ContinueIntentKind.APPEND_TEXT });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/database.service', () => ({
|
||||
DatabaseService: { getConversationsWithMessages: vi.fn() }
|
||||
}));
|
||||
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
|
||||
import { filterByLeafNodeId } from '$lib/utils/branching';
|
||||
|
||||
/**
|
||||
* Reproduces the exported-conversation bug:
|
||||
*
|
||||
* A conversation created in the current page session keeps `currNode: ''` in the
|
||||
* sidebar list, because that list is only loaded at init while IndexedDB is stamped
|
||||
* on every message insert.
|
||||
*
|
||||
* Exporting from the cached record resulted in no branch pointer, and importing
|
||||
* the file showed every branch at once.
|
||||
*/
|
||||
|
||||
const fetchMock = vi.mocked(DatabaseService.getConversationsWithMessages);
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
const CONV_ID = 'c1';
|
||||
|
||||
function message(
|
||||
id: string,
|
||||
parent: string | null,
|
||||
timestamp: number,
|
||||
role: MessageRole,
|
||||
type: MessageType = MessageType.TEXT
|
||||
): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: id,
|
||||
convId: CONV_ID,
|
||||
id,
|
||||
parent,
|
||||
role,
|
||||
timestamp,
|
||||
toolCalls: '',
|
||||
type
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
/** root -> u1 -> a1 -> { u2a -> a2a (older) | u2b -> a2b (newer) } */
|
||||
function branchedMessages(): DatabaseMessage[] {
|
||||
const messages = [
|
||||
message('root', null, 10, MessageRole.USER, MessageType.ROOT),
|
||||
message('u1', 'root', 20, MessageRole.USER),
|
||||
message('a1', 'u1', 30, MessageRole.ASSISTANT),
|
||||
message('u2a', 'a1', 40, MessageRole.USER),
|
||||
message('a2a', 'u2a', 50, MessageRole.ASSISTANT),
|
||||
message('u2b', 'a1', 60, MessageRole.USER),
|
||||
message('a2b', 'u2b', 70, MessageRole.ASSISTANT)
|
||||
];
|
||||
|
||||
for (const m of messages) {
|
||||
m.children = messages.filter((c) => c.parent === m.id).map((c) => c.id);
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
/** A second conversation with a single linear path: root -> u1 -> a1. */
|
||||
function linearMessages(convId: string): DatabaseMessage[] {
|
||||
return [
|
||||
{ ...message('root', null, 10, MessageRole.USER, MessageType.ROOT), children: ['u1'], convId },
|
||||
{ ...message('u1', 'root', 20, MessageRole.USER), children: ['a1'], convId },
|
||||
{ ...message('a1', 'u1', 30, MessageRole.ASSISTANT), convId }
|
||||
];
|
||||
}
|
||||
|
||||
function conversation(currNode: string, id: string = CONV_ID): DatabaseConversation {
|
||||
return { currNode, id, lastModified: 100, name: `Chat ${id}` };
|
||||
}
|
||||
|
||||
/** Mirrors `conversationsStore.loadConversation` */
|
||||
function displayedIds(imported: { conv: DatabaseConversation; messages: DatabaseMessage[] }) {
|
||||
if (imported.conv.currNode) {
|
||||
return filterByLeafNodeId(imported.messages, imported.conv.currNode, false).map((m) => m.id);
|
||||
}
|
||||
|
||||
return imported.messages.map((m) => m.id);
|
||||
}
|
||||
|
||||
/** Export then re-import */
|
||||
function roundTrip(conv: DatabaseConversation) {
|
||||
const jsonl = ConversationTransferService.serializeSessionToJsonl({
|
||||
conv,
|
||||
messages: branchedMessages()
|
||||
});
|
||||
const [imported] = ConversationTransferService.parseSessionsJsonl(jsonl);
|
||||
|
||||
return { imported, sessionLine: JSON.parse(jsonl.split('\n')[0]) };
|
||||
}
|
||||
|
||||
describe('conversation export source', () => {
|
||||
it('reads the database record rather than the stale sidebar list', async () => {
|
||||
conversationsStore.conversations = [conversation('')];
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
new Map([[CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }]])
|
||||
);
|
||||
|
||||
const [exported] = await conversationsStore.getConversationsForExport([CONV_ID]);
|
||||
|
||||
expect(exported.conv.currNode).toBe('a2a');
|
||||
expect(conversationsStore.conversations[0].currNode).toBe('');
|
||||
});
|
||||
|
||||
it('reads every selected conversation from the database on bulk export', async () => {
|
||||
conversationsStore.conversations = [conversation(''), conversation('', 'c2')];
|
||||
conversationsStore.activeConversation = conversation('');
|
||||
|
||||
fetchMock.mockResolvedValue(
|
||||
new Map([
|
||||
['c2', { conv: conversation('a1', 'c2'), messages: linearMessages('c2') }],
|
||||
[CONV_ID, { conv: conversation('a2a'), messages: branchedMessages() }]
|
||||
])
|
||||
);
|
||||
|
||||
const archive = vi
|
||||
.spyOn(ConversationTransferService, 'downloadConversationsArchive')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
await conversationsStore.bulkExportConversations([CONV_ID, 'c2']);
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith([CONV_ID, 'c2']);
|
||||
expect(archive).toHaveBeenCalledTimes(1);
|
||||
|
||||
const payload = archive.mock.calls[0][0];
|
||||
|
||||
expect(payload.map((entry) => entry.conv.id)).toEqual([CONV_ID, 'c2']);
|
||||
// Each entry carries its own database currNode.
|
||||
expect(payload.map((entry) => entry.conv.currNode)).toEqual(['a2a', 'a1']);
|
||||
expect(payload[1].messages.map((m: DatabaseMessage) => m.id)).toEqual(['root', 'u1', 'a1']);
|
||||
|
||||
archive.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('exported conversation branch pointer', () => {
|
||||
it('carries the database currNode, so the import restores the current branch', () => {
|
||||
// The user regenerated to create a2b, then switched back to the a2a branch,
|
||||
// so the stored leaf is NOT the newest message.
|
||||
const { imported, sessionLine } = roundTrip(conversation('a2a'));
|
||||
|
||||
expect(sessionLine.currNode).toBe('a2a');
|
||||
expect(displayedIds(imported)).toEqual(['u1', 'a1', 'u2a', 'a2a']);
|
||||
expect(imported.messages.map((m: DatabaseMessage) => m.id).sort()).toEqual([
|
||||
'a1',
|
||||
'a2a',
|
||||
'a2b',
|
||||
'root',
|
||||
'u1',
|
||||
'u2a',
|
||||
'u2b'
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows every branch on import when the cache entry exported an empty currNode', () => {
|
||||
const { imported, sessionLine } = roundTrip(conversation(''));
|
||||
|
||||
expect(sessionLine.currNode).toBe('');
|
||||
expect(displayedIds(imported)).toEqual(['root', 'u1', 'a1', 'u2a', 'a2a', 'u2b', 'a2b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { NEWLINE } from '$lib/constants';
|
||||
import { MessageRole, MessageType } from '$lib/enums';
|
||||
import { ConversationTransferService } from '$lib/services/conversation-transfer.service';
|
||||
import type { ExportedConversation } from '$lib/types/database';
|
||||
import { strToU8, zipSync } from 'fflate';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSession(id: string): ExportedConversation {
|
||||
return {
|
||||
conv: { currNode: `${id}-msg`, id, lastModified: 0, name: `Chat ${id}` },
|
||||
messages: [
|
||||
{
|
||||
children: [],
|
||||
content: `hello from ${id}`,
|
||||
convId: id,
|
||||
id: `${id}-msg`,
|
||||
parent: null,
|
||||
role: MessageRole.USER,
|
||||
timestamp: 0,
|
||||
type: MessageType.TEXT
|
||||
}
|
||||
]
|
||||
} as unknown as ExportedConversation;
|
||||
}
|
||||
|
||||
/**
|
||||
* `parseImportFile` detects the format from the file contents. iOS has no UTI
|
||||
* for `.jsonl`, so the picker cannot filter on it and the filename carries no
|
||||
* guarantee: a JSONL export must import under any name.
|
||||
*/
|
||||
describe('ConversationTransferService.parseImportFile', () => {
|
||||
it('imports a JSONL export whose name has no meaningful extension', async () => {
|
||||
const jsonl = ConversationTransferService.serializeSessionToJsonl(makeSession('a'));
|
||||
const sessions = await ConversationTransferService.parseImportFile(new File([jsonl], 'export'));
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].conv.id).toBe('a');
|
||||
expect(sessions[0].messages[0].content).toBe('hello from a');
|
||||
});
|
||||
|
||||
it('imports several sessions from one JSONL file', async () => {
|
||||
const jsonl = [makeSession('a'), makeSession('b')]
|
||||
.map((session) => ConversationTransferService.serializeSessionToJsonl(session))
|
||||
.join(NEWLINE);
|
||||
const sessions = await ConversationTransferService.parseImportFile(
|
||||
new File([jsonl], 'export.txt')
|
||||
);
|
||||
|
||||
expect(sessions.map((session) => session.conv.id)).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('imports a ZIP archive whose name has no meaningful extension', async () => {
|
||||
const zipped = zipSync({
|
||||
'a.jsonl': strToU8(ConversationTransferService.serializeSessionToJsonl(makeSession('a'))),
|
||||
'b.jsonl': strToU8(ConversationTransferService.serializeSessionToJsonl(makeSession('b'))),
|
||||
'notes.txt': strToU8('ignored')
|
||||
});
|
||||
const sessions = await ConversationTransferService.parseImportFile(
|
||||
new File([zipped], 'archive')
|
||||
);
|
||||
|
||||
expect(sessions.map((session) => session.conv.id).sort()).toEqual(['a', 'b']);
|
||||
});
|
||||
|
||||
it('imports the legacy JSON array format', async () => {
|
||||
const json = JSON.stringify([makeSession('a')], null, 2);
|
||||
const sessions = await ConversationTransferService.parseImportFile(
|
||||
new File([json], 'export.jsonl')
|
||||
);
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].conv.id).toBe('a');
|
||||
});
|
||||
|
||||
it('imports the legacy JSON single object format', async () => {
|
||||
const json = JSON.stringify(makeSession('a'));
|
||||
const sessions = await ConversationTransferService.parseImportFile(new File([json], 'export'));
|
||||
|
||||
expect(sessions).toHaveLength(1);
|
||||
expect(sessions[0].conv.id).toBe('a');
|
||||
});
|
||||
|
||||
it('rejects a file that holds neither format', async () => {
|
||||
await expect(
|
||||
ConversationTransferService.parseImportFile(new File(['not an export'], 'export.jsonl'))
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
Loaded 100 of 3918 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user