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 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
// Field updates to the active conversation must keep the object identity
|
||||
// stable: effects that track the identity ( the chat screen's sibling-info
|
||||
// refresh ) refire on every identity change, which used to trigger a full
|
||||
// message refetch on every send and tool result.
|
||||
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/database.service', () => ({
|
||||
DatabaseService: {
|
||||
getConversation: vi.fn(),
|
||||
getConversationMessages: vi.fn(),
|
||||
updateConversation: vi.fn(),
|
||||
updateCurrentNode: vi.fn()
|
||||
}
|
||||
}));
|
||||
|
||||
import { DatabaseService } from '$lib/services/database.service';
|
||||
import { conversationsStore } from '$lib/stores/conversations/index.svelte';
|
||||
import type { DatabaseConversation, DatabaseMessage } from '$lib/types/database';
|
||||
|
||||
const getConversationMock = vi.mocked(DatabaseService.getConversation);
|
||||
const getMessagesMock = vi.mocked(DatabaseService.getConversationMessages);
|
||||
const updateCurrentNodeMock = vi.mocked(DatabaseService.updateCurrentNode);
|
||||
|
||||
function makeConversation(overrides: Partial<DatabaseConversation> = {}): DatabaseConversation {
|
||||
return {
|
||||
currNode: 'node-1',
|
||||
id: 'conv-1',
|
||||
lastModified: 1000,
|
||||
name: 'conversation',
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
async function loadActive(conversation: DatabaseConversation, messages: DatabaseMessage[]) {
|
||||
getConversationMock.mockResolvedValue(conversation);
|
||||
getMessagesMock.mockResolvedValue(messages);
|
||||
|
||||
expect(await conversationsStore.loadConversation(conversation.id)).toBe(true);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
getConversationMock.mockReset();
|
||||
getMessagesMock.mockReset();
|
||||
updateCurrentNodeMock.mockReset();
|
||||
updateCurrentNodeMock.mockResolvedValue(undefined);
|
||||
vi.mocked(DatabaseService.updateConversation).mockReset();
|
||||
vi.mocked(DatabaseService.updateConversation).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe('active conversation identity', () => {
|
||||
it('hands the load read off exactly once', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toEqual([]);
|
||||
// a second consume is a miss: branch actions must fall back to a refetch
|
||||
expect(conversationsStore.consumeLastLoadedMessages('conv-1')).toBeNull();
|
||||
});
|
||||
|
||||
it('writes currNode in place on updateCurrentNode', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
await conversationsStore.updateCurrentNode('node-2');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.currNode).toBe('node-2');
|
||||
});
|
||||
|
||||
it('writes renamed and pinned fields in place on applyConversationUpdate', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.applyConversationUpdate('conv-1', { name: 'renamed', pinned: true });
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.name).toBe('renamed');
|
||||
expect(conversationsStore.activeConversation?.pinned).toBe(true);
|
||||
});
|
||||
|
||||
it('writes lastModified in place on updateConversationTimestamp', async () => {
|
||||
await loadActive(makeConversation(), []);
|
||||
const before = conversationsStore.activeConversation;
|
||||
|
||||
conversationsStore.updateConversationTimestamp('conv-1');
|
||||
|
||||
expect(conversationsStore.activeConversation).toBe(before);
|
||||
expect(conversationsStore.activeConversation?.lastModified).toBeGreaterThan(1000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
colorizeFaviconSvg,
|
||||
padFaviconSvg,
|
||||
writeThemeFavicons
|
||||
} from '../../scripts/favicon-colorize';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
const SOURCE_SVG = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">',
|
||||
' <path d="M0 0" fill="currentColor"/>',
|
||||
' <path d="M1 1" fill="#ff00aa"/>',
|
||||
' <circle fill="currentColor"/>',
|
||||
'</svg>'
|
||||
].join('\n');
|
||||
|
||||
describe('colorizeFaviconSvg', () => {
|
||||
it('substitutes every currentColor occurrence for the light variant', () => {
|
||||
const { light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
|
||||
expect(light.match(/currentColor/g)).toBeNull();
|
||||
expect(light).toContain('fill="#111111"');
|
||||
expect(light).toContain('<circle fill="#111111"/>');
|
||||
});
|
||||
|
||||
it('substitutes every currentColor occurrence for the dark variant', () => {
|
||||
const { dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
|
||||
expect(dark.match(/currentColor/g)).toBeNull();
|
||||
expect(dark).toContain('fill="#fafafa"');
|
||||
expect(dark).toContain('<circle fill="#fafafa"/>');
|
||||
});
|
||||
|
||||
it('leaves non-currentColor colors untouched in both variants', () => {
|
||||
const { dark, light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
|
||||
expect(light).toContain('fill="#ff00aa"');
|
||||
expect(dark).toContain('fill="#ff00aa"');
|
||||
});
|
||||
|
||||
it('does not alter any other part of the SVG', () => {
|
||||
const { dark, light } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
const stripColors = (s: string) =>
|
||||
s.replaceAll('#111111', '').replaceAll('#fafafa', '').replaceAll('currentColor', '');
|
||||
const expected = stripColors(SOURCE_SVG);
|
||||
|
||||
expect(stripColors(light)).toBe(expected);
|
||||
expect(stripColors(dark)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns the same SVG for light and dark when called with the same color', () => {
|
||||
const result = colorizeFaviconSvg(SOURCE_SVG, '#abcdef', '#abcdef');
|
||||
|
||||
expect(result.light).toBe(result.dark);
|
||||
});
|
||||
|
||||
it('returns the source unchanged when given a color that does not appear (no currentColor in source)', () => {
|
||||
const plain = '<svg><path fill="#000"/></svg>';
|
||||
const { dark, light } = colorizeFaviconSvg(plain, '#111111', '#fafafa');
|
||||
|
||||
expect(light).toBe(plain);
|
||||
expect(dark).toBe(plain);
|
||||
});
|
||||
});
|
||||
|
||||
describe('padFaviconSvg', () => {
|
||||
const SIZED_SVG =
|
||||
'<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">' +
|
||||
'<path d="M244.95 8L388.923 8Z" fill="currentColor"/>' +
|
||||
'</svg>';
|
||||
|
||||
it('wraps inner content in a translate-then-scale group that matches padding', () => {
|
||||
const padded = padFaviconSvg(SIZED_SVG, 0.05);
|
||||
|
||||
// scale = 1 - 0.05 = 0.95
|
||||
// translate = (0.05 * 512) / 2 = 12.8 on each axis
|
||||
expect(padded).toContain('transform="translate(12.8 12.8) scale(0.95)"');
|
||||
expect(padded).toContain('<g transform="translate(12.8 12.8) scale(0.95)">');
|
||||
expect(padded).toContain('<path d="M244.95 8L388.923 8Z" fill="currentColor"/>');
|
||||
expect(padded.endsWith('</g></svg>')).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves the outer <svg> tag attributes', () => {
|
||||
const padded = padFaviconSvg(SIZED_SVG, 0.1);
|
||||
|
||||
expect(padded.startsWith('<svg width="512" height="512" viewBox="0 0 512 512"')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns the input unchanged for zero or negative padding', () => {
|
||||
expect(padFaviconSvg(SIZED_SVG, 0)).toBe(SIZED_SVG);
|
||||
expect(padFaviconSvg(SIZED_SVG, -0.1)).toBe(SIZED_SVG);
|
||||
});
|
||||
|
||||
it('returns the input unchanged when padding would fully collapse the icon (>= 1)', () => {
|
||||
expect(padFaviconSvg(SIZED_SVG, 1)).toBe(SIZED_SVG);
|
||||
expect(padFaviconSvg(SIZED_SVG, 1.5)).toBe(SIZED_SVG);
|
||||
});
|
||||
|
||||
it('returns the input unchanged when no viewBox is present', () => {
|
||||
const noViewBox = '<svg width="32" height="32"><path d="M0 0Z"/></svg>';
|
||||
|
||||
expect(padFaviconSvg(noViewBox, 0.1)).toBe(noViewBox);
|
||||
});
|
||||
|
||||
it('returns the input unchanged when viewBox values are not finite numbers', () => {
|
||||
const bad = '<svg viewBox="auto auto 0 0"><path/></svg>';
|
||||
|
||||
expect(padFaviconSvg(bad, 0.1)).toBe(bad);
|
||||
});
|
||||
|
||||
it('tolerates a non-square viewBox', () => {
|
||||
const wide = '<svg viewBox="0 0 100 50"><rect/></svg>';
|
||||
const padded = padFaviconSvg(wide, 0.1);
|
||||
|
||||
// scale 0.9, translate (5, 2.5)
|
||||
expect(padded).toContain('transform="translate(5 2.5) scale(0.9)"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeThemeFavicons', () => {
|
||||
const LOGO =
|
||||
'<svg width="512" height="512" viewBox="0 0 512 512" fill="none" xmlns="http://www.w3.org/2000/svg">' +
|
||||
'<path d="M244.95 8L388.923 8Z" fill="currentColor"/>' +
|
||||
'</svg>';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = mkdtempSync(join(tmpdir(), 'favicon-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tmpDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
function setupSource() {
|
||||
const sourcePath = join(tmpDir, 'logo.svg');
|
||||
|
||||
writeFileSync(sourcePath, LOGO);
|
||||
|
||||
return {
|
||||
darkPath: join(tmpDir, 'favicon-dark.svg'),
|
||||
lightPath: join(tmpDir, 'favicon.svg'),
|
||||
sourcePath
|
||||
};
|
||||
}
|
||||
|
||||
it('writes colorized, un-padded favicons without modifying the source', () => {
|
||||
const { darkPath, lightPath, sourcePath } = setupSource();
|
||||
const before = readFileSync(sourcePath, 'utf-8');
|
||||
|
||||
writeThemeFavicons('#abcdef', '#012345', {
|
||||
darkOutPath: darkPath,
|
||||
lightOutPath: lightPath,
|
||||
sourcePath
|
||||
});
|
||||
|
||||
const lightOut = readFileSync(lightPath, 'utf-8');
|
||||
const darkOut = readFileSync(darkPath, 'utf-8');
|
||||
|
||||
// currentColor swapped to the requested palette in both files
|
||||
expect(lightOut).toContain('fill="#abcdef"');
|
||||
expect(lightOut).not.toContain('currentColor');
|
||||
expect(darkOut).toContain('fill="#012345"');
|
||||
expect(darkOut).not.toContain('currentColor');
|
||||
|
||||
// default padding (0) keeps the wrapper off the output
|
||||
expect(lightOut).not.toContain('<g ');
|
||||
expect(darkOut).not.toContain('<g ');
|
||||
|
||||
// source file is unchanged after the call
|
||||
expect(readFileSync(sourcePath, 'utf-8')).toBe(before);
|
||||
});
|
||||
|
||||
it('writes colorized favicons wrapped in a padding <g transform>...</g>', () => {
|
||||
const { darkPath, lightPath, sourcePath } = setupSource();
|
||||
// mirror the production wiring: PWA_ASSET_GENERATOR.FAVICON_PADDING
|
||||
const padding = 0.04;
|
||||
// scale = 1 - 0.04 = 0.96; translate = (0.04 * 512) / 2 = 10.24
|
||||
const expectedTransform = 'transform="translate(10.24 10.24) scale(0.96)"';
|
||||
|
||||
writeThemeFavicons('#111111', '#fafafa', {
|
||||
darkOutPath: darkPath,
|
||||
lightOutPath: lightPath,
|
||||
padding,
|
||||
sourcePath
|
||||
});
|
||||
|
||||
const lightOut = readFileSync(lightPath, 'utf-8');
|
||||
const darkOut = readFileSync(darkPath, 'utf-8');
|
||||
|
||||
// both files get the same padding wrapper derived from the viewBox
|
||||
expect(lightOut).toContain(`<g ${expectedTransform}>`);
|
||||
expect(lightOut.endsWith('</g></svg>')).toBe(true);
|
||||
expect(darkOut).toContain(`<g ${expectedTransform}>`);
|
||||
expect(darkOut.endsWith('</g></svg>')).toBe(true);
|
||||
|
||||
// colorization still happens inside the wrapped group
|
||||
expect(lightOut).toContain('fill="#111111"');
|
||||
expect(lightOut).not.toContain('currentColor');
|
||||
expect(darkOut).toContain('fill="#fafafa"');
|
||||
expect(darkOut).not.toContain('currentColor');
|
||||
// light palette colour must not leak into dark output
|
||||
expect(darkOut).not.toContain('#111111');
|
||||
|
||||
// source file is not modified by the padding step
|
||||
expect(readFileSync(sourcePath, 'utf-8')).toBe(LOGO);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { formatContextLength, formatParameters } from '$lib/utils/formatters';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('formatContextLength', () => {
|
||||
it('shows a dash when no value is known', () => {
|
||||
expect(formatContextLength([])).toBe('-');
|
||||
});
|
||||
|
||||
it('prints the value with thousands separators', () => {
|
||||
expect(formatContextLength([8192])).toBe('8,192 tokens');
|
||||
});
|
||||
|
||||
it('pairs the configured context with the supported one', () => {
|
||||
expect(formatContextLength([8192, 131072])).toBe('8,192 / 131,072 tokens');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatParameters', () => {
|
||||
it('shortens large parameter counts', () => {
|
||||
expect(formatParameters(35_000_000_000)).toBe('35B');
|
||||
expect(formatParameters(3_000_000)).toBe('3.00M');
|
||||
expect(formatParameters(512)).toBe('512');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('$lib/services/tools.service', () => ({
|
||||
ToolsService: { executeToolRaw: vi.fn() }
|
||||
}));
|
||||
|
||||
import { GlobSearchType } from '$lib/enums';
|
||||
import { ToolsService } from '$lib/services/tools.service';
|
||||
import { runGlobSearchWithChildren } from '$lib/utils';
|
||||
|
||||
const mockExecute = vi.mocked(ToolsService.executeToolRaw);
|
||||
|
||||
// Distinct roots per test so the module-level search cache never serves a
|
||||
// prior test's result under the same (type, path, glob, depth) key.
|
||||
beforeEach(() => {
|
||||
mockExecute.mockReset();
|
||||
});
|
||||
|
||||
describe('runGlobSearchWithChildren', () => {
|
||||
it('returns ranked outer entries as absolute paths without descending', async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
base: '/Users/rootA',
|
||||
entries: [
|
||||
{ path: 'note.md', type: 'file' },
|
||||
{ path: 'src', type: 'dir' }
|
||||
]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'note',
|
||||
'/Users/rootA',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootA/note.md', '/Users/rootA/src']);
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('appends a matched directorys children when the query ends with a separator', async () => {
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ base: '/Users/rootB', entries: [{ path: 'src', type: 'dir' }] })
|
||||
.mockResolvedValueOnce({
|
||||
base: '/Users/rootB/src',
|
||||
entries: [
|
||||
{ path: 'a.txt', type: 'file' },
|
||||
{ path: 'sub', type: 'dir' }
|
||||
]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootB/src/',
|
||||
'/Users/rootB',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
|
||||
);
|
||||
|
||||
expect(res.error).toBeUndefined();
|
||||
expect(res.exactDir).toBe('/Users/rootB/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual([
|
||||
'/Users/rootB/src',
|
||||
'/Users/rootB/src/a.txt',
|
||||
'/Users/rootB/src/sub'
|
||||
]);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('does not descend without a trailing separator in mention mode', async () => {
|
||||
mockExecute.mockResolvedValueOnce({
|
||||
base: '/Users/rootC',
|
||||
entries: [{ path: 'src', type: 'dir' }]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootC/src',
|
||||
'/Users/rootC',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ descendOnTrailingSeparator: true, type: GlobSearchType.ALL }
|
||||
);
|
||||
|
||||
expect(res.exactDir).toBeUndefined();
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('descends on an exact directory match in WD mode', async () => {
|
||||
mockExecute
|
||||
.mockResolvedValueOnce({ base: '/Users/rootD', entries: [{ path: 'src', type: 'dir' }] })
|
||||
.mockResolvedValueOnce({
|
||||
base: '/Users/rootD/src',
|
||||
entries: [{ path: 'a.txt', type: 'file' }]
|
||||
});
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'/Users/rootD/src',
|
||||
'/Users/rootD',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal,
|
||||
{ type: GlobSearchType.DIR }
|
||||
);
|
||||
|
||||
expect(res.exactDir).toBe('/Users/rootD/src');
|
||||
expect(res.entries.map((e) => e.path)).toEqual(['/Users/rootD/src', '/Users/rootD/src/a.txt']);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('surfaces a server error without attempting a child walk', async () => {
|
||||
mockExecute.mockResolvedValueOnce({ error: 'boom' });
|
||||
const res = await runGlobSearchWithChildren(
|
||||
'src',
|
||||
'/Users/rootE',
|
||||
3,
|
||||
50,
|
||||
new AbortController().signal
|
||||
);
|
||||
|
||||
expect(res.error).toBe('boom');
|
||||
expect(res.entries).toEqual([]);
|
||||
expect(mockExecute).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils/headers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the header serialization helpers used by the MCP server form
|
||||
* (custom header rows) and the new Authorization/Bearer-token flow.
|
||||
*/
|
||||
describe('parseHeadersToArray', () => {
|
||||
it('returns an empty array for empty or whitespace-only input', () => {
|
||||
expect(parseHeadersToArray('')).toEqual([]);
|
||||
expect(parseHeadersToArray(' ')).toEqual([]);
|
||||
expect(parseHeadersToArray(undefined as unknown as string)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array for invalid JSON input', () => {
|
||||
expect(parseHeadersToArray('{not-json')).toEqual([]);
|
||||
expect(parseHeadersToArray('[]')).toEqual([]);
|
||||
expect(parseHeadersToArray('"plain-string"')).toEqual([]);
|
||||
});
|
||||
|
||||
it('converts an object into ordered key/value pairs', () => {
|
||||
expect(parseHeadersToArray('{"X-Foo":"bar","Authorization":"Bearer abc"}')).toEqual([
|
||||
{ key: 'X-Foo', value: 'bar' },
|
||||
{ key: 'Authorization', value: 'Bearer abc' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('stringifies non-string values', () => {
|
||||
expect(parseHeadersToArray('{"count":"42","flag":"true"}')).toEqual([
|
||||
{ key: 'count', value: '42' },
|
||||
{ key: 'flag', value: 'true' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('serializeHeaders', () => {
|
||||
it('returns an empty string when there are no valid pairs', () => {
|
||||
expect(serializeHeaders([])).toBe('');
|
||||
expect(serializeHeaders([{ key: '', value: 'value' }])).toBe('');
|
||||
expect(serializeHeaders([{ key: ' ', value: 'value' }])).toBe('');
|
||||
});
|
||||
|
||||
it('returns an empty string when every pair has a blank key', () => {
|
||||
expect(
|
||||
serializeHeaders([
|
||||
{ key: '', value: 'drop-me' },
|
||||
{ key: ' ', value: 'drop-me-too' },
|
||||
{ key: '\t', value: 'tab-key' }
|
||||
])
|
||||
).toBe('');
|
||||
});
|
||||
|
||||
it('drops pairs with empty keys but keeps the rest', () => {
|
||||
expect(
|
||||
serializeHeaders([
|
||||
{ key: '', value: 'drop-me' },
|
||||
{ key: 'X-Keep', value: 'ok' }
|
||||
])
|
||||
).toBe('{"X-Keep":"ok"}');
|
||||
});
|
||||
|
||||
it('trims keys before serializing', () => {
|
||||
expect(serializeHeaders([{ key: ' X-Space ', value: 'ok' }])).toBe('{"X-Space":"ok"}');
|
||||
});
|
||||
|
||||
it('preserves the input order of surviving pairs', () => {
|
||||
const serialized = serializeHeaders([
|
||||
{ key: 'X-C', value: '3' },
|
||||
{ key: 'X-A', value: '1' },
|
||||
{ key: 'X-B', value: '2' }
|
||||
]);
|
||||
|
||||
// Object key order follows insertion order in modern JS engines, so
|
||||
// the serialized JSON writes keys in our input order.
|
||||
expect(JSON.parse(serialized)).toEqual({ 'X-A': '1', 'X-B': '2', 'X-C': '3' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseHeadersToArray / serializeHeaders roundtrip', () => {
|
||||
it('serializes back to an equal header object after a parse', () => {
|
||||
const original = JSON.stringify({
|
||||
'Content-Type': 'application/json',
|
||||
'X-Trace-Id': 'abc-123'
|
||||
});
|
||||
const roundtrip = serializeHeaders(parseHeadersToArray(original));
|
||||
|
||||
expect(JSON.parse(roundtrip)).toEqual(JSON.parse(original));
|
||||
});
|
||||
|
||||
it('drops rows whose keys are blank after trimming during serialization', () => {
|
||||
const pairs = parseHeadersToArray('{"X-Keep":"ok","":"drop-me"}');
|
||||
|
||||
// parseHeadersToArray keeps raw key strings (the consumer is expected to
|
||||
// filter blanks, not the parser); serialization must strip them.
|
||||
expect(pairs).toEqual([
|
||||
{ key: 'X-Keep', value: 'ok' },
|
||||
{ key: '', value: 'drop-me' }
|
||||
]);
|
||||
expect(serializeHeaders(pairs)).toBe('{"X-Keep":"ok"}');
|
||||
});
|
||||
|
||||
it('preserves upstream keys untouched (does not lowercase them)', () => {
|
||||
const upperCased = '{"Authorization":"Bearer xyz"}';
|
||||
const parsed = parseHeadersToArray(upperCased);
|
||||
|
||||
expect(parsed).toEqual([{ key: 'Authorization', value: 'Bearer xyz' }]);
|
||||
});
|
||||
|
||||
it('bearer-token write survives a re-parse when paired with regular custom headers', () => {
|
||||
// The McpServerForm bearer UI writes {Authorization: `Bearer <token>`}
|
||||
// into the same headers string as the custom KV section. The round
|
||||
// trip below mirrors the exact shape the form produces so a future
|
||||
// refactor of either code path cannot silently change the on-disk key.
|
||||
const pairs = [
|
||||
{ key: 'X-Trace-Id', value: 'abc-123' },
|
||||
{ key: 'Authorization', value: 'Bearer super-secret' }
|
||||
];
|
||||
const serialized = serializeHeaders(pairs);
|
||||
|
||||
expect(serialized).toBe('{"X-Trace-Id":"abc-123","Authorization":"Bearer super-secret"}');
|
||||
expect(parseHeadersToArray(serialized)).toEqual(pairs);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,165 @@
|
||||
import { HuggingFaceService } from '$lib/services/huggingface.service';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const {
|
||||
collapseGgufShards,
|
||||
formatSizeRange,
|
||||
getBitDepth,
|
||||
parseCachePath,
|
||||
parseParamCount,
|
||||
parseSizeBytes
|
||||
} = HuggingFaceService;
|
||||
|
||||
describe('collapseGgufShards', () => {
|
||||
it('passes non-sharded files through', () => {
|
||||
const files = [
|
||||
{ path: 'Model-Q4_K_M.gguf', size: 100 },
|
||||
{ path: 'mmproj-F16.gguf', size: 10 }
|
||||
];
|
||||
|
||||
expect(collapseGgufShards(files)).toStrictEqual(files);
|
||||
});
|
||||
|
||||
it('collapses a shard set to its first shard with the summed size', () => {
|
||||
const files = [
|
||||
{ path: 'Model-00001-of-00003.gguf', size: 10 },
|
||||
{ path: 'Model-00002-of-00003.gguf', size: 20 },
|
||||
{ path: 'Model-00003-of-00003.gguf', size: 30 }
|
||||
];
|
||||
|
||||
expect(collapseGgufShards(files)).toStrictEqual([
|
||||
{ path: 'Model-00001-of-00003.gguf', size: 60 }
|
||||
]);
|
||||
});
|
||||
|
||||
it('treats a missing shard as zero bytes', () => {
|
||||
const files = [
|
||||
{ path: 'Model-00001-of-00002.gguf', size: 10 },
|
||||
{ path: 'Model-Q8_0.gguf', size: 5 }
|
||||
];
|
||||
|
||||
expect(collapseGgufShards(files)).toStrictEqual([
|
||||
{ path: 'Model-00001-of-00002.gguf', size: 10 },
|
||||
{ path: 'Model-Q8_0.gguf', size: 5 }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBitDepth', () => {
|
||||
it('resolves known tokens', () => {
|
||||
expect(getBitDepth('Q4_K_M')).toBe(4);
|
||||
expect(getBitDepth('BF16')).toBe(16);
|
||||
expect(getBitDepth('IQ2_XXS')).toBe(2);
|
||||
});
|
||||
|
||||
it('strips the UD prefix', () => {
|
||||
expect(getBitDepth('UD-Q4_K_XL')).toBe(4);
|
||||
});
|
||||
|
||||
it('falls back to the leading precision digits', () => {
|
||||
expect(getBitDepth('TQ1_0')).toBe(1);
|
||||
expect(getBitDepth('MXFP4_MOE')).toBe(4);
|
||||
});
|
||||
|
||||
it('returns null for unrecognized tokens', () => {
|
||||
expect(getBitDepth('xyz')).toBeNull();
|
||||
expect(getBitDepth('QUANT')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatSizeRange', () => {
|
||||
it('formats a gigabyte range without spaces around the dash', () => {
|
||||
expect(formatSizeRange(19e9, 28.6e9)).toBe('19.0-28.6 GB');
|
||||
});
|
||||
|
||||
it('downgrades the unit to the smaller bound when the max is small', () => {
|
||||
expect(formatSizeRange(1e6, 2.5e6)).toBe('1.0-2.5 MB');
|
||||
});
|
||||
|
||||
it('formats sub-kilobyte sizes in bytes', () => {
|
||||
expect(formatSizeRange(100, 900)).toBe('100-900 B');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseCachePath', () => {
|
||||
it('parses a posix cache path into repo and file', () => {
|
||||
expect(
|
||||
parseCachePath(
|
||||
'/home/u/.cache/llama.cpp/models--ggml-org--Qwen3-8B-GGUF/snapshots/abc123/Q4_K_M.gguf'
|
||||
)
|
||||
).toStrictEqual({ file: 'Q4_K_M.gguf', repo: 'ggml-org/Qwen3-8B-GGUF' });
|
||||
});
|
||||
|
||||
it('accepts windows separators', () => {
|
||||
expect(
|
||||
parseCachePath('C:\\cache\\models--org--Model\\snapshots\\sha\\sub\\file.gguf')
|
||||
).toStrictEqual({ file: 'sub/file.gguf', repo: 'org/Model' });
|
||||
});
|
||||
|
||||
it('returns null for non-cache paths', () => {
|
||||
expect(parseCachePath('/models/foo.gguf')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseParamCount', () => {
|
||||
it('extracts billions and millions', () => {
|
||||
expect(parseParamCount('Qwen3.8-27B-GGUF')).toBe('27B');
|
||||
expect(parseParamCount('embeddinggemma-300M-GGUF')).toBe('300M');
|
||||
expect(parseParamCount('Model-0.6B-Q4_K_M')).toBe('0.6B');
|
||||
});
|
||||
|
||||
it('returns null when no size token is present', () => {
|
||||
expect(parseParamCount('ggml-org/Laguna-S-GGUF')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isDecisionModel', () => {
|
||||
it('flags the decision-model tag', () => {
|
||||
expect(
|
||||
HuggingFaceService.isDecisionModel({
|
||||
pipeline_tag: 'text-classification',
|
||||
tags: ['gguf', 'decision-model', 'text-classification']
|
||||
})
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('flags a text-classification pipeline even without the tag', () => {
|
||||
expect(
|
||||
HuggingFaceService.isDecisionModel({ pipeline_tag: 'text-classification', tags: ['gguf'] })
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('passes regular LLM tasks through', () => {
|
||||
expect(
|
||||
HuggingFaceService.isDecisionModel({
|
||||
pipeline_tag: 'text-generation',
|
||||
tags: ['gguf', 'text-generation']
|
||||
})
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
HuggingFaceService.isDecisionModel({
|
||||
pipeline_tag: 'image-text-to-text',
|
||||
tags: ['gguf', 'image-text-to-text']
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('passes repos without task metadata through', () => {
|
||||
expect(HuggingFaceService.isDecisionModel({ pipeline_tag: null, tags: [] })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseSizeBytes', () => {
|
||||
it('parses single-letter catalog size suffixes', () => {
|
||||
expect(parseSizeBytes('177g')).toBe(177e9);
|
||||
expect(parseSizeBytes('1.2 t')).toBe(1.2e12);
|
||||
expect(parseSizeBytes('500m')).toBe(500e6);
|
||||
});
|
||||
|
||||
it('returns null for malformed input', () => {
|
||||
expect(parseSizeBytes('unknown')).toBeNull();
|
||||
expect(parseSizeBytes('12 parsecs')).toBeNull();
|
||||
expect(parseSizeBytes('')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { getJpegOrientationFromDataURL, isJpegMimeType } from '$lib/utils/jpeg-orientation';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
// Builds the TIFF payload of an APP1 segment holding a single IFD0 entry
|
||||
function buildTiff(littleEndian: boolean, tag: number, value: number): number[] {
|
||||
const u16 = (v: number) => (littleEndian ? [v & 0xff, v >> 8] : [v >> 8, v & 0xff]);
|
||||
const u32 = (v: number) =>
|
||||
littleEndian
|
||||
? [v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff]
|
||||
: [(v >> 24) & 0xff, (v >> 16) & 0xff, (v >> 8) & 0xff, v & 0xff];
|
||||
|
||||
return [
|
||||
...(littleEndian ? [0x49, 0x49] : [0x4d, 0x4d]),
|
||||
...u16(42),
|
||||
...u32(8),
|
||||
...u16(1),
|
||||
...u16(tag),
|
||||
...u16(3),
|
||||
...u32(1),
|
||||
// SHORT value sits left justified in the 4 byte value field
|
||||
...u16(value),
|
||||
...u16(0),
|
||||
...u32(0)
|
||||
];
|
||||
}
|
||||
|
||||
// Wraps a TIFF payload into a complete minimal JPEG data URL
|
||||
function buildJpegDataURL(tiff: number[] | null, prependApp0 = false): string {
|
||||
const bytes: number[] = [0xff, 0xd8];
|
||||
|
||||
if (prependApp0) {
|
||||
// JFIF APP0 segment, irrelevant content the parser walks over
|
||||
bytes.push(0xff, 0xe0, 0x00, 0x07, 0x4a, 0x46, 0x49, 0x46, 0x00);
|
||||
}
|
||||
|
||||
if (tiff) {
|
||||
const payload = [0x45, 0x78, 0x69, 0x66, 0x00, 0x00, ...tiff];
|
||||
const length = payload.length + 2;
|
||||
|
||||
bytes.push(0xff, 0xe1, length >> 8, length & 0xff, ...payload);
|
||||
}
|
||||
|
||||
// SOS marker terminates the metadata scan
|
||||
bytes.push(0xff, 0xda, 0x00, 0x02);
|
||||
|
||||
return `data:image/jpeg;base64,${btoa(String.fromCharCode(...bytes))}`;
|
||||
}
|
||||
|
||||
describe('getJpegOrientationFromDataURL', () => {
|
||||
it('returns the orientation from a little endian EXIF block', () => {
|
||||
expect(getJpegOrientationFromDataURL(buildJpegDataURL(buildTiff(true, 0x0112, 6)))).toBe(6);
|
||||
});
|
||||
|
||||
it('returns the orientation from a big endian EXIF block', () => {
|
||||
expect(getJpegOrientationFromDataURL(buildJpegDataURL(buildTiff(false, 0x0112, 8)))).toBe(8);
|
||||
});
|
||||
|
||||
it('walks over a leading APP0 segment', () => {
|
||||
expect(getJpegOrientationFromDataURL(buildJpegDataURL(buildTiff(true, 0x0112, 3), true))).toBe(
|
||||
3
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 1 when the EXIF block holds no orientation tag', () => {
|
||||
expect(getJpegOrientationFromDataURL(buildJpegDataURL(buildTiff(true, 0x0100, 6)))).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when the orientation value is out of range', () => {
|
||||
expect(getJpegOrientationFromDataURL(buildJpegDataURL(buildTiff(true, 0x0112, 9)))).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 when the JPEG has no APP1 segment', () => {
|
||||
expect(getJpegOrientationFromDataURL(buildJpegDataURL(null, true))).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for a payload that is not a JPEG', () => {
|
||||
const png = btoa(String.fromCharCode(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a));
|
||||
|
||||
expect(getJpegOrientationFromDataURL(`data:image/png;base64,${png}`)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for a truncated payload', () => {
|
||||
const truncated = btoa(String.fromCharCode(0xff, 0xd8, 0xff));
|
||||
|
||||
expect(getJpegOrientationFromDataURL(`data:image/jpeg;base64,${truncated}`)).toBe(1);
|
||||
});
|
||||
|
||||
it('returns 1 for a malformed data URL', () => {
|
||||
expect(getJpegOrientationFromDataURL('not a data url')).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isJpegMimeType', () => {
|
||||
it('matches both JPEG MIME variants', () => {
|
||||
expect(isJpegMimeType('image/jpeg')).toBe(true);
|
||||
expect(isJpegMimeType('image/jpg')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects other image MIME types', () => {
|
||||
expect(isJpegMimeType('image/png')).toBe(false);
|
||||
expect(isJpegMimeType('image/webp')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,418 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection';
|
||||
import { describe, expect, it, test } from 'vitest';
|
||||
|
||||
describe('maskInlineLaTeX', () => {
|
||||
it('should protect LaTeX $x + y$ but not money $3.99', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('I have $10, $3.99 and <<LATEX_0>> and <<LATEX_1>>. The amount is $2,000.');
|
||||
expect(latexExpressions).toEqual(['$x + y$', '$100x$']);
|
||||
});
|
||||
|
||||
it('should ignore money like $5 and $12.99', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = 'Prices are $12.99 and $5. Tax?';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('Prices are $12.99 and $5. Tax?');
|
||||
expect(latexExpressions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should protect inline math $a^2 + b^2$ even after text', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = 'Pythagorean: $a^2 + b^2 = c^2$.';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('Pythagorean: <<LATEX_0>>.');
|
||||
expect(latexExpressions).toEqual(['$a^2 + b^2 = c^2$']);
|
||||
});
|
||||
|
||||
it('should not protect math that has letter after closing $ (e.g. units)', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = 'The cost is $99 and change.';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('The cost is $99 and change.');
|
||||
expect(latexExpressions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should allow $x$ followed by punctuation', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = 'We know $x$, right?';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('We know <<LATEX_0>>, right?');
|
||||
expect(latexExpressions).toEqual(['$x$']);
|
||||
});
|
||||
|
||||
it('should work across multiple lines', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = `Emma buys cupcakes for $3 each.\nHow much is $x + y$?`;
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe(`Emma buys cupcakes for $3 each.\nHow much is <<LATEX_0>>?`);
|
||||
expect(latexExpressions).toEqual(['$x + y$']);
|
||||
});
|
||||
|
||||
it('should not protect $100 but protect $matrix$', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = '$100 and $\\mathrm{GL}_2(\\mathbb{F}_7)$ are different.';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('$100 and <<LATEX_0>> are different.');
|
||||
expect(latexExpressions).toEqual(['$\\mathrm{GL}_2(\\mathbb{F}_7)$']);
|
||||
});
|
||||
|
||||
it('should skip if $ is followed by digit and alphanumeric after close (money)', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = 'I paid $5 quickly.';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('I paid $5 quickly.');
|
||||
expect(latexExpressions).toEqual([]);
|
||||
});
|
||||
|
||||
it('should protect LaTeX even with special chars inside', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = 'Consider $\\alpha_1 + \\beta_2$ now.';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('Consider <<LATEX_0>> now.');
|
||||
expect(latexExpressions).toEqual(['$\\alpha_1 + \\beta_2$']);
|
||||
});
|
||||
|
||||
it('short text', () => {
|
||||
const latexExpressions: string[] = ['$0$'];
|
||||
const input = '$a$\n$a$ and $b$';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('<<LATEX_1>>\n<<LATEX_2>> and <<LATEX_3>>');
|
||||
expect(latexExpressions).toEqual(['$0$', '$a$', '$a$', '$b$']);
|
||||
});
|
||||
|
||||
it('empty text', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = '$\n$$\n';
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe('$\n$$\n');
|
||||
expect(latexExpressions).toEqual([]);
|
||||
});
|
||||
|
||||
it('LaTeX-spacer preceded by backslash', () => {
|
||||
const latexExpressions: string[] = [];
|
||||
const input = `\\[
|
||||
\\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}}
|
||||
\\]`;
|
||||
const output = maskInlineLaTeX(input, latexExpressions);
|
||||
|
||||
expect(output).toBe(input);
|
||||
expect(latexExpressions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preprocessLaTeX', () => {
|
||||
test('converts inline \\( ... \\) to $...$', () => {
|
||||
const input =
|
||||
'\\( \\mathrm{GL}_2(\\mathbb{F}_7) \\): Group of invertible matrices with entries in \\(\\mathbb{F}_7\\).';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'$ \\mathrm{GL}_2(\\mathbb{F}_7) $: Group of invertible matrices with entries in $\\mathbb{F}_7$.'
|
||||
);
|
||||
});
|
||||
|
||||
test("don't inline \\\\( ... \\) to $...$", () => {
|
||||
const input =
|
||||
'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula \\((x_1,\\ldots,x_n)\\).';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'Chapter 20 of The TeXbook, in source "Definitions\\\\(also called Macros)", containst the formula $(x_1,\\ldots,x_n)$.'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves display math \\[ ... \\] and protects adjacent text', () => {
|
||||
const input = `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\\}
|
||||
\\]`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(`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\\}
|
||||
$$`);
|
||||
});
|
||||
|
||||
test('handles standalone display math equation', () => {
|
||||
const input = `Algebra:
|
||||
\\[
|
||||
x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
|
||||
\\]`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(`Algebra:
|
||||
$$
|
||||
x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
|
||||
$$`);
|
||||
});
|
||||
|
||||
test('does not interpret currency values as LaTeX', () => {
|
||||
const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.');
|
||||
});
|
||||
|
||||
test('ignores dollar signs followed by digits (money), but keeps valid math $x + y$', () => {
|
||||
const input = 'I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe('I have \\$10, \\$3.99 and $x + y$ and $100x$. The amount is \\$2,000.');
|
||||
});
|
||||
|
||||
test('handles real-world word problems with amounts and no math delimiters', () => {
|
||||
const input =
|
||||
'Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total?';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'Emma buys 2 cupcakes for \\$3 each and 1 cookie for \\$1.50. How much money does she spend in total?'
|
||||
);
|
||||
});
|
||||
|
||||
test('handles decimal amounts in word problem correctly', () => {
|
||||
const input =
|
||||
'Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive?';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'Maria has \\$20. She buys a notebook for \\$4.75 and a pack of pencils for \\$3.25. How much change does she receive?'
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves display math with surrounding non-ASCII text', () => {
|
||||
const input = `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 が爆発したときのエネルギーに匹敵します。`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
`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 が爆発したときのエネルギーに匹敵します。`
|
||||
);
|
||||
});
|
||||
|
||||
test('LaTeX-spacer preceded by backslash', () => {
|
||||
const input = `\\[
|
||||
\\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}}
|
||||
\\]`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
`$$
|
||||
\\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}}
|
||||
$$`
|
||||
);
|
||||
});
|
||||
|
||||
test('converts \\[ ... \\] even when preceded by text without space', () => {
|
||||
const input = 'Some line ...\nAlgebra: \\[x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}\\]';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'Some line ...\nAlgebra: \n$$x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$$\n'
|
||||
);
|
||||
});
|
||||
|
||||
test('converts \\[ ... \\] in table-cells', () => {
|
||||
const input = `| ID | Expression |\n| #1 | \\[
|
||||
x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}
|
||||
\\] |`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(
|
||||
'| ID | Expression |\n| #1 | $x = \\frac{-b \\pm \\sqrt{\\,b^{2}-4ac\\,}}{2a}$ |'
|
||||
);
|
||||
});
|
||||
|
||||
test('escapes isolated $ before digits ($5 → \\$5), but not valid math', () => {
|
||||
const input = 'This costs $5 and this is math $x^2$. $100 is money.';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe('This costs \\$5 and this is math $x^2$. \\$100 is money.');
|
||||
// Note: Since $x^2$ is detected as valid LaTeX, it's preserved.
|
||||
// $5 becomes \$5 only *after* real math is masked — but here it's correct because the masking logic avoids treating $5 as math.
|
||||
});
|
||||
|
||||
test('display with LaTeX-line-breaks', () => {
|
||||
const input = String.raw`- 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}$$`;
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
// If the formula contains '\\' the $$-delimiters should be in their own line.
|
||||
expect(output).toBe(`- Algebraic topology, Homotopy Groups of $\\mathbb{S}^3$:
|
||||
$$\n\\pi_n(\\mathbb{S}^3) = \\begin{cases}
|
||||
\\mathbb{Z} & n = 3 \\\\
|
||||
0 & n > 3, n \\neq 4 \\\\
|
||||
\\mathbb{Z}_2 & n = 4 \\\\
|
||||
\\end{cases}\n$$`);
|
||||
});
|
||||
|
||||
test('handles mhchem notation safely if present', () => {
|
||||
const input = 'Chemical reaction: \\( \\ce{H2O} \\) and $\\ce{CO2}$';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe('Chemical reaction: $ \\ce{H2O} $ and $\\ce{CO2}$');
|
||||
});
|
||||
|
||||
test('preserves code blocks', () => {
|
||||
const input = 'Inline code: `sum $total` and block:\n```\ndollar $amount\n```\nEnd.';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(input); // Code blocks prevent misinterpretation
|
||||
});
|
||||
|
||||
test('preserves backslash parentheses in code blocks (GitHub issue)', () => {
|
||||
const input = '```python\nfoo = "\\(bar\\)"\n```';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied
|
||||
});
|
||||
|
||||
test('preserves backslash brackets in code blocks', () => {
|
||||
const input = '```python\nfoo = "\\[bar\\]"\n```';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(input); // Code blocks should not have LaTeX conversion applied
|
||||
});
|
||||
|
||||
test('preserves backslash parentheses in inline code', () => {
|
||||
const input = 'Use `foo = "\\(bar\\)"` in your code.';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
expect(output).toBe(input);
|
||||
});
|
||||
|
||||
test('escape backslash in mchem ce', () => {
|
||||
const input = 'mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
// mhchem-escape would insert a backslash here.
|
||||
expect(output).toBe('mchem ce:\n$\\ce{2H2(g) + O2(g) -> 2H2O(l)}$');
|
||||
});
|
||||
|
||||
test('escape backslash in mchem pu', () => {
|
||||
const input = 'mchem pu:\n$\\pu{-572 kJ mol^{-1}}$';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
// mhchem-escape would insert a backslash here.
|
||||
expect(output).toBe('mchem pu:\n$\\pu{-572 kJ mol^{-1}}$');
|
||||
});
|
||||
|
||||
test('LaTeX in blockquotes with display math', () => {
|
||||
const input =
|
||||
'> **Definition (limit):** \n> \\[\n> \\lim_{x\\to a} f(x) = L\n> \\]\n> means that as \\(x\\) gets close to \\(a\\).';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
// Blockquote markers should be preserved, LaTeX should be converted
|
||||
expect(output).toContain('> **Definition (limit):**');
|
||||
expect(output).toContain('$$');
|
||||
expect(output).toContain('$x$');
|
||||
expect(output).not.toContain('\\[');
|
||||
expect(output).not.toContain('\\]');
|
||||
expect(output).not.toContain('\\(');
|
||||
expect(output).not.toContain('\\)');
|
||||
});
|
||||
|
||||
test('LaTeX in blockquotes with inline math', () => {
|
||||
const input =
|
||||
"> The derivative \\(f'(x)\\) at point \\(x=a\\) measures slope.\n> Formula: \\(f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}\\)";
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
// Blockquote markers should be preserved, inline LaTeX converted to $...$
|
||||
expect(output).toContain("> The derivative $f'(x)$ at point $x=a$ measures slope.");
|
||||
expect(output).toContain("> Formula: $f'(a)=\\lim_{h\\to 0}\\frac{f(a+h)-f(a)}{h}$");
|
||||
});
|
||||
|
||||
test('Mixed content with blockquotes and regular text', () => {
|
||||
const input =
|
||||
'Regular text with \\(x^2\\).\n\n> Quote with \\(y^2\\).\n\nMore text with \\(z^2\\).';
|
||||
const output = preprocessLaTeX(input);
|
||||
|
||||
// All LaTeX should be converted, blockquote markers preserved
|
||||
expect(output).toBe('Regular text with $x^2$.\n\n> Quote with $y^2$.\n\nMore text with $z^2$.');
|
||||
});
|
||||
});
|
||||
|
||||
// The fast path skips the whole protect/restore pipeline when the content has
|
||||
// neither `$` nor a backslash. These pin the assumption it relies on: such
|
||||
// content is returned byte-for-byte unchanged, and anything carrying a trigger
|
||||
// character still goes through the full pipeline.
|
||||
describe('preprocessLaTeX fast path', () => {
|
||||
const untouched = [
|
||||
['plain prose', 'The quick brown fox jumps over the lazy dog.'],
|
||||
['headings and lists', '# Title\n\n- one\n- two\n\n## Sub\n\n1. first\n2. second'],
|
||||
['fenced code without escapes', '```ts\nconst x = 1;\n```'],
|
||||
['inline code and emphasis', 'Use `npm run build` with **bold** and _italic_.'],
|
||||
['blockquotes', '> quoted line\n> another line'],
|
||||
['tables', '| a | b |\n| --- | --- |\n| 1 | 2 |'],
|
||||
['brackets and parens without escapes', 'array[0] and call(arg) and [link](/url)'],
|
||||
['multi-paragraph prose', 'First para.\n\nSecond para.\n\nThird para.']
|
||||
];
|
||||
|
||||
for (const [label, input] of untouched) {
|
||||
test(`returns ${label} unchanged`, () => {
|
||||
expect(preprocessLaTeX(input)).toBe(input);
|
||||
});
|
||||
}
|
||||
|
||||
test('still processes content once a trigger character is present', () => {
|
||||
expect(preprocessLaTeX('inline \\(x^2\\) here')).toBe('inline $x^2$ here');
|
||||
expect(preprocessLaTeX('Price: $5')).toBe('Price: \\$5');
|
||||
});
|
||||
|
||||
test('fast path result matches the full pipeline for trigger-free content', () => {
|
||||
// Appending a trigger char forces the slow path; stripping it back off
|
||||
// must agree with what the fast path returned for the same prefix.
|
||||
const body = 'Plain paragraph with no math.\n\nAnother paragraph here.';
|
||||
const viaFastPath = preprocessLaTeX(body);
|
||||
const viaFullPipeline = preprocessLaTeX(`${body}\n\n\\(z\\)`);
|
||||
|
||||
expect(viaFullPipeline.startsWith(viaFastPath)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { CONFIG_LOCALSTORAGE_KEY, STORAGE_APP_NAME } from '$lib/constants';
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// node env unit project has no DOM, install a minimal localStorage backed by a Map
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
setItem: (k, v) => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
});
|
||||
|
||||
/**
|
||||
* Migration `mcp-default-overrides-merge-v1` folds the values of the parallel
|
||||
* `mcpDefaultServerOverrides` config entry onto `mcpServers[i].enabled` (the
|
||||
* single source of truth for new-chat defaults). The legacy key is kept on
|
||||
* disk for downgrade compatibility.
|
||||
*/
|
||||
describe('mcp-default-overrides-merge-v1 migration', () => {
|
||||
const MIGRATION_STATE_KEY = `${STORAGE_APP_NAME}.migration-state`;
|
||||
const MCP_DEFAULT_OVERRIDES_KEY = `${STORAGE_APP_NAME}.mcpDefaultServerOverrides`;
|
||||
|
||||
beforeEach(async () => {
|
||||
localStorage.clear();
|
||||
// Reset the migration run counter so `runAllMigrations` is guaranteed to execute.
|
||||
await import('$lib/services/migration.service').then((mod) =>
|
||||
mod.MigrationService.resetState()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
async function runMigrations() {
|
||||
const { MigrationService } = await import('$lib/services/migration.service');
|
||||
|
||||
await MigrationService.runAllMigrations();
|
||||
}
|
||||
|
||||
function readConfig(): Record<string, unknown> {
|
||||
const raw = localStorage.getItem(CONFIG_LOCALSTORAGE_KEY);
|
||||
|
||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
}
|
||||
|
||||
function writeConfig(config: Record<string, unknown>) {
|
||||
localStorage.setItem(CONFIG_LOCALSTORAGE_KEY, JSON.stringify(config));
|
||||
}
|
||||
|
||||
it('applies matching overrides onto mcpServers[i].enabled and preserves the legacy key', async () => {
|
||||
writeConfig({
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
|
||||
{ enabled: true, serverId: 'exa' },
|
||||
{ enabled: false, serverId: 'hf' }
|
||||
]),
|
||||
mcpServers: JSON.stringify([
|
||||
{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' },
|
||||
{ enabled: false, id: 'hf', url: 'https://huggingface.co/mcp' }
|
||||
])
|
||||
});
|
||||
|
||||
await runMigrations();
|
||||
|
||||
const after = readConfig();
|
||||
const servers = JSON.parse(after.mcpServers as string) as Array<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
|
||||
expect(servers.find((s) => s.id === 'exa')?.enabled).toBe(true);
|
||||
expect(servers.find((s) => s.id === 'hf')?.enabled).toBe(false);
|
||||
expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
|
||||
});
|
||||
|
||||
it('skips override ids that do not match any configured server', async () => {
|
||||
writeConfig({
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([
|
||||
{ enabled: true, serverId: 'orphan' },
|
||||
{ enabled: true, serverId: 'exa' }
|
||||
]),
|
||||
mcpServers: JSON.stringify([{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
|
||||
});
|
||||
|
||||
await runMigrations();
|
||||
|
||||
const after = readConfig();
|
||||
const servers = JSON.parse(after.mcpServers as string) as Array<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
|
||||
expect(servers).toHaveLength(1);
|
||||
expect(servers[0].enabled).toBe(true);
|
||||
expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
|
||||
});
|
||||
|
||||
it('is a no-op when there are no legacy overrides', async () => {
|
||||
writeConfig({
|
||||
mcpServers: JSON.stringify([{ enabled: true, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
|
||||
});
|
||||
|
||||
await runMigrations();
|
||||
|
||||
const after = readConfig();
|
||||
const servers = JSON.parse(after.mcpServers as string) as Array<{
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
}>;
|
||||
|
||||
expect(servers[0].enabled).toBe(true);
|
||||
expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(false);
|
||||
});
|
||||
|
||||
it('does not rewrite mcpServers when override.enabled already matches', async () => {
|
||||
const originalServers = JSON.stringify([
|
||||
{ enabled: true, id: 'exa', url: 'https://mcp.exa.ai/mcp' }
|
||||
]);
|
||||
|
||||
writeConfig({
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ enabled: true, serverId: 'exa' }]),
|
||||
mcpServers: originalServers
|
||||
});
|
||||
|
||||
await runMigrations();
|
||||
|
||||
const after = readConfig();
|
||||
|
||||
expect(after.mcpServers).toBe(originalServers);
|
||||
expect(MCP_DEFAULT_OVERRIDES_KEY in after).toBe(true);
|
||||
});
|
||||
|
||||
it('records itself as completed so subsequent loads do not re-run', async () => {
|
||||
writeConfig({
|
||||
[MCP_DEFAULT_OVERRIDES_KEY]: JSON.stringify([{ enabled: true, serverId: 'exa' }]),
|
||||
mcpServers: JSON.stringify([{ enabled: false, id: 'exa', url: 'https://mcp.exa.ai/mcp' }])
|
||||
});
|
||||
|
||||
const { MigrationService } = await import('$lib/services/migration.service');
|
||||
|
||||
await MigrationService.runAllMigrations();
|
||||
|
||||
const stateRaw = localStorage.getItem(MIGRATION_STATE_KEY);
|
||||
|
||||
expect(stateRaw).not.toBeNull();
|
||||
const state = JSON.parse(stateRaw!) as { completed: string[]; failed: string[] };
|
||||
|
||||
expect(state.completed).toContain('mcp-default-overrides-merge-v1');
|
||||
expect(state.failed).not.toContain('mcp-default-overrides-merge-v1');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { SETTINGS_KEYS } from '$lib/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Default-value policy for the `MCP_SERVERS` setting.
|
||||
*
|
||||
* Earlier versions of the UI preloaded a hard-coded list of suggested
|
||||
* MCP servers into this setting on first install. That caused silent
|
||||
* third-party HTTP requests at app load (see issue #25509) and a popup
|
||||
* "recommendation" dialog (see issue #25274). New users must now opt
|
||||
* in explicitly when adding a server, so the default is an empty list.
|
||||
*/
|
||||
describe('MCP_SERVERS default value', () => {
|
||||
it('does not preload any servers in the MCP_SERVERS setting default', async () => {
|
||||
const { SETTING_CONFIG_DEFAULT } = await import('$lib/constants');
|
||||
|
||||
expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.MCP_SERVERS]).toBe('[]');
|
||||
}, 15000);
|
||||
});
|
||||
@@ -0,0 +1,349 @@
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { CORS_PROXY } from '$lib/constants';
|
||||
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import type { MCPConnection, MCPConnectionLog, MCPServerConfig } from '$lib/types';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
type DiagnosticFetchFactory = (
|
||||
serverName: string,
|
||||
config: MCPServerConfig,
|
||||
baseInit: RequestInit,
|
||||
targetUrl: URL,
|
||||
useProxy: boolean,
|
||||
onLog?: (log: MCPConnectionLog) => void
|
||||
) => { fetch: typeof fetch; disable: () => void };
|
||||
|
||||
const createDiagnosticFetch = (
|
||||
config: MCPServerConfig,
|
||||
onLog?: (log: MCPConnectionLog) => void,
|
||||
baseInit: RequestInit = {},
|
||||
useProxy = false
|
||||
) =>
|
||||
(
|
||||
MCPService as unknown as { createDiagnosticFetch: DiagnosticFetchFactory }
|
||||
).createDiagnosticFetch('test-server', config, baseInit, new URL(config.url), useProxy, onLog);
|
||||
|
||||
describe('MCPService', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('stops transport phase logging after handshake diagnostics are disabled', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, { body: '{}', method: 'POST' });
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true);
|
||||
|
||||
controller.disable();
|
||||
await controller.fetch(config.url, { body: '{}', method: 'POST' });
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('redacts all configured custom headers in diagnostic request logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
headers: {
|
||||
'x-auth-token': 'secret-token',
|
||||
'x-vendor-api-key': 'secret-key'
|
||||
},
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {
|
||||
headers: config.headers
|
||||
});
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
body: '{}',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-auth-token': '[redacted]',
|
||||
'x-vendor-api-key': '[redacted]'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('wraps dynamic request headers when using the CORS proxy', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const proxiedAuthToken = `${CORS_PROXY.HEADER_PREFIX}x-auth-token`;
|
||||
const proxiedContentType = `${CORS_PROXY.HEADER_PREFIX}content-type`;
|
||||
const proxiedSessionId = `${CORS_PROXY.HEADER_PREFIX}mcp-session-id`;
|
||||
const response = new Response('{}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(response);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp',
|
||||
useProxy: true
|
||||
};
|
||||
const controller = createDiagnosticFetch(
|
||||
config,
|
||||
(log) => logs.push(log),
|
||||
{
|
||||
headers: {
|
||||
authorization: 'Bearer llama-server-key',
|
||||
[proxiedAuthToken]: 'target-token'
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
await controller.fetch('http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp', {
|
||||
body: '{}',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-request-12345'
|
||||
},
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
const sentHeaders = fetchMock.mock.calls[0]?.[1]?.headers as Headers;
|
||||
|
||||
expect(sentHeaders.get('authorization')).toBe('Bearer llama-server-key');
|
||||
expect(sentHeaders.get(proxiedAuthToken)).toBe('target-token');
|
||||
expect(sentHeaders.get(proxiedContentType)).toBe('application/json');
|
||||
expect(sentHeaders.get(proxiedSessionId)).toBe('session-request-12345');
|
||||
expect(sentHeaders.has('content-type')).toBe(false);
|
||||
expect(sentHeaders.has('mcp-session-id')).toBe(false);
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
headers: {
|
||||
authorization: '[redacted]',
|
||||
[proxiedAuthToken]: '[redacted]',
|
||||
[proxiedSessionId]: '....12345'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('DELETE request with CORS proxy should return a fake 200 response', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const fetchMock = vi.fn();
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp',
|
||||
useProxy: true
|
||||
};
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {}, true);
|
||||
const response = await controller.fetch(
|
||||
'http://localhost:8080/cors-proxy?url=https%3A%2F%2Fexample.com%2Fmcp',
|
||||
{ method: 'DELETE' }
|
||||
);
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(200);
|
||||
expect(logs.at(-1)?.details).toMatchObject({
|
||||
response: { isFake: true, status: 200 }
|
||||
});
|
||||
});
|
||||
|
||||
it('partially redacts mcp-session-id in diagnostic request and response logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-response-67890'
|
||||
},
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
body: '{}',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-request-12345'
|
||||
},
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': '....12345'
|
||||
}
|
||||
}
|
||||
});
|
||||
expect(logs[1].details).toMatchObject({
|
||||
response: {
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': '....67890'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts JSON-RPC methods without logging the raw request body', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
status: 200
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'https://example.com/mcp'
|
||||
};
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
body: JSON.stringify([
|
||||
{ id: 1, jsonrpc: '2.0', method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
]),
|
||||
method: 'POST'
|
||||
});
|
||||
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
body: {
|
||||
kind: 'string',
|
||||
size: expect.any(Number)
|
||||
},
|
||||
jsonRpcMethods: ['initialize', 'notifications/initialized'],
|
||||
method: 'POST'
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('adds a CORS hint to Failed to fetch diagnostic log messages', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const fetchError = new TypeError('Failed to fetch');
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(fetchError));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
url: 'http://localhost:8000/mcp'
|
||||
};
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await expect(controller.fetch(config.url, { body: '{}', method: 'POST' })).rejects.toThrow(
|
||||
'Failed to fetch'
|
||||
);
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[1].message).toBe(
|
||||
'HTTP POST http://localhost:8000/mcp failed: Failed to fetch (check CORS?)'
|
||||
);
|
||||
});
|
||||
|
||||
it('detaches phase error logging after the initialize handshake completes', async () => {
|
||||
const phaseLogs: Array<{ phase: MCPConnectionPhase; log: MCPConnectionLog }> = [];
|
||||
const stopPhaseLogging = vi.fn();
|
||||
|
||||
let emitClientError: ((error: Error) => void) | undefined;
|
||||
|
||||
vi.spyOn(MCPService, 'createTransport').mockReturnValue({
|
||||
stopPhaseLogging,
|
||||
transport: {} as never,
|
||||
type: MCPTransportType.WEBSOCKET
|
||||
});
|
||||
vi.spyOn(MCPService, 'listTools').mockResolvedValue([]);
|
||||
vi.spyOn(Client.prototype, 'getServerVersion').mockReturnValue(undefined);
|
||||
vi.spyOn(Client.prototype, 'getServerCapabilities').mockReturnValue(undefined);
|
||||
vi.spyOn(Client.prototype, 'getInstructions').mockReturnValue(undefined);
|
||||
vi.spyOn(Client.prototype, 'connect').mockImplementation(async function (this: Client) {
|
||||
emitClientError = (error: Error) => this.onerror?.(error);
|
||||
this.onerror?.(new Error('handshake protocol error'));
|
||||
});
|
||||
|
||||
await MCPService.connect(
|
||||
'test-server',
|
||||
{
|
||||
transport: MCPTransportType.WEBSOCKET,
|
||||
url: 'ws://example.com/mcp'
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
(phase, log) => phaseLogs.push({ log, phase })
|
||||
);
|
||||
|
||||
expect(stopPhaseLogging).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ log, phase }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: handshake protocol error'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
|
||||
emitClientError?.(new Error('runtime protocol error'));
|
||||
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ log, phase }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: runtime protocol error'
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('falls back to structuredContent when content array is empty', async () => {
|
||||
const connection = {
|
||||
client: {
|
||||
callTool: vi.fn().mockResolvedValue({
|
||||
content: [],
|
||||
structuredContent: { accounts: [{ id: 1 }], total: 1 }
|
||||
})
|
||||
},
|
||||
requestTimeoutMs: 9000,
|
||||
serverName: 'test-server'
|
||||
} as unknown as MCPConnection;
|
||||
const result = await MCPService.callTool(connection, { arguments: {}, name: 'tool' });
|
||||
|
||||
expect(result.isError).toBe(false);
|
||||
expect(result.content).toBe('{"accounts":[{"id":1}],"total":1}');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
import { FileMentionEntryType } from '$lib/enums';
|
||||
import {
|
||||
buildMentionInsertion,
|
||||
containsFileMentionLink,
|
||||
decodeFileLinkPath,
|
||||
encodeFileLinkPath,
|
||||
fileMentionLinkRe,
|
||||
getMentionBadgeIconPaths,
|
||||
getMentionBadgeLabel,
|
||||
MENTION_BADGE_FILE_ICON_PATHS,
|
||||
MENTION_BADGE_FOLDER_ICON_PATHS
|
||||
} from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('encodeFileLinkPath', () => {
|
||||
it('leaves a clean path unchanged', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar.txt')).toBe('/Users/foo/bar.txt');
|
||||
});
|
||||
|
||||
it('encodes spaces per path segment', () => {
|
||||
expect(
|
||||
encodeFileLinkPath('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png');
|
||||
});
|
||||
|
||||
it('preserves the leading and trailing slash (directory marker)', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/bar/')).toBe('/Users/foo/bar/');
|
||||
});
|
||||
|
||||
it('encodes parentheses in macOS screenshot names', () => {
|
||||
expect(encodeFileLinkPath('/Users/foo/Pic (1).png')).toBe('/Users/foo/Pic%20(1).png');
|
||||
});
|
||||
});
|
||||
|
||||
describe('fileMentionLinkRe', () => {
|
||||
it('matches a standard mention link', () => {
|
||||
expect(fileMentionLinkRe().test('[docs](file:///a/b)')).toBe(true);
|
||||
expect(containsFileMentionLink('[docs](file:///a/b)')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match non-file links', () => {
|
||||
expect(fileMentionLinkRe().test('[foo](https://example.com)')).toBe(false);
|
||||
expect(fileMentionLinkRe().test('plain text')).toBe(false);
|
||||
});
|
||||
|
||||
it('admits a close paren in a macOS-style file name', () => {
|
||||
const match = fileMentionLinkRe().exec(
|
||||
'[Screenshot (1).png](file:///Users/foo/Screenshot (1).png)'
|
||||
);
|
||||
|
||||
expect(match).not.toBeNull();
|
||||
expect(match?.[1]).toBe('Screenshot (1).png');
|
||||
expect(match?.[2]).toBe('/Users/foo/Screenshot (1).png');
|
||||
});
|
||||
|
||||
it('admits a parenthesized folder segment', () => {
|
||||
expect(
|
||||
fileMentionLinkRe().exec('[main.rs](file:///Users/foo/Project (Stuff)/main.rs)')?.[2]
|
||||
).toBe('/Users/foo/Project (Stuff)/main.rs');
|
||||
});
|
||||
|
||||
it('stops at the closing paren of an adjacent badge', () => {
|
||||
expect(fileMentionLinkRe().exec('[a](file:///p)[b](file:///q)')?.[0]).toBe('[a](file:///p)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeIconPaths', () => {
|
||||
it('returns the folder glyphs for a trailing-separator path', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar/')).toBe(MENTION_BADGE_FOLDER_ICON_PATHS);
|
||||
});
|
||||
|
||||
it('returns the file glyphs otherwise', () => {
|
||||
expect(getMentionBadgeIconPaths('/Users/foo/bar.txt')).toBe(MENTION_BADGE_FILE_ICON_PATHS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMentionBadgeLabel', () => {
|
||||
it('returns the name by default', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', false)).toBe('bar');
|
||||
});
|
||||
|
||||
it('renders the decoded full path without the trailing separator', () => {
|
||||
expect(getMentionBadgeLabel('bar', '/Users/foo/bar/', true)).toBe('/Users/foo/bar');
|
||||
expect(getMentionBadgeLabel('shot', '/Users/foo/Screenshot%20(1).png', true)).toBe(
|
||||
'/Users/foo/Screenshot (1).png'
|
||||
);
|
||||
});
|
||||
|
||||
it('abbreviates a known home prefix to a tilde', () => {
|
||||
expect(getMentionBadgeLabel('main.rs', '/home/user/src/main.rs', true, '/home/user')).toBe(
|
||||
'~/src/main.rs'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the name when the decoded path is empty', () => {
|
||||
expect(getMentionBadgeLabel('root', '/', true)).toBe('root');
|
||||
});
|
||||
});
|
||||
|
||||
describe('decodeFileLinkPath', () => {
|
||||
it('decodes encoded segments back to the original path', () => {
|
||||
expect(
|
||||
decodeFileLinkPath('/Users/allozaur/Desktop/Screenshot%202026-08-05%20at%2011.33.45.png')
|
||||
).toBe('/Users/allozaur/Desktop/Screenshot 2026-08-05 at 11.33.45.png');
|
||||
});
|
||||
|
||||
it('is the inverse of encodeFileLinkPath', () => {
|
||||
for (const path of [
|
||||
'/a/b.txt',
|
||||
'/Users/foo/Desktop/Screenshot 2026-08-05 at 11.33.45.png',
|
||||
'/Users/foo/bar (1)/dir/',
|
||||
'/sp ace/pa%th.txt'
|
||||
]) {
|
||||
expect(decodeFileLinkPath(encodeFileLinkPath(path))).toBe(path);
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the input on malformed percent sequences', () => {
|
||||
expect(decodeFileLinkPath('/a/%zz.txt')).toBe('/a/%zz.txt');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMentionInsertion', () => {
|
||||
const file = (path: string, name: string) => ({
|
||||
name,
|
||||
path,
|
||||
type: FileMentionEntryType.FILE
|
||||
});
|
||||
const dir = (path: string, name: string) => ({
|
||||
name,
|
||||
path,
|
||||
type: FileMentionEntryType.DIRECTORY
|
||||
});
|
||||
|
||||
it('splices a root-anchored file link in place of the token', () => {
|
||||
const value = 'hello @repo';
|
||||
const result = buildMentionInsertion(file('/Users/foo/myRepo', 'myRepo'), value, {
|
||||
end: 11,
|
||||
start: 6
|
||||
});
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
const { caretOffset, newValue } = result!;
|
||||
|
||||
expect(newValue).toBe('hello [myRepo](file:///Users/foo/myRepo) ');
|
||||
expect(caretOffset).toBe(6 + '[myRepo](file:///Users/foo/myRepo) '.length);
|
||||
});
|
||||
|
||||
it('keeps the trailing slash on the directory marker', () => {
|
||||
const value = 'see @src';
|
||||
const { newValue } = buildMentionInsertion(dir('/Users/foo/myRepo/src/', 'src'), value, {
|
||||
end: 8,
|
||||
start: 4
|
||||
})!;
|
||||
|
||||
expect(newValue).toBe('see [src](file:///Users/foo/myRepo/src/) ');
|
||||
});
|
||||
|
||||
it('escapes spaces and parens in the target', () => {
|
||||
const value = '@pic';
|
||||
const { newValue } = buildMentionInsertion(
|
||||
file('/Users/foo/Desktop/Pic (1).png', 'Pic (1).png'),
|
||||
value,
|
||||
{ end: 4, start: 0 }
|
||||
)!;
|
||||
|
||||
expect(newValue).toBe('[Pic (1).png](file:///Users/foo/Desktop/Pic%20(1).png) ');
|
||||
});
|
||||
|
||||
it('re-adds the directory marker when the cleaned path empties', () => {
|
||||
const { newValue } = buildMentionInsertion(dir('/', 'root'), '/', { end: 1, start: 0 })!;
|
||||
|
||||
expect(newValue).toBe('[root](file:///) ');
|
||||
});
|
||||
|
||||
it('returns null for an out-of-range token', () => {
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { end: 5, start: 0 })).toBeNull();
|
||||
expect(buildMentionInsertion(file('/a/b.txt', 'b.txt'), 'x', { end: 1, start: 2 })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { splitMentionSegments } from '$lib/utils/mention-badge';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('splitMentionSegments', () => {
|
||||
it('returns a single plain run when there is no mention', () => {
|
||||
const segments = splitMentionSegments('# not a heading here');
|
||||
|
||||
expect(segments).toEqual([{ mention: null, text: '# not a heading here' }]);
|
||||
});
|
||||
|
||||
it('splits text around a mention', () => {
|
||||
const segments = splitMentionSegments('look at [main.c](file:///src/main.c) please');
|
||||
|
||||
expect(segments.map((segment) => segment.text)).toEqual([
|
||||
'look at ',
|
||||
'[main.c](file:///src/main.c)',
|
||||
' please'
|
||||
]);
|
||||
expect(segments[1].mention).toEqual({ name: 'main.c', path: '/src/main.c' });
|
||||
});
|
||||
|
||||
it('decodes percent-encoded paths', () => {
|
||||
const segments = splitMentionSegments('[a b.txt](file:///tmp/a%20b.txt)');
|
||||
|
||||
expect(segments[0].mention?.path).toBe('/tmp/a b.txt');
|
||||
});
|
||||
|
||||
it('keeps the directory marker so the folder icon is picked', () => {
|
||||
const segments = splitMentionSegments('[src](file:///repo/src/)');
|
||||
|
||||
expect(segments[0].mention?.path).toBe('/repo/src/');
|
||||
});
|
||||
|
||||
it('handles adjacent mentions with no text between them', () => {
|
||||
const segments = splitMentionSegments('[a](file:///a)[b](file:///b)');
|
||||
|
||||
expect(segments).toHaveLength(2);
|
||||
expect(segments.every((segment) => segment.mention !== null)).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves the exact source when segments are joined back', () => {
|
||||
const source = 'see [a](file:///a) and [b](file:///b/) done';
|
||||
|
||||
expect(splitMentionSegments(source).reduce((acc, segment) => acc + segment.text, '')).toBe(
|
||||
source
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
import { findMentionToken, takeMentionDismissSnapshot } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('findMentionToken', () => {
|
||||
it('returns null for an empty/bare cursor', () => {
|
||||
expect(findMentionToken('', 0)).toBeNull();
|
||||
expect(findMentionToken('text', 0)).toBeNull();
|
||||
});
|
||||
|
||||
it('recognizes a mention at the start of the value', () => {
|
||||
expect(findMentionToken('@pr', 3)).toEqual({ end: 3, query: 'pr', start: 0 });
|
||||
});
|
||||
|
||||
it('recognizes a mention after a word boundary', () => {
|
||||
expect(findMentionToken('hello @pr', 9)).toEqual({ end: 9, query: 'pr', start: 6 });
|
||||
});
|
||||
|
||||
it('returns null when the @ is mid-identifier', () => {
|
||||
expect(findMentionToken('em@', 3)).toBeNull();
|
||||
expect(findMentionToken('text@pr', 7)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the cursor is past the whitespace break', () => {
|
||||
expect(findMentionToken('@pr hello', 9)).toBeNull();
|
||||
});
|
||||
|
||||
it('treats boundary characters (parens, brackets, comma) as token starts', () => {
|
||||
expect(findMentionToken('(@pr', 4)).toEqual({ end: 4, query: 'pr', start: 1 });
|
||||
expect(findMentionToken('[@pr', 4)).toEqual({ end: 4, query: 'pr', start: 1 });
|
||||
expect(findMentionToken('a,@pr', 5)).toEqual({ end: 5, query: 'pr', start: 2 });
|
||||
});
|
||||
|
||||
it('does not treat an identifier character as a boundary', () => {
|
||||
expect(findMentionToken('user@abc', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('extracts the whole token up to the trailing boundary as the query', () => {
|
||||
expect(findMentionToken('@', 1)).toEqual({ end: 1, query: '', start: 0 });
|
||||
expect(findMentionToken('@hello', 6)).toEqual({ end: 6, query: 'hello', start: 0 });
|
||||
});
|
||||
|
||||
it('keeps the whole token as the query when the caret is mid-token', () => {
|
||||
expect(findMentionToken('@hello', 4)).toEqual({ end: 6, query: 'hello', start: 0 });
|
||||
expect(findMentionToken('@hello world', 4)).toEqual({ end: 6, query: 'hello', start: 0 });
|
||||
});
|
||||
|
||||
it('ignores a boundary @ and keeps the most recent token', () => {
|
||||
expect(findMentionToken('a @foo @bar', 11)).toEqual({ end: 11, query: 'bar', start: 7 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('takeMentionDismissSnapshot', () => {
|
||||
it('returns null when there is no valid mention at the cursor', () => {
|
||||
expect(takeMentionDismissSnapshot('plain text', 5)).toBeNull();
|
||||
expect(takeMentionDismissSnapshot('user@abc', 8)).toBeNull();
|
||||
});
|
||||
|
||||
it('captures start and query of the current mention', () => {
|
||||
expect(takeMentionDismissSnapshot('hello @proj', 11)).toEqual({
|
||||
query: 'proj',
|
||||
start: 6
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { detectToolUseSupport } from '$lib/utils/chat-template-tool-detector';
|
||||
import { minMemoryTierGb } from '$lib/utils/model-compatibility';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('minMemoryTierGb', () => {
|
||||
it('picks the smallest tier whose budget fits the file', () => {
|
||||
// budget(16) = 16 * 1024 * 0.75 - 2048 = 10240 MiB; ~9.2 GiB file fits
|
||||
expect(minMemoryTierGb(9.5 * 1024 * 1024 * 1024)).toBe(16);
|
||||
// budget(12) = 7168 MiB; the same file does not fit
|
||||
expect(minMemoryTierGb(9.5 * 1024 * 1024 * 1024)).not.toBe(12);
|
||||
});
|
||||
|
||||
it('applies the quant headroom to the file size', () => {
|
||||
// exactly the tier-32 budget before the 1.05 headroom; with it the file
|
||||
// spills into the next tier
|
||||
const budget32Mb = 32 * 1024 * 0.75 - 2048;
|
||||
const bytes = (budget32Mb / 1.05) * 1024 * 1024;
|
||||
|
||||
expect(minMemoryTierGb(bytes)).toBe(32);
|
||||
expect(minMemoryTierGb(bytes + 1)).toBe(48);
|
||||
});
|
||||
|
||||
it('returns null for empty sizes and over-budget files', () => {
|
||||
expect(minMemoryTierGb(0)).toBeNull();
|
||||
expect(minMemoryTierGb(4 * 1024 * 1024 * 1024 * 1024)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectToolUseSupport', () => {
|
||||
it('detects the jinja tools variable', () => {
|
||||
expect(detectToolUseSupport('{% for tool in tools %}')).toBe(true);
|
||||
expect(detectToolUseSupport('{{ tools | tojson }}')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects tool-call tokens case-insensitively', () => {
|
||||
expect(detectToolUseSupport('WRITES <tool_call> BLOCKS')).toBe(true);
|
||||
expect(detectToolUseSupport('emits Tool_Call sections')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects templates without tool references', () => {
|
||||
expect(detectToolUseSupport('')).toBe(false);
|
||||
expect(detectToolUseSupport('{{ prompt }}')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
import { groupModelFamilies, modelFamilyKey } from '$lib/utils/model-families';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('modelFamilyKey', () => {
|
||||
it('folds the leading letters of the model name', () => {
|
||||
expect(modelFamilyKey('Qwen3.5-27B')).toBe('Qwen');
|
||||
expect(modelFamilyKey('Qwen3.8-27B-GGUF')).toBe('Qwen');
|
||||
expect(modelFamilyKey('Llama-3.2-3B-Instruct')).toBe('Llama');
|
||||
});
|
||||
|
||||
it('uses the model and not the org', () => {
|
||||
expect(modelFamilyKey('ggml-org/Qwen3.8-27B-GGUF')).toBe('Qwen');
|
||||
});
|
||||
|
||||
it('falls back to the first segment when the name starts with a digit', () => {
|
||||
expect(modelFamilyKey('3.5-MoE-instruct')).toBe('3');
|
||||
});
|
||||
|
||||
it('keeps a name with no letters whole', () => {
|
||||
expect(modelFamilyKey('_internal')).toBe('_internal');
|
||||
expect(modelFamilyKey('-leading-dash')).toBe('-leading-dash');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupModelFamilies', () => {
|
||||
const rows = [
|
||||
{ name: 'ggml-org/Qwen3.5-9B-GGUF' },
|
||||
{ name: 'unsloth/Qwen3.8-27B-GGUF' },
|
||||
{ name: 'meta-llama/Llama-3.2-3B-Instruct' },
|
||||
{ name: 'org/tinyllama-1.1B' }
|
||||
];
|
||||
const modelOf = (row: { name: string }) => row.name;
|
||||
|
||||
it('collects the entries of one family', () => {
|
||||
const families = groupModelFamilies(rows, modelOf);
|
||||
|
||||
expect(families.map((family) => [family.key, family.entries.length])).toEqual([
|
||||
['Qwen', 2],
|
||||
['Llama', 1],
|
||||
['tinyllama', 1]
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels a family with its key', () => {
|
||||
expect(groupModelFamilies(rows, modelOf)[0].label).toBe('Qwen');
|
||||
});
|
||||
|
||||
it('keeps the input order of the entries', () => {
|
||||
expect(groupModelFamilies(rows, modelOf)[0].entries.map(modelOf)).toEqual([
|
||||
'ggml-org/Qwen3.5-9B-GGUF',
|
||||
'unsloth/Qwen3.8-27B-GGUF'
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,423 @@
|
||||
import type { ModelSidecar } from '$lib/constants';
|
||||
import { ModelAuxSidecar, ModelDraftSidecar } from '$lib/enums';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const { parseModelId } = ModelsService;
|
||||
|
||||
describe('parseModelId', () => {
|
||||
it('handles unknown patterns correctly', () => {
|
||||
expect(parseModelId('model-name-1')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'model-name-1',
|
||||
orgName: null,
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'model-name-1',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('org/model-name-2')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'model-name-2',
|
||||
orgName: 'org',
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'org/model-name-2',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts model parameters correctly', () => {
|
||||
expect(parseModelId('model-100B-BF16')).toMatchObject({ params: '100B' });
|
||||
expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ params: '100B' });
|
||||
});
|
||||
|
||||
it('extracts model parameters correctly in lowercase', () => {
|
||||
expect(parseModelId('model-100b-bf16')).toMatchObject({ params: '100B' });
|
||||
expect(parseModelId('model-100b:q4_k_m')).toMatchObject({ params: '100B' });
|
||||
});
|
||||
|
||||
it('extracts effective parameters correctly', () => {
|
||||
expect(parseModelId('model-E4B-BF16')).toMatchObject({ params: 'E4B' });
|
||||
expect(parseModelId('model-e2b:q4_k_m')).toMatchObject({ params: 'E2B' });
|
||||
});
|
||||
|
||||
it('extracts activated parameters correctly', () => {
|
||||
expect(parseModelId('model-100B-A10B-BF16')).toMatchObject({ activatedParams: 'A10B' });
|
||||
expect(parseModelId('model-100B-A10B:Q4_K_M')).toMatchObject({ activatedParams: 'A10B' });
|
||||
});
|
||||
|
||||
it('extracts activated parameters correctly in lowercase', () => {
|
||||
expect(parseModelId('model-100b-a10b-bf16')).toMatchObject({ activatedParams: 'A10B' });
|
||||
expect(parseModelId('model-100b-a10b:q4_k_m')).toMatchObject({ activatedParams: 'A10B' });
|
||||
});
|
||||
|
||||
it('extracts quantization correctly', () => {
|
||||
// Dash-separated quantization
|
||||
expect(parseModelId('model-100B-UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' });
|
||||
expect(parseModelId('model-100B-IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' });
|
||||
expect(parseModelId('model-100B-Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' });
|
||||
expect(parseModelId('model-100B-Q8_0')).toMatchObject({ quantization: 'Q8_0' });
|
||||
expect(parseModelId('model-100B-UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' });
|
||||
expect(parseModelId('model-100B-F16')).toMatchObject({ quantization: 'F16' });
|
||||
expect(parseModelId('model-100B-BF16')).toMatchObject({ quantization: 'BF16' });
|
||||
expect(parseModelId('model-100B-MXFP4')).toMatchObject({ quantization: 'MXFP4' });
|
||||
|
||||
// Colon-separated quantization
|
||||
expect(parseModelId('model-100B:UD-IQ1_S')).toMatchObject({ quantization: 'UD-IQ1_S' });
|
||||
expect(parseModelId('model-100B:IQ4_XS')).toMatchObject({ quantization: 'IQ4_XS' });
|
||||
expect(parseModelId('model-100B:Q4_K_M')).toMatchObject({ quantization: 'Q4_K_M' });
|
||||
expect(parseModelId('model-100B:Q8_0')).toMatchObject({ quantization: 'Q8_0' });
|
||||
expect(parseModelId('model-100B:UD-Q8_K_XL')).toMatchObject({ quantization: 'UD-Q8_K_XL' });
|
||||
expect(parseModelId('model-100B:F16')).toMatchObject({ quantization: 'F16' });
|
||||
expect(parseModelId('model-100B:BF16')).toMatchObject({ quantization: 'BF16' });
|
||||
expect(parseModelId('model-100B:MXFP4')).toMatchObject({ quantization: 'MXFP4' });
|
||||
|
||||
// Dot-separated quantization
|
||||
expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toMatchObject({
|
||||
quantization: 'Q4_K_M'
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts additional tags correctly', () => {
|
||||
expect(parseModelId('model-100B-foobar-Q4_K_M')).toMatchObject({ tags: ['foobar'] });
|
||||
expect(parseModelId('model-100B-A10B-foobar-1M-BF16')).toMatchObject({
|
||||
tags: ['foobar', '1M']
|
||||
});
|
||||
expect(parseModelId('model-100B-1M-foobar:UD-Q8_K_XL')).toMatchObject({
|
||||
tags: ['1M', 'foobar']
|
||||
});
|
||||
});
|
||||
|
||||
it('filters out container format segments from tags', () => {
|
||||
expect(parseModelId('model-100B-GGUF-Instruct-BF16')).toMatchObject({
|
||||
tags: ['Instruct']
|
||||
});
|
||||
expect(parseModelId('model-100B-GGML-Instruct:Q4_K_M')).toMatchObject({
|
||||
tags: ['Instruct']
|
||||
});
|
||||
});
|
||||
|
||||
it('splits a download tag into quant and sidecar', () => {
|
||||
expect(parseModelId('ggml-org/Qwen3.8-27B-GGUF:Q8_0-dflash')).toMatchObject({
|
||||
orgName: 'ggml-org',
|
||||
params: '27B',
|
||||
quantization: 'Q8_0',
|
||||
sidecar: 'dflash'
|
||||
});
|
||||
|
||||
expect(parseModelId('ggml-org/Qwen3.8-27B-GGUF:Q4_K_M-mtp')).toMatchObject({
|
||||
quantization: 'Q4_K_M',
|
||||
sidecar: 'mtp'
|
||||
});
|
||||
|
||||
// a bare sidecar tag is the sidecar alone, with no quant
|
||||
expect(parseModelId('ggml-org/Qwen3.8-27B-GGUF:mmproj')).toMatchObject({
|
||||
quantization: null,
|
||||
sidecar: 'mmproj'
|
||||
});
|
||||
|
||||
// a plain quant tag stays a quant
|
||||
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:UD-Q2_K_XL')).toMatchObject({
|
||||
quantization: 'UD-Q2_K_XL',
|
||||
sidecar: null
|
||||
});
|
||||
});
|
||||
|
||||
it('strips trailing container format segments from model names', () => {
|
||||
expect(parseModelId('unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'DeepSeek-V4-Flash-0731',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q2_K_XL',
|
||||
raw: 'unsloth/DeepSeek-V4-Flash-0731-GGUF:Q2_K_XL',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('unsloth/Laguna-S-2.1-GGUF:Q4_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Laguna-S-2.1',
|
||||
orgName: 'unsloth',
|
||||
params: null,
|
||||
quantization: 'Q4_K_XL',
|
||||
raw: 'unsloth/Laguna-S-2.1-GGUF:Q4_K_XL',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('org/Model-Name-GGUF')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Model-Name',
|
||||
orgName: 'org',
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: 'org/Model-Name-GGUF',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('handles real-world examples correctly', () => {
|
||||
expect(parseModelId('meta-llama/Llama-3.1-8B')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Llama-3.1',
|
||||
orgName: 'meta-llama',
|
||||
params: '8B',
|
||||
quantization: null,
|
||||
raw: 'meta-llama/Llama-3.1-8B',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-120b-MXFP4')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '120B',
|
||||
quantization: 'MXFP4',
|
||||
raw: 'openai/gpt-oss-120b-MXFP4',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-20b:Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '20B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'openai/gpt-oss-20b:Q4_K_M',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16')).toStrictEqual({
|
||||
activatedParams: 'A3B',
|
||||
modelName: 'Qwen3-Coder',
|
||||
orgName: 'Qwen',
|
||||
params: '30B',
|
||||
quantization: 'BF16',
|
||||
raw: 'Qwen/Qwen3-Coder-30B-A3B-Instruct-1M-BF16',
|
||||
sidecar: null,
|
||||
tags: ['Instruct', '1M']
|
||||
});
|
||||
});
|
||||
|
||||
it('handles real-world examples with quantization in segments', () => {
|
||||
expect(parseModelId('meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Llama-4-Scout',
|
||||
orgName: 'meta-llama',
|
||||
params: '17B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'meta-llama/Llama-4-Scout-17B-16E-Instruct-Q4_K_M',
|
||||
sidecar: null,
|
||||
tags: ['16E', 'Instruct']
|
||||
});
|
||||
|
||||
expect(parseModelId('MiniMaxAI/MiniMax-M2-IQ4_XS')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'MiniMax-M2',
|
||||
orgName: 'MiniMaxAI',
|
||||
params: null,
|
||||
quantization: 'IQ4_XS',
|
||||
raw: 'MiniMaxAI/MiniMax-M2-IQ4_XS',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('MiniMaxAI/MiniMax-M2-UD-Q3_K_XL')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'MiniMax-M2',
|
||||
orgName: 'MiniMaxAI',
|
||||
params: null,
|
||||
quantization: 'UD-Q3_K_XL',
|
||||
raw: 'MiniMaxAI/MiniMax-M2-UD-Q3_K_XL',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Devstral-2',
|
||||
orgName: 'mistralai',
|
||||
params: '123B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'mistralai/Devstral-2-123B-Instruct-2512-Q4_K_M',
|
||||
sidecar: null,
|
||||
tags: ['Instruct', '2512']
|
||||
});
|
||||
|
||||
expect(parseModelId('mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Devstral-Small-2',
|
||||
orgName: 'mistralai',
|
||||
params: '24B',
|
||||
quantization: 'Q8_0',
|
||||
raw: 'mistralai/Devstral-Small-2-24B-Instruct-2512-Q8_0',
|
||||
sidecar: null,
|
||||
tags: ['Instruct', '2512']
|
||||
});
|
||||
|
||||
expect(parseModelId('noctrex/GLM-4.7-Flash-MXFP4_MOE')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'GLM-4.7-Flash',
|
||||
orgName: 'noctrex',
|
||||
params: null,
|
||||
quantization: 'MXFP4_MOE',
|
||||
raw: 'noctrex/GLM-4.7-Flash-MXFP4_MOE',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3-Coder-Next-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'Qwen3-Coder-Next',
|
||||
orgName: 'Qwen',
|
||||
params: null,
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'Qwen/Qwen3-Coder-Next-Q4_K_M',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-120b-Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '120B',
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'openai/gpt-oss-120b-Q4_K_M',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('openai/gpt-oss-20b-F16')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'gpt-oss',
|
||||
orgName: 'openai',
|
||||
params: '20B',
|
||||
quantization: 'F16',
|
||||
raw: 'openai/gpt-oss-20b-F16',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
|
||||
expect(parseModelId('nomic-embed-text-v2-moe.Q4_K_M')).toStrictEqual({
|
||||
activatedParams: null,
|
||||
modelName: 'nomic-embed-text-v2-moe',
|
||||
orgName: null,
|
||||
params: null,
|
||||
quantization: 'Q4_K_M',
|
||||
raw: 'nomic-embed-text-v2-moe.Q4_K_M',
|
||||
sidecar: null,
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('handles ambiguous model names', () => {
|
||||
// Qwen3.5 Instruct vs Thinking — tags should distinguish them
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Instruct')).toMatchObject({
|
||||
activatedParams: 'A3B',
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
tags: ['Instruct']
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Thinking')).toMatchObject({
|
||||
activatedParams: 'A3B',
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
tags: ['Thinking']
|
||||
});
|
||||
|
||||
// Dot-separated quantization with variant suffixes
|
||||
expect(parseModelId('gemma-3-27b-it-heretic-v2.Q8_0')).toMatchObject({
|
||||
modelName: 'gemma-3',
|
||||
params: '27B',
|
||||
quantization: 'Q8_0',
|
||||
tags: ['it', 'heretic', 'v2']
|
||||
});
|
||||
|
||||
expect(parseModelId('gemma-3-27b-it.Q8_0')).toMatchObject({
|
||||
modelName: 'gemma-3',
|
||||
params: '27B',
|
||||
quantization: 'Q8_0',
|
||||
tags: ['it']
|
||||
});
|
||||
});
|
||||
|
||||
it('parses sidecar file tokens', () => {
|
||||
// sidecar prefix: bare filename or multi-slash path reduces to the filename
|
||||
expect(parseModelId('mtp-Q4_0.gguf')).toMatchObject({
|
||||
quantization: 'Q4_0',
|
||||
sidecar: ModelDraftSidecar.MTP
|
||||
});
|
||||
|
||||
expect(parseModelId('ggml-org/Model-GGUF/mtp-Q4_0.gguf')).toMatchObject({
|
||||
quantization: 'Q4_0',
|
||||
sidecar: ModelDraftSidecar.MTP
|
||||
});
|
||||
|
||||
expect(parseModelId('ggml-org/Model-GGUF/mmproj-F16.gguf')).toMatchObject({
|
||||
quantization: 'F16',
|
||||
sidecar: ModelAuxSidecar.MMPROJ
|
||||
});
|
||||
|
||||
// embedded-draft suffix: -<type> only strips when preceded by a quant
|
||||
expect(parseModelId('ggml-org/Hy3-IQ1_M-mtp')).toMatchObject({
|
||||
modelName: 'Hy3',
|
||||
quantization: 'IQ1_M',
|
||||
sidecar: ModelDraftSidecar.MTP
|
||||
});
|
||||
|
||||
// a model literally named MyModel-mtp is not a draft
|
||||
expect(parseModelId('ggml-org/MyModel-mtp')).toMatchObject({
|
||||
modelName: 'MyModel-mtp',
|
||||
sidecar: null
|
||||
});
|
||||
|
||||
// no sidecar
|
||||
expect(parseModelId('ggml-org/model-Q4_K_M')).toMatchObject({
|
||||
quantization: 'Q4_K_M',
|
||||
sidecar: null
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('draftSidecarsByRepo', () => {
|
||||
const entry = (id: string, sidecars: ModelSidecar[], status = 'unloaded') => ({
|
||||
id,
|
||||
sidecars,
|
||||
status: { value: status }
|
||||
});
|
||||
|
||||
it('keeps one badge per kind, at the repo that has it on disk', () => {
|
||||
const byRepo = ModelsService.draftSidecarsByRepo([
|
||||
entry('ggml-org/model:Q4_0', [ModelDraftSidecar.MTP]),
|
||||
entry('ggml-org/model:Q8_0', [ModelDraftSidecar.MTP])
|
||||
]);
|
||||
|
||||
// the listing reports kinds, never the draft's file: its quant stays unknown
|
||||
expect(byRepo.get('ggml-org/model')).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: null, repo: 'ggml-org/model' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops duplicates, aux sidecars and unfinished downloads', () => {
|
||||
const byRepo = ModelsService.draftSidecarsByRepo([
|
||||
entry('ggml-org/model:Q4_0', [ModelDraftSidecar.MTP, ModelAuxSidecar.MMPROJ]),
|
||||
entry('ggml-org/model:Q4_0', [ModelDraftSidecar.MTP]),
|
||||
entry('ggml-org/model:Q8_0', [ModelDraftSidecar.MTP], 'incomplete')
|
||||
]);
|
||||
|
||||
expect(byRepo.get('ggml-org/model')).toEqual([
|
||||
{ kind: ModelDraftSidecar.MTP, quant: null, repo: 'ggml-org/model' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { ModelOption } from '$lib/types/models';
|
||||
import {
|
||||
filterModelOptions,
|
||||
type GroupedModelOptions,
|
||||
groupModelOptions,
|
||||
windowLocalGroups
|
||||
} from '$lib/utils/model-list';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function option(model: string, orgName: string | null = null): ModelOption {
|
||||
return {
|
||||
capabilities: [],
|
||||
id: model,
|
||||
model,
|
||||
name: model,
|
||||
parsedId: {
|
||||
activatedParams: null,
|
||||
modelName: model,
|
||||
orgName,
|
||||
params: null,
|
||||
quantization: null,
|
||||
raw: model,
|
||||
sidecar: null,
|
||||
tags: []
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const row = (model: string) => ({ option: option(model) });
|
||||
|
||||
function groups(): GroupedModelOptions {
|
||||
return {
|
||||
available: [
|
||||
{ items: [row('org/a'), row('org/b'), row('org/c')], orgName: 'org' },
|
||||
{ items: [row('other/d')], orgName: 'other' }
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
describe('windowLocalGroups', () => {
|
||||
it('fills the budget in listed order', () => {
|
||||
const windowed = windowLocalGroups(groups(), 2);
|
||||
|
||||
expect(windowed.available.flatMap((group) => group.items).map((item) => item.option.model)) //
|
||||
.toEqual(['org/a', 'org/b']);
|
||||
expect(windowed.shown).toBe(2);
|
||||
});
|
||||
|
||||
it('drops an org whose whole budget is spent', () => {
|
||||
const windowed = windowLocalGroups(groups(), 3);
|
||||
|
||||
expect(windowed.available.map((group) => group.orgName)).toEqual(['org']);
|
||||
expect(windowed.shown).toBe(3);
|
||||
});
|
||||
|
||||
it('keeps every group when the budget covers the list', () => {
|
||||
const windowed = windowLocalGroups(groups(), 99);
|
||||
|
||||
expect(windowed.available.map((group) => group.orgName)).toEqual(['org', 'other']);
|
||||
expect(windowed.shown).toBe(4);
|
||||
});
|
||||
|
||||
it('shows nothing on a zero budget', () => {
|
||||
const windowed = windowLocalGroups(groups(), 0);
|
||||
|
||||
expect(windowed.available).toEqual([]);
|
||||
expect(windowed.shown).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterModelOptions', () => {
|
||||
const options = [
|
||||
{ ...option('org/Qwen3-8B'), aliases: ['qwen'], tags: ['vision'] },
|
||||
option('org/Llama-3-8B')
|
||||
];
|
||||
|
||||
it('keeps every option without a term', () => {
|
||||
expect(filterModelOptions(options, ' ')).toEqual(options);
|
||||
});
|
||||
|
||||
it('matches the model id and the name', () => {
|
||||
expect(filterModelOptions(options, 'llama').map((item) => item.model)).toEqual([
|
||||
'org/Llama-3-8B'
|
||||
]);
|
||||
});
|
||||
|
||||
it('matches aliases and tags', () => {
|
||||
expect(filterModelOptions(options, 'qwen').map((item) => item.model)).toEqual(['org/Qwen3-8B']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupModelOptions', () => {
|
||||
it('groups the options by org, unnamed last', () => {
|
||||
const grouped = groupModelOptions([
|
||||
option('org/a', 'org'),
|
||||
option('org/b', 'org'),
|
||||
option('other/c', 'other'),
|
||||
option('plain')
|
||||
]);
|
||||
|
||||
expect(grouped.available.map((group) => group.orgName)).toEqual(['org', 'other', null]);
|
||||
expect(grouped.available[0].items.map((item) => item.option.model)).toEqual(['org/a', 'org/b']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isValidModelName, normalizeModelName } from '$lib/utils/model-names';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('normalizeModelName', () => {
|
||||
it('preserves Hugging Face org/model format (single slash)', () => {
|
||||
// Single slash is treated as Hugging Face format and preserved
|
||||
expect(normalizeModelName('meta-llama/Llama-3.1-8B')).toBe('meta-llama/Llama-3.1-8B');
|
||||
expect(normalizeModelName('models/model-name-1')).toBe('models/model-name-1');
|
||||
});
|
||||
|
||||
it('extracts filename from multi-segment paths', () => {
|
||||
// Multiple slashes -> extract just the filename
|
||||
expect(normalizeModelName('path/to/model/model-name-2')).toBe('model-name-2');
|
||||
expect(normalizeModelName('/absolute/path/to/model')).toBe('model');
|
||||
});
|
||||
|
||||
it('extracts filename from backslash paths', () => {
|
||||
expect(normalizeModelName('C\\Models\\model-name-1')).toBe('model-name-1');
|
||||
expect(normalizeModelName('path\\to\\model\\model-name-2')).toBe('model-name-2');
|
||||
});
|
||||
|
||||
it('handles mixed path separators', () => {
|
||||
expect(normalizeModelName('path/to\\model/model-name-2')).toBe('model-name-2');
|
||||
});
|
||||
|
||||
it('returns simple names as-is', () => {
|
||||
expect(normalizeModelName('simple-model')).toBe('simple-model');
|
||||
expect(normalizeModelName('model-name-2')).toBe('model-name-2');
|
||||
});
|
||||
|
||||
it('trims whitespace', () => {
|
||||
expect(normalizeModelName(' model-name ')).toBe('model-name');
|
||||
});
|
||||
|
||||
it('returns empty string for empty input', () => {
|
||||
expect(normalizeModelName('')).toBe('');
|
||||
expect(normalizeModelName(' ')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidModelName', () => {
|
||||
it('returns true for valid names', () => {
|
||||
expect(isValidModelName('model')).toBe(true);
|
||||
expect(isValidModelName('path/to/model.bin')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for empty values', () => {
|
||||
expect(isValidModelName('')).toBe(false);
|
||||
expect(isValidModelName(' ')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
import { ModelAuxSidecar, ModelDraftSidecar, SidecarForm } from '$lib/enums';
|
||||
import { HuggingFaceService } from '$lib/services/huggingface.service';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
import { sidecarFromTag } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const { buildDownloadTag } = ModelsService;
|
||||
const { extractQuantMeta } = HuggingFaceService;
|
||||
|
||||
// the sidecar filename grammar mirrors the server (common/download.cpp):
|
||||
// the token must be lowercase, and it can sit at the start, between name
|
||||
// segments, or at the end of the file name
|
||||
describe('extractQuantMeta', () => {
|
||||
it('parses the prefix form', () => {
|
||||
expect(extractQuantMeta('mtp-Model-Q4_0.gguf')).toStrictEqual({
|
||||
quant: 'Q4_0',
|
||||
shared: false,
|
||||
sidecar: ModelDraftSidecar.MTP,
|
||||
sidecarForm: SidecarForm.PREFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the infix form', () => {
|
||||
expect(extractQuantMeta('Model-mtp-Q4_0.gguf')).toStrictEqual({
|
||||
quant: 'Q4_0',
|
||||
shared: false,
|
||||
sidecar: ModelDraftSidecar.MTP,
|
||||
sidecarForm: SidecarForm.INFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses the suffix form', () => {
|
||||
expect(extractQuantMeta('gemma-4-E2B-it-BF16-mtp.gguf')).toStrictEqual({
|
||||
quant: 'BF16',
|
||||
shared: false,
|
||||
sidecar: ModelDraftSidecar.MTP,
|
||||
sidecarForm: SidecarForm.SUFFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an uppercase infix token', () => {
|
||||
expect(extractQuantMeta('gemma-4-31B-it-MTP-BF16.gguf')).toStrictEqual({
|
||||
quant: 'BF16',
|
||||
shared: false,
|
||||
sidecar: ModelDraftSidecar.MTP,
|
||||
sidecarForm: SidecarForm.INFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses an uppercase trailing token', () => {
|
||||
expect(extractQuantMeta('gemma-4-E2B-it-BF16-MTP.gguf')).toStrictEqual({
|
||||
quant: 'BF16',
|
||||
shared: false,
|
||||
sidecar: ModelDraftSidecar.MTP,
|
||||
sidecarForm: SidecarForm.SUFFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a short-form sidecar with a bare quant', () => {
|
||||
expect(extractQuantMeta('mmproj-F16.gguf')).toStrictEqual({
|
||||
quant: 'F16',
|
||||
shared: false,
|
||||
sidecar: ModelAuxSidecar.MMPROJ,
|
||||
sidecarForm: SidecarForm.PREFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a bare sidecar file', () => {
|
||||
expect(extractQuantMeta('imatrix.gguf')).toStrictEqual({
|
||||
quant: null,
|
||||
shared: false,
|
||||
sidecar: ModelAuxSidecar.IMATRIX,
|
||||
sidecarForm: SidecarForm.PREFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a standalone sidecar with a draft tail', () => {
|
||||
expect(extractQuantMeta('Model-mtp-draft.gguf')).toStrictEqual({
|
||||
quant: null,
|
||||
shared: false,
|
||||
sidecar: ModelDraftSidecar.MTP,
|
||||
sidecarForm: SidecarForm.SUFFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a nested sidecar path by its file name', () => {
|
||||
expect(extractQuantMeta('MTP/mtp-Model-Q4_0.gguf')).toStrictEqual({
|
||||
quant: 'Q4_0',
|
||||
shared: false,
|
||||
sidecar: ModelDraftSidecar.MTP,
|
||||
sidecarForm: SidecarForm.PREFIX
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-weight files', () => {
|
||||
expect(extractQuantMeta('README.md')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildDownloadTag', () => {
|
||||
it('appends the quantization', () => {
|
||||
expect(buildDownloadTag('org/repo', 'Q4_0', null)).toBe('org/repo:Q4_0');
|
||||
});
|
||||
|
||||
it('appends the quantization and sidecar', () => {
|
||||
expect(buildDownloadTag('org/repo', 'Q4_0', ModelDraftSidecar.MTP)).toBe('org/repo:Q4_0-mtp');
|
||||
});
|
||||
|
||||
it('uses the sidecar alone when there is no quant', () => {
|
||||
expect(buildDownloadTag('org/repo', null, ModelAuxSidecar.MMPROJ)).toBe('org/repo:mmproj');
|
||||
});
|
||||
|
||||
it('returns the repo id untouched without a tag', () => {
|
||||
expect(buildDownloadTag('org/repo', null, null)).toBe('org/repo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sidecarFromTag', () => {
|
||||
it('reads the token after the last dash', () => {
|
||||
expect(sidecarFromTag('Q4_0-mtp')).toBe(ModelDraftSidecar.MTP);
|
||||
expect(sidecarFromTag('mmproj')).toBe(ModelAuxSidecar.MMPROJ);
|
||||
});
|
||||
|
||||
it('returns null for quant-only and unrelated tags', () => {
|
||||
expect(sidecarFromTag('Q4_K_XL')).toBeNull();
|
||||
expect(sidecarFromTag('UD-Q4_K_XL')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { isExitCodeSummaryLine, parseExecShellCommandExitStatus } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('parseExecShellCommandExitStatus', () => {
|
||||
it('returns undefined when result is empty', () => {
|
||||
expect(parseExecShellCommandExitStatus(undefined)).toBeUndefined();
|
||||
expect(parseExecShellCommandExitStatus('')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses a zero-exit summary at end of clean stdout', () => {
|
||||
const status = parseExecShellCommandExitStatus('hello world\n[exit code: 0]');
|
||||
|
||||
expect(status).toEqual({
|
||||
code: 0,
|
||||
rawText: '[exit code: 0]',
|
||||
timedOut: false
|
||||
});
|
||||
});
|
||||
|
||||
it('parses a non-zero exit summary', () => {
|
||||
const status = parseExecShellCommandExitStatus('cargo: error[E0425]\n[exit code: 101]');
|
||||
|
||||
expect(status?.code).toBe(101);
|
||||
expect(status?.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('detects timed-out suffix', () => {
|
||||
const status = parseExecShellCommandExitStatus(
|
||||
'still building...\n[exit code: -1] [exit due to timed out]'
|
||||
);
|
||||
|
||||
expect(status?.code).toBe(-1);
|
||||
expect(status?.timedOut).toBe(true);
|
||||
});
|
||||
|
||||
it('tolerates trailing whitespace after the tail line', () => {
|
||||
const status = parseExecShellCommandExitStatus('[exit code: 0] \n\n');
|
||||
|
||||
expect(status?.code).toBe(0);
|
||||
});
|
||||
|
||||
it('does not match an explanatory mention of "[exit code:" not at end', () => {
|
||||
// Any non-trailing occurrence should NOT trigger the badge - we
|
||||
// anchor to the absolute end of the string.
|
||||
const status = parseExecShellCommandExitStatus(
|
||||
'the shell prints [exit code: 0]\nwhen done\nreally done\n'
|
||||
);
|
||||
|
||||
expect(status).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not match mid-stream exit lines followed by more output', () => {
|
||||
const status = parseExecShellCommandExitStatus('[exit code: 0]\nmore output keeps streaming');
|
||||
|
||||
expect(status).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isExitCodeSummaryLine', () => {
|
||||
const status = parseExecShellCommandExitStatus('hello\n[exit code: 7]');
|
||||
|
||||
it('matches when line trims to the tail text', () => {
|
||||
expect(isExitCodeSummaryLine(' [exit code: 7] ', status)).toBe(true);
|
||||
});
|
||||
|
||||
it('does not match unrelated lines', () => {
|
||||
expect(isExitCodeSummaryLine('plain output line', status)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for missing status argument', () => {
|
||||
expect(isExitCodeSummaryLine('[exit code: 7]', undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExecShellCommandExitStatus tail scan', () => {
|
||||
it('finds the marker at the end of a blob larger than the tail window', () => {
|
||||
// the parser matches only the last ~128 chars; a marker past that
|
||||
// window must still parse, and an earlier fake must not match
|
||||
const blob = `${'the shell prints [exit code: 1] mid-stream\n'.repeat(2000)}[exit code: 0]`;
|
||||
const status = parseExecShellCommandExitStatus(blob);
|
||||
|
||||
expect(status?.code).toBe(0);
|
||||
expect(status?.timedOut).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps rejecting markers that are not at the absolute end', () => {
|
||||
const blob = `${'stdout\n'.repeat(2000)}[exit code: 0]\nsome trailing log line`;
|
||||
|
||||
expect(parseExecShellCommandExitStatus(blob)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
import { MCP_SERVER_ID_PREFIX } from '$lib/constants';
|
||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the mcpServers settings parser.
|
||||
*
|
||||
* The parser has to be resilient to anything that may live in the
|
||||
* user's localStorage: malformed JSON, wrong shapes, missing fields,
|
||||
* falsy-but-not-zero numbers, and entry arrays that have been mutated
|
||||
* by the user via the settings form.
|
||||
*/
|
||||
describe('parseMcpServerSettings', () => {
|
||||
it('returns an empty array for falsy or whitespace-only input', () => {
|
||||
expect(parseMcpServerSettings(null)).toEqual([]);
|
||||
expect(parseMcpServerSettings(undefined)).toEqual([]);
|
||||
expect(parseMcpServerSettings('')).toEqual([]);
|
||||
expect(parseMcpServerSettings(' ')).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty array and logs a warning for invalid JSON strings', () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
|
||||
expect(parseMcpServerSettings('{not-json')).toEqual([]);
|
||||
expect(warn).toHaveBeenCalled();
|
||||
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
it('returns an empty array for valid JSON that is not an array', () => {
|
||||
expect(parseMcpServerSettings('"plain-string"')).toEqual([]);
|
||||
expect(parseMcpServerSettings('{"id":"foo"}')).toEqual([]);
|
||||
expect(parseMcpServerSettings('42')).toEqual([]);
|
||||
expect(parseMcpServerSettings('null')).toEqual([]);
|
||||
});
|
||||
|
||||
it('drops entries with no parseable id and substitutes a stable fallback', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([{ enabled: true, url: 'https://a.test' }, { url: 'https://b.test' }])
|
||||
);
|
||||
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed[0]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-1`);
|
||||
expect(parsed[1]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-2`);
|
||||
});
|
||||
|
||||
it('reuses the first id when it is present and falls back only for missing ones', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'custom-1', url: 'https://a.test' },
|
||||
{ url: 'https://b.test' },
|
||||
{ id: 'custom-3', url: 'https://c.test' }
|
||||
])
|
||||
);
|
||||
|
||||
expect(parsed[0]?.id).toBe('custom-1');
|
||||
expect(parsed[1]?.id).toBe(`${MCP_SERVER_ID_PREFIX}-2`);
|
||||
expect(parsed[2]?.id).toBe('custom-3');
|
||||
});
|
||||
|
||||
it('does not emit a per-server timeout, the request timeout is a live global setting', () => {
|
||||
// A stored per-server requestTimeoutSeconds was never editable in
|
||||
// any UI and froze the global setting at server creation time,
|
||||
// making the Settings value a no-op for existing servers. The
|
||||
// parser drops the field so the global applies live everywhere.
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([{ id: 'a', requestTimeoutSeconds: 45, url: 'https://a.test' }])
|
||||
);
|
||||
|
||||
expect(parsed[0]).not.toHaveProperty('requestTimeoutSeconds');
|
||||
});
|
||||
|
||||
it('treats whitespace-only headers strings as undefined', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ headers: ' ', id: 'a', url: 'https://a.test' },
|
||||
{ headers: '{"X-Foo":"bar"}', id: 'b', url: 'https://b.test' }
|
||||
])
|
||||
);
|
||||
|
||||
// The parser trims headers and coerces empty/whitespace to undefined.
|
||||
expect(parsed[0]?.headers).toBeUndefined();
|
||||
expect(parsed[1]?.headers).toBe('{"X-Foo":"bar"}');
|
||||
});
|
||||
|
||||
it('defaults coercion for booleans (undefined -> false, true -> true)', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'a', url: 'https://a.test' },
|
||||
{ enabled: true, id: 'b', url: 'https://b.test' },
|
||||
{ enabled: false, id: 'c', url: 'https://c.test' },
|
||||
{ id: 'd', url: 'https://d.test', useProxy: true }
|
||||
])
|
||||
);
|
||||
|
||||
expect(parsed[0]?.enabled).toBe(false);
|
||||
expect(parsed[1]?.enabled).toBe(true);
|
||||
expect(parsed[2]?.enabled).toBe(false);
|
||||
expect(parsed[0]?.useProxy).toBe(false);
|
||||
expect(parsed[3]?.useProxy).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps disabled entries in the list, enabled is state and never a visibility filter', () => {
|
||||
// Regression guard for issue #25625: filtering the server list on
|
||||
// `enabled` hides a toggled-off server from every UI surface with
|
||||
// no way to re-enable it. Any list derived from this parser must
|
||||
// contain disabled entries.
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ enabled: true, id: 'on', url: 'https://on.test' },
|
||||
{ enabled: false, id: 'off', url: 'https://off.test' }
|
||||
])
|
||||
);
|
||||
|
||||
expect(parsed.map((entry) => entry.id)).toEqual(['on', 'off']);
|
||||
expect(parsed[1]?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves input order when mapping entries', () => {
|
||||
const source = [
|
||||
{ id: 'gamma', url: 'https://c.test' },
|
||||
{ id: 'alpha', url: 'https://a.test' },
|
||||
{ id: 'beta', url: 'https://b.test' }
|
||||
];
|
||||
const parsed = parseMcpServerSettings(JSON.stringify(source));
|
||||
|
||||
expect(parsed.map((entry) => entry.id)).toEqual(['gamma', 'alpha', 'beta']);
|
||||
});
|
||||
|
||||
it('passes non-string raw input through the JSON-equality path', () => {
|
||||
const parsed = parseMcpServerSettings([
|
||||
{ id: 'a', url: 'https://a.test' },
|
||||
{ enabled: true, id: 'b', url: 'https://b.test' }
|
||||
]);
|
||||
|
||||
expect(parsed).toHaveLength(2);
|
||||
expect(parsed[0]?.id).toBe('a');
|
||||
expect(parsed[1]?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('coerces non-string url values to an empty string rather than throwing', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([{ id: 'a', url: 42 }, { id: 'b' }, { id: 'c', url: 'https://c.test' }])
|
||||
);
|
||||
|
||||
expect(parsed[0]?.url).toBe('');
|
||||
expect(parsed[1]?.url).toBe('');
|
||||
expect(parsed[2]?.url).toBe('https://c.test');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,138 @@
|
||||
// Tests for the memoized parseToolCalls and O(1) tool message lookup in
|
||||
// deriveAgenticSections. These were added to prevent regressions where
|
||||
// streaming text tokens trigger redundant JSON.parse calls on unchanged
|
||||
// tool call data.
|
||||
|
||||
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { deriveAgenticSections } from '$lib/utils/agentic';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
function makeMessage(overrides: Partial<DatabaseMessage>): DatabaseMessage {
|
||||
return {
|
||||
children: [],
|
||||
content: '',
|
||||
convId: 'c1',
|
||||
id: 'm1',
|
||||
parent: null,
|
||||
role: MessageRole.ASSISTANT,
|
||||
timestamp: 0,
|
||||
type: 'text',
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
describe('parseToolCalls memoization', () => {
|
||||
it('returns the same array reference for the same JSON string', () => {
|
||||
// parseToolCalls is not exported, but deriveAgenticSections uses it
|
||||
// internally. We verify memoization through behavior: calling
|
||||
// deriveAgenticSections twice with the same toolCalls should not
|
||||
// re-parse (which we verify by checking the returned sections
|
||||
// are equivalent).
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
const sections1 = deriveAgenticSections(msg, [], [], false);
|
||||
const sections2 = deriveAgenticSections(msg, [], [], false);
|
||||
|
||||
expect(sections1).toHaveLength(sections2.length);
|
||||
expect(sections1[0].type).toBe(sections2[0].type);
|
||||
});
|
||||
|
||||
it('does not re-parse JSON on cache hit', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
const spy = vi.spyOn(JSON, 'parse');
|
||||
|
||||
deriveAgenticSections(msg, [], [], false);
|
||||
const callsAfterFirst = spy.mock.calls.length;
|
||||
|
||||
deriveAgenticSections(msg, [], [], false);
|
||||
expect(spy.mock.calls.length).toBe(callsAfterFirst);
|
||||
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('handles empty/undefined toolCalls without error', () => {
|
||||
const msg = makeMessage({ content: 'hello' });
|
||||
const sections = deriveAgenticSections(msg, [], [], false);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
});
|
||||
|
||||
it('handles invalid JSON gracefully', () => {
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: '{invalid' });
|
||||
const sections = deriveAgenticSections(msg, [], [], false);
|
||||
|
||||
// Should return just the text section, no tool call sections
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe(AgenticSectionType.TEXT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveAgenticSections O(1) tool message lookup', () => {
|
||||
it('matches tool messages to tool calls by toolCallId', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test_1' }, id: 'call_1', type: 'function' },
|
||||
{ function: { arguments: '{}', name: 'test_2' }, id: 'call_2', type: 'function' }
|
||||
]);
|
||||
const toolMessages = [
|
||||
makeMessage({ content: 'result_1', role: MessageRole.TOOL, toolCallId: 'call_1' }),
|
||||
makeMessage({ content: 'result_2', role: MessageRole.TOOL, toolCallId: 'call_2' })
|
||||
];
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
const sections = deriveAgenticSections(msg, toolMessages, [], false);
|
||||
// Expect: TEXT + 2 TOOL_CALL sections
|
||||
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
|
||||
|
||||
expect(toolCallSections).toHaveLength(2);
|
||||
expect(toolCallSections[0].toolResult).toBe('result_1');
|
||||
expect(toolCallSections[1].toolResult).toBe('result_2');
|
||||
});
|
||||
|
||||
it('handles missing tool messages (pending calls during streaming)', () => {
|
||||
const toolCallsJson = JSON.stringify([
|
||||
{ function: { arguments: '{}', name: 'test' }, id: 'call_1', type: 'function' }
|
||||
]);
|
||||
const msg = makeMessage({ content: '', toolCalls: toolCallsJson });
|
||||
const sections = deriveAgenticSections(msg, [], [], true);
|
||||
const toolCallSection = sections.find((s) => s.type === AgenticSectionType.TOOL_CALL_PENDING);
|
||||
|
||||
expect(toolCallSection).toBeDefined();
|
||||
expect(toolCallSection?.content).toBe('');
|
||||
});
|
||||
|
||||
it('scales with many tool calls (no O(n^2) blowup)', () => {
|
||||
const N = 100;
|
||||
const toolCalls = Array.from(
|
||||
{ length: N },
|
||||
(_, i): ApiChatCompletionToolCall => ({
|
||||
function: { arguments: '{}', name: `tool_${i}` },
|
||||
id: `call_${i}`,
|
||||
type: 'function'
|
||||
})
|
||||
);
|
||||
const toolCallsJson = JSON.stringify(toolCalls);
|
||||
const toolMessages = Array.from({ length: N }, (_, i) =>
|
||||
makeMessage({
|
||||
content: `result_${i}`,
|
||||
role: MessageRole.TOOL,
|
||||
toolCallId: `call_${i}`
|
||||
})
|
||||
);
|
||||
const msg = makeMessage({ content: 'hello', toolCalls: toolCallsJson });
|
||||
// If the lookup were still O(n^2), this would be noticeably slow
|
||||
const start = Date.now();
|
||||
const sections = deriveAgenticSections(msg, toolMessages, [], false);
|
||||
const elapsed = Date.now() - start;
|
||||
const toolCallSections = sections.filter((s) => s.type === AgenticSectionType.TOOL_CALL);
|
||||
|
||||
expect(toolCallSections).toHaveLength(N);
|
||||
expect(elapsed).toBeLessThan(100); // Should be fast with O(1) lookup
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import { deriveAgenticSections } from '$lib/utils/agentic';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
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;
|
||||
}
|
||||
|
||||
// Mirrors the filter inside ChatService.convertDbMessageToApiChatMessageData:
|
||||
// a partial tool call captured mid-stream must not survive into the next request
|
||||
// payload. The fix in chatStore.savePartialResponseIfNeeded clears toolCalls to ''
|
||||
// on Stop/Send immediately, mirroring what the agentic flow already does in
|
||||
// onAssistantTurnComplete(...undefined).
|
||||
function buildApiToolCalls(message: DatabaseMessage): unknown[] | undefined {
|
||||
if (!message.toolCalls) return undefined;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message.toolCalls);
|
||||
|
||||
return Array.isArray(parsed) && parsed.length > 0 ? parsed : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
describe('partial tool call cleanup', () => {
|
||||
// Reproduces the broken payload from the user's screenshot: model was
|
||||
// streaming a tool call whose arguments JSON was cut mid-string. The outer
|
||||
// envelope still parses, but the arguments themselves are invalid JSON and
|
||||
// the server rejects the request.
|
||||
it('marks a partial tool call payload as unsafe to re-send', () => {
|
||||
const message = makeAssistant({
|
||||
content: 'partial reasoning',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
function: {
|
||||
arguments: '{"command":`grep -n \\"read_to\\" ` /Users',
|
||||
name: 'exec_shell_command'
|
||||
},
|
||||
id: 'call_1',
|
||||
type: 'function'
|
||||
}
|
||||
])
|
||||
});
|
||||
const apiToolCalls = buildApiToolCalls(message);
|
||||
|
||||
// The bug: even though arguments are invalid, the outer array parses and
|
||||
// the request gets sent. Function arguments must be parseable JSON on their
|
||||
// own for the server to execute the tool.
|
||||
expect(apiToolCalls).toBeDefined();
|
||||
const args = (apiToolCalls![0] as { function: { arguments: string } }).function.arguments;
|
||||
|
||||
expect(() => JSON.parse(args)).toThrow();
|
||||
});
|
||||
|
||||
// After Stop, savePartialResponseIfNeeded clears toolCalls and the agentic
|
||||
// flow does the same in its silent-return detection. The next request reads
|
||||
// toolCalls = '' and the conversion drops the field entirely so the server
|
||||
// never sees the half-streamed call.
|
||||
it('drops tool_calls from the API request after toolCalls is cleared', () => {
|
||||
const clearedMessage = makeAssistant({
|
||||
content: 'partial reasoning',
|
||||
toolCalls: ''
|
||||
});
|
||||
const apiToolCalls = buildApiToolCalls(clearedMessage);
|
||||
|
||||
expect(apiToolCalls).toBeUndefined();
|
||||
});
|
||||
|
||||
// The cleanup path keeps the partial reasoning content visible in the UI;
|
||||
// only the tool_calls field is reset. deriveAgenticSections should still
|
||||
// surface the reasoning as interrupted (no content / no tool calls behind
|
||||
// it) without resurrecting the dead tool call block.
|
||||
it('keeps reasoning content visible after cleanup, without a tool call block', () => {
|
||||
const cleared = makeAssistant({
|
||||
content: '',
|
||||
reasoningContent: 'thinking about read_to',
|
||||
toolCalls: ''
|
||||
});
|
||||
const sections = deriveAgenticSections(cleared);
|
||||
|
||||
expect(sections).toHaveLength(1);
|
||||
expect(sections[0].type).toBe('reasoning');
|
||||
expect(sections.some((s) => s.type.includes('tool_call'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const DIST_DIR = resolve(__dirname, '../../dist');
|
||||
const distExists = existsSync(DIST_DIR);
|
||||
|
||||
// PWA Build Output tests are integration tests that require a built dist/.
|
||||
// CI builds first then runs these tests; local devs should run `npm run build` or use `npm run test:pwa`.
|
||||
describe('PWA Build Output', () => {
|
||||
if (!distExists) {
|
||||
console.warn(`⚠ Skipping PWA Build Output tests - dist/ not found (run 'npm run build' first)`);
|
||||
it('skipped - dist/ not found', () => {});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const swContent = readFileSync(resolve(DIST_DIR, 'sw.js'), 'utf-8');
|
||||
const indexContent = readFileSync(resolve(DIST_DIR, 'index.html'), 'utf-8');
|
||||
|
||||
describe('Core files exist', () => {
|
||||
it('service worker (sw.js) exists', () => {
|
||||
expect(existsSync(resolve(DIST_DIR, 'sw.js')), 'sw.js not found').toBeTruthy();
|
||||
});
|
||||
|
||||
it('workbox library exists (hashed filename)', () => {
|
||||
// SvelteKit generates workbox-{hash}.js files
|
||||
const files = readdirSync(DIST_DIR).filter((f) => f.match(/^workbox-[^.]+\.js$/));
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('manifest.webmanifest exists', () => {
|
||||
expect(
|
||||
existsSync(resolve(DIST_DIR, 'manifest.webmanifest')),
|
||||
'manifest.webmanifest not found'
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it('SvelteKit bundle.js exists in _app/immutable/', () => {
|
||||
// SvelteKit generates hashed bundle names in _app/immutable/
|
||||
const appDir = resolve(DIST_DIR, '_app', 'immutable');
|
||||
|
||||
expect(existsSync(appDir), '_app/immutable/ not found').toBeTruthy();
|
||||
const files = readdirSync(appDir).filter((f) => f.startsWith('bundle.') && f.endsWith('.js'));
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('SvelteKit bundle.css exists in _app/immutable/assets/', () => {
|
||||
// SvelteKit generates hashed CSS bundles in _app/immutable/assets/
|
||||
const cssDir = resolve(DIST_DIR, '_app', 'immutable', 'assets');
|
||||
|
||||
expect(existsSync(cssDir), '_app/immutable/assets/ not found').toBeTruthy();
|
||||
const files = readdirSync(cssDir).filter(
|
||||
(f) => f.startsWith('bundle.') && f.endsWith('.css')
|
||||
);
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('version.json exists in _app/', () => {
|
||||
// SvelteKit stores version.json in _app directory
|
||||
expect(
|
||||
existsSync(resolve(DIST_DIR, '_app', 'version.json')),
|
||||
'_app/version.json not found'
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('version.json content', () => {
|
||||
it('has valid JSON with version field', () => {
|
||||
const content = readFileSync(resolve(DIST_DIR, '_app', 'version.json'), 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
expect(parsed).toHaveProperty('version');
|
||||
expect(typeof parsed.version).toBe('string');
|
||||
expect(parsed.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service worker content', () => {
|
||||
it('service worker has minified self.define format', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
// SvelteKit's workbox-plugin-sveltekit produces a minified SW with self.define
|
||||
expect(swContent).toMatch(/if\(!self.define\)/);
|
||||
});
|
||||
|
||||
it('references hashed workbox file (SvelteKit build output)', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
// SvelteKit's workbox-plugin-sveltekit references hashed workbox files
|
||||
expect(swContent).toMatch(/define\(\["\.\/workbox-[a-zA-Z0-9]+"\]/);
|
||||
});
|
||||
|
||||
it('precache contains SvelteKit bundle.js with content hash', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
// SvelteKit uses content-hashed bundle names in _app/immutable/
|
||||
expect(swContent).toMatch(/"_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"/);
|
||||
});
|
||||
|
||||
it('precache contains SvelteKit bundle.css with content hash', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
// SvelteKit uses content-hashed CSS bundle names in _app/immutable/assets/
|
||||
expect(swContent).toMatch(/"_app\/immutable\/assets\/bundle\.[a-zA-Z0-9_-]+\.css"/);
|
||||
});
|
||||
|
||||
it('precache contains _app/version.json', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
// SvelteKit stores version.json in _app directory
|
||||
expect(swContent).toMatch(/"_app\/version\.json"/);
|
||||
});
|
||||
|
||||
it('precache contains manifest.webmanifest', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
expect(swContent).toMatch(/"manifest\.webmanifest"/);
|
||||
});
|
||||
|
||||
it('no navigation route — API endpoints bypass PWA', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
// NavigationRoute is intentionally absent so direct browser
|
||||
// navigation to server API endpoints returns JSON, not HTML.
|
||||
expect(swContent).not.toMatch(/NavigationRoute/);
|
||||
});
|
||||
|
||||
it('has runtime caching for API routes', () => {
|
||||
expect(swContent).toBeTruthy();
|
||||
expect(swContent).toMatch(/api-cache/);
|
||||
expect(swContent).toMatch(/NetworkFirst/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('index.html content', () => {
|
||||
it('has modulepreload link for SvelteKit bundle with content hash', () => {
|
||||
expect(indexContent).toBeTruthy();
|
||||
// SvelteKit generates hashed bundle names in _app/immutable/
|
||||
expect(indexContent).toMatch(/href="(\.\/|\/)_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"/);
|
||||
});
|
||||
|
||||
it('has stylesheet link for SvelteKit bundle.css with content hash', () => {
|
||||
expect(indexContent).toBeTruthy();
|
||||
expect(indexContent).toMatch(
|
||||
/href="(\.\/|\/)_app\/immutable\/assets\/bundle\.[a-zA-Z0-9_-]+\.css"/
|
||||
);
|
||||
});
|
||||
|
||||
it('has dynamic import for SvelteKit bundle with content hash', () => {
|
||||
expect(indexContent).toBeTruthy();
|
||||
expect(indexContent).toMatch(
|
||||
/import\("(\.\/|\/)_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"\)/
|
||||
);
|
||||
});
|
||||
|
||||
it('has __sveltekit__ variable (SvelteKit adds hash suffix)', () => {
|
||||
expect(indexContent).toBeTruthy();
|
||||
// SvelteKit 2.x uses __sveltekit__ as base with random suffix
|
||||
expect(indexContent).toMatch(/__sveltekit_[a-zA-Z0-9-]+/);
|
||||
});
|
||||
|
||||
it('has PWA manifest link', () => {
|
||||
expect(indexContent).toBeTruthy();
|
||||
expect(indexContent).toMatch(/rel="manifest" href="(\.?\/)?manifest\.webmanifest"/);
|
||||
});
|
||||
|
||||
it('has apple-touch-icon link', () => {
|
||||
expect(indexContent).toBeTruthy();
|
||||
expect(indexContent).toMatch(/rel="apple-touch-icon"/);
|
||||
});
|
||||
|
||||
it('has _app paths for SvelteKit bundles', () => {
|
||||
expect(indexContent).toBeTruthy();
|
||||
// SvelteKit uses _app paths for hashed assets
|
||||
expect(indexContent).toMatch(/_app\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SvelteKit _app directory', () => {
|
||||
it('_app directory exists (SvelteKit uses it for hashed assets)', () => {
|
||||
expect(existsSync(resolve(DIST_DIR, '_app'))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Hashed workbox files', () => {
|
||||
it('workbox-*.js files exist in dist root (SvelteKit build output)', () => {
|
||||
const files = readdirSync(DIST_DIR).filter((f) => f.match(/^workbox-[^.]+\.js$/));
|
||||
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Static assets', () => {
|
||||
it('has favicon.ico', () => {
|
||||
expect(existsSync(resolve(DIST_DIR, 'favicon.ico'))).toBeTruthy();
|
||||
});
|
||||
|
||||
it('has PWA icons', () => {
|
||||
expect(existsSync(resolve(DIST_DIR, 'pwa-64x64.png'))).toBeTruthy();
|
||||
expect(existsSync(resolve(DIST_DIR, 'pwa-192x192.png'))).toBeTruthy();
|
||||
expect(existsSync(resolve(DIST_DIR, 'pwa-512x512.png'))).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { MessageRole } from '$lib/enums';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Tests for the new reasoning content handling.
|
||||
* In the new architecture, reasoning content is stored in a dedicated
|
||||
* `reasoningContent` field on DatabaseMessage, not embedded in content with tags.
|
||||
* The API sends it as `reasoning_content` on ApiChatMessageData.
|
||||
*/
|
||||
|
||||
describe('reasoning content in new structured format', () => {
|
||||
it('reasoning is stored as separate field, not in content', () => {
|
||||
// Simulate what the new chat store does
|
||||
const message = {
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
|
||||
};
|
||||
|
||||
// Content should be clean
|
||||
expect(message.content).not.toContain('<<<');
|
||||
expect(message.content).toBe('The answer is 4.');
|
||||
|
||||
// Reasoning in dedicated field
|
||||
expect(message.reasoningContent).toBe('Let me think: 2+2=4, basic arithmetic.');
|
||||
});
|
||||
|
||||
it('convertDbMessageToApiChatMessageData includes reasoning_content', () => {
|
||||
// Simulate the conversion logic
|
||||
const dbMessage = {
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.',
|
||||
role: MessageRole.ASSISTANT
|
||||
};
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
content: dbMessage.content,
|
||||
role: dbMessage.role
|
||||
};
|
||||
|
||||
if (dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
expect(apiMessage.content).toBe('The answer is 4.');
|
||||
expect(apiMessage.reasoning_content).toBe('Let me think: 2+2=4, basic arithmetic.');
|
||||
// No internal tags leak into either field
|
||||
expect(apiMessage.content).not.toContain('<<<');
|
||||
expect(apiMessage.reasoning_content).not.toContain('<<<');
|
||||
});
|
||||
|
||||
it('API message excludes reasoning when excludeReasoningFromContext is true', () => {
|
||||
const dbMessage = {
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'internal thinking',
|
||||
role: MessageRole.ASSISTANT
|
||||
};
|
||||
const excludeReasoningFromContext = true;
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
content: dbMessage.content,
|
||||
role: dbMessage.role
|
||||
};
|
||||
|
||||
if (!excludeReasoningFromContext && dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
expect(apiMessage.content).toBe('The answer is 4.');
|
||||
expect(apiMessage.reasoning_content).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles messages with no reasoning', () => {
|
||||
const dbMessage = {
|
||||
content: 'No reasoning here.',
|
||||
reasoningContent: undefined,
|
||||
role: MessageRole.ASSISTANT
|
||||
};
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
content: dbMessage.content,
|
||||
role: dbMessage.role
|
||||
};
|
||||
|
||||
if (dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
expect(apiMessage.content).toBe('No reasoning here.');
|
||||
expect(apiMessage.reasoning_content).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { redactValue } from '$lib/utils/redact';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('redactValue', () => {
|
||||
it('returns [redacted] by default', () => {
|
||||
expect(redactValue('secret-token')).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('shows last N characters when showLastChars is provided', () => {
|
||||
expect(redactValue('session-abc12', 5)).toBe('....abc12');
|
||||
});
|
||||
|
||||
it('handles value shorter than showLastChars', () => {
|
||||
expect(redactValue('ab', 5)).toBe('....ab');
|
||||
});
|
||||
|
||||
it('returns [redacted] when showLastChars is 0', () => {
|
||||
expect(redactValue('secret', 0)).toBe('[redacted]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
extractJsonRpcMethods,
|
||||
formatDiagnosticErrorMessage,
|
||||
getRequestBody,
|
||||
getRequestMethod,
|
||||
getRequestUrl,
|
||||
summarizeRequestBody
|
||||
} from '$lib/utils/request-helpers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('getRequestUrl', () => {
|
||||
it('returns a plain string input as-is', () => {
|
||||
expect(getRequestUrl('https://example.com/mcp')).toBe('https://example.com/mcp');
|
||||
});
|
||||
|
||||
it('returns href from a URL object', () => {
|
||||
expect(getRequestUrl(new URL('https://example.com/mcp'))).toBe('https://example.com/mcp');
|
||||
});
|
||||
|
||||
it('returns url from a Request object', () => {
|
||||
const req = new Request('https://example.com/mcp');
|
||||
|
||||
expect(getRequestUrl(req)).toBe('https://example.com/mcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestMethod', () => {
|
||||
it('prefers method from init', () => {
|
||||
expect(getRequestMethod('https://example.com', { method: 'POST' })).toBe('POST');
|
||||
});
|
||||
|
||||
it('falls back to Request.method', () => {
|
||||
const req = new Request('https://example.com', { method: 'PUT' });
|
||||
|
||||
expect(getRequestMethod(req)).toBe('PUT');
|
||||
});
|
||||
|
||||
it('falls back to baseInit.method', () => {
|
||||
expect(getRequestMethod('https://example.com', undefined, { method: 'DELETE' })).toBe('DELETE');
|
||||
});
|
||||
|
||||
it('defaults to GET', () => {
|
||||
expect(getRequestMethod('https://example.com')).toBe('GET');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRequestBody', () => {
|
||||
it('returns body from init', () => {
|
||||
expect(getRequestBody('https://example.com', { body: 'payload' })).toBe('payload');
|
||||
});
|
||||
|
||||
it('returns undefined when no body is present', () => {
|
||||
expect(getRequestBody('https://example.com')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeRequestBody', () => {
|
||||
it('returns empty for null', () => {
|
||||
expect(summarizeRequestBody(null)).toEqual({ kind: 'empty' });
|
||||
});
|
||||
|
||||
it('returns empty for undefined', () => {
|
||||
expect(summarizeRequestBody(undefined)).toEqual({ kind: 'empty' });
|
||||
});
|
||||
|
||||
it('returns string kind with size', () => {
|
||||
expect(summarizeRequestBody('hello')).toEqual({ kind: 'string', size: 5 });
|
||||
});
|
||||
|
||||
it('returns blob kind with size', () => {
|
||||
const blob = new Blob(['abc']);
|
||||
|
||||
expect(summarizeRequestBody(blob)).toEqual({ kind: 'blob', size: 3 });
|
||||
});
|
||||
|
||||
it('returns formdata kind', () => {
|
||||
expect(summarizeRequestBody(new FormData())).toEqual({ kind: 'formdata' });
|
||||
});
|
||||
|
||||
it('returns arraybuffer kind with size', () => {
|
||||
expect(summarizeRequestBody(new ArrayBuffer(8))).toEqual({ kind: 'arraybuffer', size: 8 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatDiagnosticErrorMessage', () => {
|
||||
it('appends CORS hint for Failed to fetch', () => {
|
||||
expect(formatDiagnosticErrorMessage(new TypeError('Failed to fetch'))).toBe(
|
||||
'Failed to fetch (check CORS?)'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes through other error messages unchanged', () => {
|
||||
expect(formatDiagnosticErrorMessage(new Error('timeout'))).toBe('timeout');
|
||||
});
|
||||
|
||||
it('handles non-Error values', () => {
|
||||
expect(formatDiagnosticErrorMessage('some string')).toBe('some string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractJsonRpcMethods', () => {
|
||||
it('extracts methods from a JSON-RPC array', () => {
|
||||
const body = JSON.stringify([
|
||||
{ id: 1, jsonrpc: '2.0', method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
]);
|
||||
|
||||
expect(extractJsonRpcMethods(body)).toEqual(['initialize', 'notifications/initialized']);
|
||||
});
|
||||
|
||||
it('extracts method from a single JSON-RPC message', () => {
|
||||
const body = JSON.stringify({ id: 1, jsonrpc: '2.0', method: 'tools/list' });
|
||||
|
||||
expect(extractJsonRpcMethods(body)).toEqual(['tools/list']);
|
||||
});
|
||||
|
||||
it('returns undefined for non-string body', () => {
|
||||
expect(extractJsonRpcMethods(null)).toBeUndefined();
|
||||
expect(extractJsonRpcMethods(undefined)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined for invalid JSON', () => {
|
||||
expect(extractJsonRpcMethods('not json')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns undefined when no methods found', () => {
|
||||
expect(extractJsonRpcMethods(JSON.stringify({ foo: 'bar' }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CORS_PROXY } from '$lib/constants';
|
||||
import { sanitizeHeaders } from '$lib/utils/api-headers';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('sanitizeHeaders', () => {
|
||||
it('returns empty object for undefined input', () => {
|
||||
expect(sanitizeHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it('passes through non-sensitive headers', () => {
|
||||
const headers = new Headers({ accept: 'text/html', 'content-type': 'application/json' });
|
||||
|
||||
expect(sanitizeHeaders(headers)).toEqual({
|
||||
accept: 'text/html',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts known sensitive headers', () => {
|
||||
const headers = new Headers({
|
||||
authorization: 'Bearer secret',
|
||||
'content-type': 'application/json',
|
||||
'x-api-key': 'key-123'
|
||||
});
|
||||
const result = sanitizeHeaders(headers);
|
||||
|
||||
expect(result.authorization).toBe('[redacted]');
|
||||
expect(result['x-api-key']).toBe('[redacted]');
|
||||
expect(result['content-type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('partially redacts headers specified in partialRedactHeaders', () => {
|
||||
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
|
||||
const partial = new Map([['mcp-session-id', 5]]);
|
||||
|
||||
expect(sanitizeHeaders(headers, undefined, partial)['mcp-session-id']).toBe('....12345');
|
||||
});
|
||||
|
||||
it('fully redacts mcp-session-id when no partialRedactHeaders is given', () => {
|
||||
const headers = new Headers({ 'mcp-session-id': 'session-12345' });
|
||||
|
||||
expect(sanitizeHeaders(headers)['mcp-session-id']).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('redacts extra headers provided by the caller', () => {
|
||||
const headers = new Headers({
|
||||
'content-type': 'application/json',
|
||||
'x-vendor-key': 'vendor-secret'
|
||||
});
|
||||
const result = sanitizeHeaders(headers, ['x-vendor-key']);
|
||||
|
||||
expect(result['x-vendor-key']).toBe('[redacted]');
|
||||
expect(result['content-type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('handles case-insensitive extra header names', () => {
|
||||
const headers = new Headers({ 'X-Custom-Token': 'token-value' });
|
||||
const result = sanitizeHeaders(headers, ['X-CUSTOM-TOKEN']);
|
||||
|
||||
expect(result['x-custom-token']).toBe('[redacted]');
|
||||
});
|
||||
|
||||
it('redacts proxied sensitive and custom target headers', () => {
|
||||
const proxiedAuthorization = `${CORS_PROXY.HEADER_PREFIX}authorization`;
|
||||
const proxiedSessionId = `${CORS_PROXY.HEADER_PREFIX}mcp-session-id`;
|
||||
const proxiedVendorKey = `${CORS_PROXY.HEADER_PREFIX}x-vendor-key`;
|
||||
const headers = new Headers({
|
||||
[proxiedAuthorization]: 'Bearer secret',
|
||||
[proxiedSessionId]: 'session-12345',
|
||||
[proxiedVendorKey]: 'vendor-secret'
|
||||
});
|
||||
const partial = new Map([['mcp-session-id', 5]]);
|
||||
const result = sanitizeHeaders(headers, ['x-vendor-key'], partial);
|
||||
|
||||
expect(result[proxiedAuthorization]).toBe('[redacted]');
|
||||
expect(result[proxiedSessionId]).toBe('....12345');
|
||||
expect(result[proxiedVendorKey]).toBe('[redacted]');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { extractSearchQuery, extractSearchResults } from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const SAMPLE = `Title: World Cup 2026 | Match schedule, fixtures, results & stadiums
|
||||
URL: https://www.fifa.com/en/tournaments/mens/worldcup/canadamexicousa2026/articles/match-schedule-fixtures-results-teams-stadiums
|
||||
Published: 2026-06-22T00:01:00.000Z
|
||||
Author: N/A
|
||||
Highlights:
|
||||
Find out the full match schedule for World Cup 2026 in Canada, Mexico and USA with fixtures and results from each of the 104 games in the ...
|
||||
---
|
||||
Title: 2026 FIFA World Cup match schedule: Fixtures, results, features - ESPN
|
||||
URL: https://www.espn.com/soccer/story/_/id/48939282/2026-fifa-world-cup-fixtures-results-match-schedule-group-stage-knockout-rounds-bracket
|
||||
Published: 2026-07-08T07:07:00.000Z
|
||||
Author: ESPN
|
||||
Highlights:
|
||||
Round of 32 · Tuesday, July 7 · Argentina 3-2 Egypt (Atlanta) Switzerland (4) 0-0 (3) Colombia (Vancouver, Canada) · Monday, July 6 · Portugal 0-1 ...
|
||||
---
|
||||
Title: BBC
|
||||
URL: https://www.bbc.co.uk/sport/football/world-cup/schedule
|
||||
Published: N/A
|
||||
Author: N/A
|
||||
Highlights:
|
||||
Something
|
||||
# World Cup
|
||||
...`;
|
||||
const QUERY_ARGS = '{"query":"FIFA World Cup 2026 schedule"}';
|
||||
|
||||
describe('real-world Exa fixture', () => {
|
||||
it('extracts every search result and preserves rich highlights', () => {
|
||||
const results = extractSearchResults(SAMPLE);
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].title).toContain('World Cup 2026 | Match schedule');
|
||||
expect(results[0].url).toContain('fifa.com');
|
||||
expect(results[1].title).toContain('2026 FIFA World Cup match schedule');
|
||||
expect(results[1].author).toBe('ESPN');
|
||||
expect(results[1].highlights).toContain('Round of 32');
|
||||
expect(results[2].title).toBe('BBC');
|
||||
});
|
||||
|
||||
it('parses the query out of the tool-args JSON', () => {
|
||||
expect(extractSearchQuery(QUERY_ARGS)).toBe('FIFA World Cup 2026 schedule');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
import {
|
||||
extractSearchQuery,
|
||||
extractSearchResults,
|
||||
faviconForUrl,
|
||||
isWebSearchToolName,
|
||||
looksLikeSearchResult
|
||||
} from '$lib/utils/search-results';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('extractSearchResults', () => {
|
||||
it('parses the Exa fixture with multiple results', () => {
|
||||
const fixture = `Title: World Cup 2026 | Match schedule, fixtures
|
||||
URL: https://www.fifa.com/articles/match-schedule
|
||||
Published: 2026-06-22T00:01:00.000Z
|
||||
Author: N/A
|
||||
Highlights:
|
||||
Find out the full match schedule for World Cup 2026
|
||||
---
|
||||
Title: 2026 FIFA World Cup match schedule
|
||||
URL: https://www.espn.com/soccer/story/abc/def
|
||||
Published: 2026-07-08T07:07:00.000Z
|
||||
Author: ESPN
|
||||
Highlights:
|
||||
Round of 32 · Tuesday, July 7
|
||||
---
|
||||
Title: BBC
|
||||
URL: https://www.bbc.co.uk/sport/football/world-cup/schedule
|
||||
Published: N/A
|
||||
Author: N/A
|
||||
Highlights:
|
||||
# FIFA World Cup Schedule
|
||||
...`;
|
||||
const results = extractSearchResults(fixture);
|
||||
|
||||
expect(results.length).toBe(3);
|
||||
expect(results[0].title).toContain('World Cup 2026');
|
||||
expect(results[0].url).toBe('https://www.fifa.com/articles/match-schedule');
|
||||
expect(results[0].published).toBe('2026-06-22T00:01:00.000Z');
|
||||
// N/A filtered out
|
||||
expect(results[0].author).toBeUndefined();
|
||||
expect(results[0].highlights).toContain('match schedule');
|
||||
expect(results[1].author).toBe('ESPN');
|
||||
expect(results[2].author).toBeUndefined(); // N/A filtered
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(extractSearchResults('')).toEqual([]);
|
||||
expect(extractSearchResults(undefined)).toEqual([]);
|
||||
expect(extractSearchResults(null)).toEqual([]);
|
||||
});
|
||||
|
||||
it('skips chunks missing title or url', () => {
|
||||
const txt = `Title: no url here
|
||||
Highlights:
|
||||
foo
|
||||
---
|
||||
Title: foo
|
||||
URL: https://x.com
|
||||
---
|
||||
just a paragraph
|
||||
---
|
||||
Title: b
|
||||
URL: not a url`;
|
||||
const results = extractSearchResults(txt);
|
||||
|
||||
// Only middle one should pass (has title + url).
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].url).toBe('https://x.com');
|
||||
});
|
||||
|
||||
it('parses a single result without separators', () => {
|
||||
const txt = `Title: only one
|
||||
URL: https://example.com/test
|
||||
Published: 2026-01-01T00:00:00Z
|
||||
Author: alice
|
||||
Highlights:
|
||||
a highlight`;
|
||||
const results = extractSearchResults(txt);
|
||||
|
||||
expect(results.length).toBe(1);
|
||||
expect(results[0].title).toBe('only one');
|
||||
expect(results[0].author).toBe('alice');
|
||||
expect(results[0].highlights).toBe('a highlight');
|
||||
});
|
||||
|
||||
it('extracts query from JSON toolArgs', () => {
|
||||
expect(extractSearchQuery('{"query":"foo"}')).toBe('foo');
|
||||
expect(extractSearchQuery(' {"query":" foo "} ')).toBe('foo');
|
||||
expect(extractSearchQuery('not json')).toBe('');
|
||||
expect(extractSearchQuery(null)).toBe('');
|
||||
expect(extractSearchQuery('{"query":123}')).toBe('');
|
||||
});
|
||||
|
||||
it('resolves favicon URLs from origins', () => {
|
||||
expect(faviconForUrl('https://example.com/path/to/page')).toBe(
|
||||
'https://example.com/favicon.ico'
|
||||
);
|
||||
expect(faviconForUrl('http://example.com/x')).toBe('http://example.com/favicon.ico');
|
||||
expect(faviconForUrl('not a url')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isWebSearchToolName', () => {
|
||||
it('excludes tools that take the same query argument but are not web searches', () => {
|
||||
expect(isWebSearchToolName('search_pull_requests')).toBe(false);
|
||||
expect(isWebSearchToolName('search_code')).toBe(false);
|
||||
expect(isWebSearchToolName('search_repositories')).toBe(false);
|
||||
expect(isWebSearchToolName('search_issues')).toBe(false);
|
||||
});
|
||||
|
||||
it('handles empty / missing input', () => {
|
||||
expect(isWebSearchToolName(null)).toBe(false);
|
||||
expect(isWebSearchToolName(undefined)).toBe(false);
|
||||
expect(isWebSearchToolName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for unrelated tools', () => {
|
||||
expect(isWebSearchToolName('web_fetch')).toBe(false);
|
||||
expect(isWebSearchToolName('read_file')).toBe(false);
|
||||
expect(isWebSearchToolName('exec_shell_command')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractSearchResults prefilter', () => {
|
||||
it('returns the shared empty array for blobs without the wire format', () => {
|
||||
// exec/file tool results never carry Title:/URL: field lines; the
|
||||
// cheap prefilter must skip the line-split parse for them
|
||||
const stdout = `${'make[1]: entering directory\n'.repeat(5000)}`;
|
||||
|
||||
expect(extractSearchResults(stdout)).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns an empty result when only one required field is present', () => {
|
||||
expect(extractSearchResults('URL: https://example.com')).toEqual([]);
|
||||
expect(extractSearchResults('Title: only a title')).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('looksLikeSearchResult', () => {
|
||||
it('requires both Title and URL field markers', () => {
|
||||
expect(looksLikeSearchResult('Title: a\nURL: https://b')).toBe(true);
|
||||
expect(looksLikeSearchResult('URL: https://b')).toBe(false);
|
||||
expect(looksLikeSearchResult('plain stdout')).toBe(false);
|
||||
expect(looksLikeSearchResult(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { SETTINGS_CHAT_SECTIONS, SETTINGS_KEYS } from '$lib/constants';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('checkApiKeyField', () => {
|
||||
it('should have isPrivate set to true', () => {
|
||||
const fields = SETTINGS_CHAT_SECTIONS.flatMap((section) => section.fields);
|
||||
const apiKeyField = fields.find((field) => field?.key === SETTINGS_KEYS.API_KEY);
|
||||
|
||||
expect(apiKeyField).toBeDefined();
|
||||
expect(apiKeyField?.isPrivate).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
// Guards the Settings > Display wiring of "Show tool call in progress": the
|
||||
// flat config default and its presence as a checkbox in the Display section
|
||||
// come from the settings registry, so the key/label/type/section pairing is the
|
||||
// contract the dialog renders.
|
||||
|
||||
import {
|
||||
SETTING_CONFIG_DEFAULT,
|
||||
SETTINGS_CHAT_SECTIONS,
|
||||
SETTINGS_KEYS,
|
||||
SETTINGS_SECTION_SLUGS
|
||||
} from '$lib/constants';
|
||||
import { SettingsFieldType } from '$lib/enums';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function displayField(key: string) {
|
||||
const section = SETTINGS_CHAT_SECTIONS.find((s) => s.slug === SETTINGS_SECTION_SLUGS.DISPLAY);
|
||||
|
||||
return section?.fields?.find((field) => field?.key === key);
|
||||
}
|
||||
|
||||
describe('showToolCallInProgress setting', () => {
|
||||
it('is a checkbox in the Display section', () => {
|
||||
const field = displayField(SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS);
|
||||
|
||||
expect(field).toBeDefined();
|
||||
expect(field?.type).toBe(SettingsFieldType.CHECKBOX);
|
||||
expect(field?.label).toBe('Show tool call in progress');
|
||||
});
|
||||
|
||||
it('ships enabled, like "Show thought in progress"', () => {
|
||||
expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS]).toBe(true);
|
||||
expect(SETTING_CONFIG_DEFAULT[SETTINGS_KEYS.SHOW_THOUGHT_IN_PROGRESS]).toBe(true);
|
||||
});
|
||||
|
||||
it('is listed next to its "always show" counterpart', () => {
|
||||
const section = SETTINGS_CHAT_SECTIONS.find(
|
||||
(s) => s.slug === SETTINGS_SECTION_SLUGS.DISPLAY
|
||||
) as (typeof SETTINGS_CHAT_SECTIONS)[number];
|
||||
const keys = section.fields?.map((field) => field?.key) ?? [];
|
||||
const inProgressIndex = keys.indexOf(SETTINGS_KEYS.SHOW_TOOL_CALL_IN_PROGRESS);
|
||||
|
||||
expect(inProgressIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(keys[inProgressIndex + 1]).toBe(SETTINGS_KEYS.ALWAYS_SHOW_TOOL_CALL_CONTENT);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { SourceHistory } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('SourceHistory', () => {
|
||||
it('coalesces pushes inside the group window into one undo step', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.push({ caret: 1, value: 'a' }, 1200);
|
||||
h.push({ caret: 2, value: 'ab' }, 1500);
|
||||
|
||||
expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
|
||||
expect(h.undo({ caret: 0, value: '' })).toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new group once the window has passed', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.push({ caret: 3, value: 'abc' }, 2000);
|
||||
|
||||
expect(h.undo({ caret: 6, value: 'abcdef' })).toEqual({ caret: 3, value: 'abc' });
|
||||
expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
|
||||
});
|
||||
|
||||
it('newGroup forces a separate entry even inside the window', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.push({ caret: 3, value: 'abc' }, 1100, true);
|
||||
|
||||
expect(h.undo({ caret: 4, value: 'abc\n' })).toEqual({ caret: 3, value: 'abc' });
|
||||
expect(h.undo({ caret: 3, value: 'abc' })).toEqual({ caret: 0, value: '' });
|
||||
});
|
||||
|
||||
it('redo round-trips and a fresh push clears the redo stack', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
|
||||
const undone = h.undo({ caret: 3, value: 'abc' });
|
||||
|
||||
expect(undone).toEqual({ caret: 0, value: '' });
|
||||
expect(h.redo({ caret: 0, value: '' })).toEqual({ caret: 3, value: 'abc' });
|
||||
|
||||
h.undo({ caret: 3, value: 'abc' });
|
||||
h.push({ caret: 0, value: '' }, 5000);
|
||||
expect(h.redo({ caret: 1, value: 'x' })).toBeNull();
|
||||
});
|
||||
|
||||
it('starts a new group on the first edit after an undo', () => {
|
||||
const h = new SourceHistory(100, 800);
|
||||
|
||||
h.push({ caret: 0, value: '' }, 1000);
|
||||
h.undo({ caret: 3, value: 'abc' });
|
||||
|
||||
h.push({ caret: 0, value: '' }, 1200);
|
||||
expect(h.undo({ caret: 1, value: 'x' })).toEqual({ caret: 0, value: '' });
|
||||
});
|
||||
|
||||
it('evicts the oldest entry past the limit', () => {
|
||||
const h = new SourceHistory(2, 800);
|
||||
|
||||
h.push({ caret: 0, value: 'one' }, 1000);
|
||||
h.push({ caret: 0, value: 'two' }, 2000);
|
||||
h.push({ caret: 0, value: 'three' }, 3000);
|
||||
|
||||
expect(h.undo({ caret: 0, value: 'cur' })).toEqual({ caret: 0, value: 'three' });
|
||||
expect(h.undo({ caret: 0, value: 'three' })).toEqual({ caret: 0, value: 'two' });
|
||||
expect(h.undo({ caret: 0, value: 'two' })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { parseSseJsonStream } from '$lib/utils/sse';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSseResponse(events: string[]): Response {
|
||||
const body = events.join('\n\n') + '\n\n';
|
||||
|
||||
return new Response(body, {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
status: 200
|
||||
});
|
||||
}
|
||||
|
||||
describe('parseSseJsonStream', () => {
|
||||
it('yields parsed data for each record', async () => {
|
||||
const response = makeSseResponse(['data: {"chunk": "a"}', 'data: {"chunk": "b"}']);
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
expect(collected).toEqual([{ chunk: 'a' }, { chunk: 'b' }]);
|
||||
});
|
||||
|
||||
it('stops on [DONE] sentinel', async () => {
|
||||
const response = makeSseResponse([
|
||||
'data: {"chunk": "a"}',
|
||||
'data: [DONE]',
|
||||
'data: {"chunk": "after-done"}'
|
||||
]);
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
expect(collected).toEqual([{ chunk: 'a' }]);
|
||||
});
|
||||
|
||||
it('skips malformed JSON records', async () => {
|
||||
const response = makeSseResponse([
|
||||
'data: {"chunk": "ok"}',
|
||||
'data: {not-json}',
|
||||
'data: {"chunk": "also-ok"}'
|
||||
]);
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
expect(collected).toEqual([{ chunk: 'ok' }, { chunk: 'also-ok' }]);
|
||||
});
|
||||
|
||||
it('handles records split across multiple chunks (partial last line)', async () => {
|
||||
const full = 'data: {"chunk": "x"}\n\ndata: {"chunk": "y"}\n\n';
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const enc = new TextEncoder();
|
||||
|
||||
controller.enqueue(enc.encode(full.slice(0, full.length / 2)));
|
||||
controller.enqueue(enc.encode(full.slice(full.length / 2)));
|
||||
controller.close();
|
||||
}
|
||||
});
|
||||
const response = new Response(stream, {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
status: 200
|
||||
});
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
expect(collected).toEqual([{ chunk: 'x' }, { chunk: 'y' }]);
|
||||
});
|
||||
|
||||
it('returns immediately if response has no body', async () => {
|
||||
const response = new Response(null, { status: 200 });
|
||||
const collected: unknown[] = [];
|
||||
|
||||
for await (const ev of parseSseJsonStream(response)) {
|
||||
collected.push(ev.data);
|
||||
}
|
||||
expect(collected).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import { ChatService } from '$lib/services/chat.service';
|
||||
import type { ApiStreamSession } from '$lib/types';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSession(overrides: Partial<ApiStreamSession>): ApiStreamSession {
|
||||
return {
|
||||
completed_at: 0,
|
||||
conversation_id: 'conv',
|
||||
is_done: true,
|
||||
started_at: 0,
|
||||
total_bytes: 0,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('selectActiveStream', () => {
|
||||
it('returns null on empty input', () => {
|
||||
expect(ChatService.selectActiveStream([])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on null or undefined input', () => {
|
||||
expect(ChatService.selectActiveStream(null)).toBeNull();
|
||||
expect(ChatService.selectActiveStream(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the single session when it is running', () => {
|
||||
const s = makeSession({ conversation_id: 'only', is_done: false, started_at: 42 });
|
||||
|
||||
expect(ChatService.selectActiveStream([s])).toBe(s);
|
||||
});
|
||||
|
||||
it('returns null when the single session is finalized', () => {
|
||||
const s = makeSession({ conversation_id: 'only', is_done: true, started_at: 42 });
|
||||
|
||||
expect(ChatService.selectActiveStream([s])).toBeNull();
|
||||
});
|
||||
|
||||
it('prefers a still running session over a finalized one regardless of started_at', () => {
|
||||
const finalized = makeSession({ conversation_id: 'old', is_done: true, started_at: 1000 });
|
||||
const running = makeSession({ conversation_id: 'new', is_done: false, started_at: 10 });
|
||||
|
||||
expect(ChatService.selectActiveStream([finalized, running])?.conversation_id).toBe('new');
|
||||
expect(ChatService.selectActiveStream([running, finalized])?.conversation_id).toBe('new');
|
||||
});
|
||||
|
||||
it('among running sessions, picks the most recently started one', () => {
|
||||
const a = makeSession({ conversation_id: 'a', is_done: false, started_at: 100 });
|
||||
const b = makeSession({ conversation_id: 'b', is_done: false, started_at: 200 });
|
||||
const c = makeSession({ conversation_id: 'c', is_done: false, started_at: 150 });
|
||||
|
||||
expect(ChatService.selectActiveStream([a, b, c])?.conversation_id).toBe('b');
|
||||
expect(ChatService.selectActiveStream([c, a, b])?.conversation_id).toBe('b');
|
||||
});
|
||||
|
||||
it('returns null when all sessions are finalized, the DB already holds the content', () => {
|
||||
const a = makeSession({ conversation_id: 'a', is_done: true, started_at: 10 });
|
||||
const b = makeSession({ conversation_id: 'b', is_done: true, started_at: 30 });
|
||||
const c = makeSession({ conversation_id: 'c', is_done: true, started_at: 20 });
|
||||
|
||||
expect(ChatService.selectActiveStream([a, b, c])).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps the first match on ties when both are running with identical started_at', () => {
|
||||
// reduce visits left to right, the initial accumulator stays unless a strictly greater value appears
|
||||
const a = makeSession({ conversation_id: 'first', is_done: false, started_at: 50 });
|
||||
const b = makeSession({ conversation_id: 'second', is_done: false, started_at: 50 });
|
||||
|
||||
expect(ChatService.selectActiveStream([a, b])?.conversation_id).toBe('first');
|
||||
});
|
||||
|
||||
it('handles a typical realistic mix: two finalized old, one freshly running, one freshly finalized', () => {
|
||||
const old1 = makeSession({ conversation_id: 'old1', is_done: true, started_at: 100 });
|
||||
const old2 = makeSession({ conversation_id: 'old2', is_done: true, started_at: 200 });
|
||||
const freshFin = makeSession({ conversation_id: 'freshFin', is_done: true, started_at: 500 });
|
||||
const running = makeSession({ conversation_id: 'running', is_done: false, started_at: 400 });
|
||||
|
||||
expect(ChatService.selectActiveStream([old1, old2, freshFin, running])?.conversation_id).toBe(
|
||||
'running'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// node env unit project has no DOM, install a minimal localStorage backed by a Map
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
setItem: (k, v) => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
});
|
||||
|
||||
import { STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX } from '$lib/constants';
|
||||
import { ChatService } from '$lib/services/chat.service';
|
||||
|
||||
describe('ChatService stream resume', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('returns null when no state exists for the conversation', () => {
|
||||
expect(ChatService.getStreamState('conv-a')).toBeNull();
|
||||
});
|
||||
|
||||
it('saves and reads back the byte count', () => {
|
||||
ChatService.saveStreamState('conv-a', 4242);
|
||||
const got = ChatService.getStreamState('conv-a');
|
||||
|
||||
expect(got).not.toBeNull();
|
||||
expect(got!.bytesReceived).toBe(4242);
|
||||
expect(typeof got!.updatedAt).toBe('number');
|
||||
});
|
||||
|
||||
it('overwrites the previous byte count on a new save for the same conversation', () => {
|
||||
ChatService.saveStreamState('conv-a', 100);
|
||||
ChatService.saveStreamState('conv-a', 200);
|
||||
const got = ChatService.getStreamState('conv-a');
|
||||
|
||||
expect(got!.bytesReceived).toBe(200);
|
||||
});
|
||||
|
||||
it('keeps states for distinct conversations isolated', () => {
|
||||
ChatService.saveStreamState('conv-a', 10);
|
||||
ChatService.saveStreamState('conv-b', 20);
|
||||
expect(ChatService.getStreamState('conv-a')!.bytesReceived).toBe(10);
|
||||
expect(ChatService.getStreamState('conv-b')!.bytesReceived).toBe(20);
|
||||
});
|
||||
|
||||
it('clears the state for a given conversation', () => {
|
||||
ChatService.saveStreamState('conv-a', 10);
|
||||
ChatService.clearStreamState('conv-a');
|
||||
expect(ChatService.getStreamState('conv-a')).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores empty conversation id on save', () => {
|
||||
ChatService.saveStreamState('', 1);
|
||||
expect(ChatService.getStreamState('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null on corrupted storage payload', () => {
|
||||
localStorage.setItem(`${STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX}conv-a`, '{not-json');
|
||||
expect(ChatService.getStreamState('conv-a')).toBeNull();
|
||||
});
|
||||
|
||||
it('persists the model alongside the byte count', () => {
|
||||
ChatService.saveStreamState('conv-a', 10, 'model-x');
|
||||
expect(ChatService.getStreamState('conv-a')!.model).toBe('model-x');
|
||||
});
|
||||
|
||||
it('stores a null model when none is provided', () => {
|
||||
ChatService.saveStreamState('conv-a', 10);
|
||||
expect(ChatService.getStreamState('conv-a')!.model).toBeNull();
|
||||
});
|
||||
|
||||
it('overwrites the model on a new save for the same conversation', () => {
|
||||
ChatService.saveStreamState('conv-a', 10, 'model-x');
|
||||
ChatService.saveStreamState('conv-a', 20, 'model-y');
|
||||
expect(ChatService.getStreamState('conv-a')!.model).toBe('model-y');
|
||||
});
|
||||
|
||||
describe('throttled saves (per-chunk path)', () => {
|
||||
// unique conversation ids: the throttle tracker is module state and
|
||||
// outlives beforeEach's localStorage.clear()
|
||||
let counter = 0;
|
||||
|
||||
const freshConv = () => `conv-throttle-${++counter}`;
|
||||
|
||||
it('writes immediately when no write was recorded for the conversation', () => {
|
||||
const conv = freshConv();
|
||||
|
||||
ChatService.saveStreamStateThrottled(conv, 100);
|
||||
expect(ChatService.getStreamState(conv)!.bytesReceived).toBe(100);
|
||||
});
|
||||
|
||||
it('holds a save pending when it lands inside the interval, flush forces it out', () => {
|
||||
const conv = freshConv();
|
||||
|
||||
ChatService.saveStreamStateThrottled(conv, 100);
|
||||
ChatService.saveStreamStateThrottled(conv, 200);
|
||||
expect(ChatService.getStreamState(conv)!.bytesReceived).toBe(100);
|
||||
|
||||
ChatService.flushStreamState(conv);
|
||||
expect(ChatService.getStreamState(conv)!.bytesReceived).toBe(200);
|
||||
});
|
||||
|
||||
it('flush is a no-op when nothing is pending', () => {
|
||||
const conv = freshConv();
|
||||
|
||||
ChatService.saveStreamStateThrottled(conv, 100);
|
||||
ChatService.flushStreamState(conv);
|
||||
ChatService.flushStreamState(conv);
|
||||
expect(ChatService.getStreamState(conv)!.bytesReceived).toBe(100);
|
||||
});
|
||||
|
||||
it('an immediate save resets the throttle window', () => {
|
||||
const conv = freshConv();
|
||||
|
||||
ChatService.saveStreamStateThrottled(conv, 100);
|
||||
ChatService.saveStreamState(conv, 150);
|
||||
expect(ChatService.getStreamState(conv)!.bytesReceived).toBe(150);
|
||||
|
||||
ChatService.saveStreamStateThrottled(conv, 200);
|
||||
expect(ChatService.getStreamState(conv)!.bytesReceived).toBe(150);
|
||||
|
||||
ChatService.flushStreamState(conv);
|
||||
expect(ChatService.getStreamState(conv)!.bytesReceived).toBe(200);
|
||||
});
|
||||
|
||||
it('clearStreamState drops the pending throttled state', () => {
|
||||
const conv = freshConv();
|
||||
|
||||
ChatService.saveStreamStateThrottled(conv, 100);
|
||||
ChatService.saveStreamStateThrottled(conv, 200);
|
||||
ChatService.clearStreamState(conv);
|
||||
expect(ChatService.getStreamState(conv)).toBeNull();
|
||||
|
||||
ChatService.flushStreamState(conv);
|
||||
expect(ChatService.getStreamState(conv)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resumeStreamIdentity', () => {
|
||||
it('appends the persisted model so the resume key matches the frozen POST identity', () => {
|
||||
ChatService.saveStreamState('conv-a', 10, 'model-x');
|
||||
expect(
|
||||
ChatService.resumeStreamIdentity('conv-a', ChatService.getStreamState('conv-a'), 'dropdown')
|
||||
).toBe('conv-a::model-x');
|
||||
});
|
||||
|
||||
it('keeps the bare conv id when the persisted model is null', () => {
|
||||
ChatService.saveStreamState('conv-a', 10);
|
||||
expect(
|
||||
ChatService.resumeStreamIdentity('conv-a', ChatService.getStreamState('conv-a'), 'dropdown')
|
||||
).toBe('conv-a');
|
||||
});
|
||||
|
||||
it('falls back to the current model only when no state is persisted', () => {
|
||||
expect(ChatService.resumeStreamIdentity('conv-a', null, 'dropdown')).toBe('conv-a::dropdown');
|
||||
});
|
||||
|
||||
it('ignores the fallback when a state exists, the persisted value is authoritative', () => {
|
||||
ChatService.saveStreamState('conv-a', 10, 'model-x');
|
||||
expect(
|
||||
ChatService.resumeStreamIdentity('conv-a', ChatService.getStreamState('conv-a'), 'dropdown')
|
||||
).toBe('conv-a::model-x');
|
||||
});
|
||||
|
||||
it('falls back when a legacy state has no model field', () => {
|
||||
localStorage.setItem(
|
||||
`${STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX}conv-a`,
|
||||
JSON.stringify({ bytesReceived: 10, updatedAt: 1 })
|
||||
);
|
||||
expect(
|
||||
ChatService.resumeStreamIdentity('conv-a', ChatService.getStreamState('conv-a'), 'dropdown')
|
||||
).toBe('conv-a::dropdown');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { tryParseToolResultObject } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('tryParseToolResultObject', () => {
|
||||
it('returns null when no result is provided', () => {
|
||||
expect(tryParseToolResultObject(undefined)).toBeNull();
|
||||
expect(tryParseToolResultObject('')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the parsed object when the result is JSON', () => {
|
||||
expect(tryParseToolResultObject('{"result":"ok","bytes":42}')).toEqual({
|
||||
bytes: 42,
|
||||
result: 'ok'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for JSON arrays (only objects are useful to callers)', () => {
|
||||
expect(tryParseToolResultObject('[1,2,3]')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for JSON primitives', () => {
|
||||
expect(tryParseToolResultObject('"raw string"')).toBeNull();
|
||||
expect(tryParseToolResultObject('42')).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for invalid JSON', () => {
|
||||
expect(tryParseToolResultObject('not json')).toBeNull();
|
||||
expect(tryParseToolResultObject('{bad')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('tryParseToolResultObject gating', () => {
|
||||
it('parses JSON objects that start after leading whitespace', () => {
|
||||
expect(tryParseToolResultObject('\n {"result":"ok"}')).toEqual({ result: 'ok' });
|
||||
});
|
||||
|
||||
it('skips the parse for large plain-text results', () => {
|
||||
// most tool results are file contents or stdout; the gate avoids a
|
||||
// doomed JSON.parse over the whole blob
|
||||
expect(tryParseToolResultObject(`${'stdout line\n'.repeat(2000)}`)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,752 @@
|
||||
import {
|
||||
extractToolArgPath,
|
||||
parseToolArgs
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/_shared';
|
||||
import {
|
||||
parseEditFileMeta,
|
||||
parseEditFileTitleMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/edit-file';
|
||||
import { parseExecShellCommandMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/exec-shell-command';
|
||||
import { parseFileGlobSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/file-glob-search';
|
||||
import { parseGrepSearchMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/grep-search';
|
||||
import { parseReadFileMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/read-file';
|
||||
import { parseRunJavascriptMeta } from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/run-javascript';
|
||||
import {
|
||||
parseWriteFileMeta,
|
||||
parseWriteFileTitleMeta
|
||||
} from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessageToolCall/parsers/write-file';
|
||||
import { CODE_BLOCK } from '$lib/constants';
|
||||
import { AgenticSectionType, BuiltInTool } from '$lib/enums';
|
||||
import type { AgenticSection, WriteFileMeta } from '$lib/types';
|
||||
import { abbreviateHome, formatCwdMessage, lastPathSegment, parseCwdMessage } from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
function makeSection(
|
||||
overrides: Partial<AgenticSection> = {},
|
||||
toolName = BuiltInTool.SERVER_READ_FILE
|
||||
): AgenticSection {
|
||||
return {
|
||||
content: '',
|
||||
toolArgs: JSON.stringify({ path: '/foo.txt' }),
|
||||
toolName,
|
||||
toolResult: undefined,
|
||||
type: AgenticSectionType.TOOL_CALL,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('lastPathSegment', () => {
|
||||
it('returns the last segment of an absolute path', () => {
|
||||
expect(lastPathSegment('/Users/me/code/my-project')).toBe('my-project');
|
||||
});
|
||||
|
||||
it('returns the last segment of a tilde-relative path', () => {
|
||||
expect(lastPathSegment('~/git/llama.brand')).toBe('llama.brand');
|
||||
});
|
||||
|
||||
it('strips trailing slashes', () => {
|
||||
expect(lastPathSegment('/foo/bar/')).toBe('bar');
|
||||
});
|
||||
|
||||
it('strips multiple trailing slashes', () => {
|
||||
expect(lastPathSegment('/foo/bar///')).toBe('bar');
|
||||
});
|
||||
|
||||
it('returns the input unchanged when there is no slash', () => {
|
||||
expect(lastPathSegment('project')).toBe('project');
|
||||
});
|
||||
|
||||
it('returns tilde when only tilde is given', () => {
|
||||
expect(lastPathSegment('~/')).toBe('~');
|
||||
});
|
||||
});
|
||||
|
||||
describe('abbreviateHome', () => {
|
||||
it('abbreviates paths under home with a tilde', () => {
|
||||
expect(abbreviateHome('/Users/al/Documents/x.txt', '/Users/al')).toBe('~/Documents/x.txt');
|
||||
});
|
||||
|
||||
it('abbreviates home itself to a bare tilde', () => {
|
||||
expect(abbreviateHome('/Users/al', '/Users/al')).toBe('~');
|
||||
});
|
||||
|
||||
it('returns paths outside home unchanged', () => {
|
||||
expect(abbreviateHome('/opt/project', '/Users/al')).toBe('/opt/project');
|
||||
});
|
||||
|
||||
it('does not abbreviate a mere prefix match', () => {
|
||||
expect(abbreviateHome('/Users/alice/x', '/Users/al')).toBe('/Users/alice/x');
|
||||
});
|
||||
|
||||
it('returns the path unchanged when home is unknown', () => {
|
||||
expect(abbreviateHome('/Users/al/Documents', null)).toBe('/Users/al/Documents');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCwdMessage / parseCwdMessage', () => {
|
||||
it('formats a cwd change matching the UI text, with a file link', () => {
|
||||
expect(formatCwdMessage('/Users/al/Documents', '/Users/al')).toBe(
|
||||
'Set working directory to [file:///Users/al/Documents](~/Documents).'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the basename display when home is unknown', () => {
|
||||
expect(formatCwdMessage('/opt/project', null)).toBe(
|
||||
'Set working directory to [file:///opt/project](project).'
|
||||
);
|
||||
});
|
||||
|
||||
it('round-trips through the parser', () => {
|
||||
const info = parseCwdMessage(formatCwdMessage('/Users/al/Documents', '/Users/al'));
|
||||
|
||||
expect(info?.path).toBe('/Users/al/Documents');
|
||||
expect(info?.display).toBe('~/Documents');
|
||||
});
|
||||
|
||||
it('parses a cwd message even when guidance follows the link', () => {
|
||||
expect(
|
||||
parseCwdMessage(
|
||||
'Set working directory to [file:///a/b](~/b). Tool calls run with this as their working directory.'
|
||||
)
|
||||
).toEqual({ display: '~/b', path: '/a/b' });
|
||||
});
|
||||
|
||||
it('parses the cleared marker', () => {
|
||||
expect(parseCwdMessage('Working directory cleared')).toEqual({ display: '', path: null });
|
||||
});
|
||||
|
||||
it('returns null for non-cwd content', () => {
|
||||
expect(parseCwdMessage('hello there')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseToolArgs (shared)', () => {
|
||||
it('returns null when the section has no toolArgs', () => {
|
||||
const result = parseToolArgs(
|
||||
BuiltInTool.SERVER_READ_FILE,
|
||||
makeSection({ toolArgs: undefined })
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when the tool name does not match', () => {
|
||||
const result = parseToolArgs(
|
||||
BuiltInTool.SERVER_READ_FILE,
|
||||
makeSection({ toolArgs: '{"path":"/x"}' }, BuiltInTool.SERVER_WRITE_FILE)
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when args are not valid final JSON (partial: false)', () => {
|
||||
const result = parseToolArgs(
|
||||
BuiltInTool.SERVER_READ_FILE,
|
||||
makeSection({ toolArgs: '{"path": "/foo.tx' })
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns parsed args when valid final JSON', () => {
|
||||
const result = parseToolArgs(
|
||||
BuiltInTool.SERVER_READ_FILE,
|
||||
makeSection({ toolArgs: '{"path":"/foo.txt"}' })
|
||||
);
|
||||
|
||||
expect(result).toEqual({ path: '/foo.txt' });
|
||||
});
|
||||
|
||||
it('accepts partial JSON when partial: true', () => {
|
||||
const result = parseToolArgs(
|
||||
BuiltInTool.SERVER_READ_FILE,
|
||||
makeSection({ toolArgs: '{"path": "/foo.tx' }),
|
||||
{ partial: true }
|
||||
);
|
||||
|
||||
expect(result).toEqual({ path: '/foo.tx' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToolArgPath', () => {
|
||||
it('reads every path alias', () => {
|
||||
expect(extractToolArgPath({ path: '/a' })).toBe('/a');
|
||||
expect(extractToolArgPath({ file_path: '/b' })).toBe('/b');
|
||||
expect(extractToolArgPath({ filePath: '/c' })).toBe('/c');
|
||||
});
|
||||
|
||||
it('returns undefined when no path alias is a usable string', () => {
|
||||
expect(extractToolArgPath({ content: 'x' })).toBeUndefined();
|
||||
expect(extractToolArgPath({ path: '' })).toBeUndefined();
|
||||
expect(extractToolArgPath({ path: 7 })).toBeUndefined();
|
||||
expect(extractToolArgPath(null)).toBeUndefined();
|
||||
expect(extractToolArgPath(undefined)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWriteFileMeta', () => {
|
||||
it('returns null for sections with a different tool name', () => {
|
||||
expect(
|
||||
parseWriteFileMeta(
|
||||
makeSection({
|
||||
toolArgs: '{"path":"/x","content":"y"}',
|
||||
toolName: BuiltInTool.SERVER_READ_FILE
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('returns content without a path when args have no path-like field', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
||||
);
|
||||
|
||||
expect(meta?.content).toBe('x');
|
||||
expect(meta?.filePath).toBeUndefined();
|
||||
expect(meta?.fileName).toBeUndefined();
|
||||
expect(meta?.language).toBe(CODE_BLOCK.DEFAULT_LANGUAGE);
|
||||
});
|
||||
|
||||
it('returns content streamed before the path has arrived', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection({ toolArgs: '{"content":"hello ', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
||||
);
|
||||
|
||||
expect(meta?.content).toBe('hello ');
|
||||
});
|
||||
|
||||
it('accepts partial args (renders incrementally as content streams in)', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection({ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
||||
);
|
||||
|
||||
expect(meta?.filePath).toBe('/foo.t');
|
||||
});
|
||||
|
||||
it('returns file path, language, content, bytes, resultMessage', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo.ts","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE,
|
||||
toolResult: '{"result":"wrote","bytes":42}'
|
||||
},
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta).toMatchObject<Partial<WriteFileMeta>>({
|
||||
bytesWritten: 42,
|
||||
content: 'x',
|
||||
filePath: '/foo.ts',
|
||||
language: expect.any(String),
|
||||
resultMessage: 'wrote'
|
||||
});
|
||||
});
|
||||
|
||||
it('surfaces errorMessage from the result blob', () => {
|
||||
const meta = parseWriteFileMeta(
|
||||
makeSection({
|
||||
toolArgs: '{"path":"/foo","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE,
|
||||
toolResult: '{"error":"permission denied"}'
|
||||
})
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('permission denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseWriteFileTitleMeta', () => {
|
||||
it('matches the full meta for path, language and result fields', () => {
|
||||
const args = JSON.stringify({ content: 'x'.repeat(50_000), path: '/foo.ts' });
|
||||
const toolResult = '{"result":"wrote","bytes":42}';
|
||||
const section = makeSection(
|
||||
{ toolArgs: args, toolName: BuiltInTool.SERVER_WRITE_FILE, toolResult },
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
const full = parseWriteFileMeta(section);
|
||||
const title = parseWriteFileTitleMeta(section);
|
||||
|
||||
expect(title?.filePath).toBe(full?.filePath);
|
||||
expect(title?.fileName).toBe(full?.fileName);
|
||||
expect(title?.language).toBe(full?.language);
|
||||
expect(title?.bytesWritten).toBe(full?.bytesWritten);
|
||||
expect(title?.resultMessage).toBe(full?.resultMessage);
|
||||
expect(title?.errorMessage).toBe(full?.errorMessage);
|
||||
});
|
||||
|
||||
it('extracts a path with escaped characters without parsing the content blob', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/a\\nb\\"c/d.ts","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE
|
||||
},
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/a\nb"c/d.ts');
|
||||
});
|
||||
|
||||
it('falls back to the full parse for args the extractor can not see', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
// key written with an escaped unicode escape sequence in the name
|
||||
toolArgs: '{"\\u0070ath":"/foo.ts","content":"x"}',
|
||||
toolName: BuiltInTool.SERVER_WRITE_FILE
|
||||
},
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.ts');
|
||||
});
|
||||
|
||||
it('accepts partial args like the full parser', () => {
|
||||
const section = makeSection(
|
||||
{ toolArgs: '{"path":"/foo.t', toolName: BuiltInTool.SERVER_WRITE_FILE },
|
||||
BuiltInTool.SERVER_WRITE_FILE
|
||||
);
|
||||
|
||||
expect(parseWriteFileTitleMeta(section)?.filePath).toBe('/foo.t');
|
||||
});
|
||||
|
||||
it('returns meta without a path when args have no path-like field', () => {
|
||||
const title = parseWriteFileTitleMeta(
|
||||
makeSection({ toolArgs: '{"content":"x"}', toolName: BuiltInTool.SERVER_WRITE_FILE })
|
||||
);
|
||||
|
||||
expect(title?.filePath).toBeUndefined();
|
||||
expect(title?.fileName).toBeUndefined();
|
||||
expect(title?.language).toBe(CODE_BLOCK.DEFAULT_LANGUAGE);
|
||||
});
|
||||
|
||||
it('returns null for sections with a different tool name', () => {
|
||||
expect(
|
||||
parseWriteFileTitleMeta(
|
||||
makeSection({
|
||||
toolArgs: '{"path":"/x","content":"y"}',
|
||||
toolName: BuiltInTool.SERVER_READ_FILE
|
||||
})
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEditFileTitleMeta', () => {
|
||||
it('matches the full meta for path and result fields', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"}]}' + ' '.repeat(0),
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"result":"ok","edits_applied":1}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
const full = parseEditFileMeta(section);
|
||||
const title = parseEditFileTitleMeta(section);
|
||||
|
||||
expect(title?.filePath).toBe(full?.filePath);
|
||||
expect(title?.fileName).toBe(full?.fileName);
|
||||
expect(title?.editsApplied).toBe(full?.editsApplied);
|
||||
expect(title?.resultMessage).toBe(full?.resultMessage);
|
||||
expect(title?.errorMessage).toBe(full?.errorMessage);
|
||||
});
|
||||
|
||||
it('surfaces errorMessage from the result blob without parsing args', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo.ts","edits":[]}',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"error":"permission denied"}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
|
||||
expect(parseEditFileTitleMeta(section)?.errorMessage).toBe('permission denied');
|
||||
});
|
||||
|
||||
it('returns meta without a path when args have no path-like field', () => {
|
||||
const title = parseEditFileTitleMeta(
|
||||
makeSection({ toolArgs: '{"edits":[]}', toolName: BuiltInTool.SERVER_EDIT_FILE })
|
||||
);
|
||||
|
||||
expect(title?.filePath).toBeUndefined();
|
||||
expect(title?.fileName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseEditFileMeta', () => {
|
||||
it('parses edits array and applies editsApplied from the result', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs:
|
||||
'{"path":"/foo.ts","edits":[{"old_text":"a","new_text":"b"},{"old_text":"c","new_text":"d"}]}',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"result":"ok","edits_applied":2}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
const meta = parseEditFileMeta(section);
|
||||
|
||||
expect(meta?.edits).toEqual([
|
||||
{ newText: 'b', oldText: 'a' },
|
||||
{ newText: 'd', oldText: 'c' }
|
||||
]);
|
||||
expect(meta?.editsApplied).toBe(2);
|
||||
expect(meta?.resultMessage).toBe('ok');
|
||||
});
|
||||
|
||||
it('drops edits with empty old_text', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo","edits":[{"old_text":""},{"old_text":"a","new_text":""}]}',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
const meta = parseEditFileMeta(section);
|
||||
|
||||
// First entry is dropped (empty old_text). Second is kept
|
||||
// (empty new_text is fine - it's the "delete" case).
|
||||
expect(meta?.edits).toEqual([{ newText: '', oldText: 'a' }]);
|
||||
});
|
||||
|
||||
it('errorMessage wins over result message', () => {
|
||||
const section = makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/foo"}',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE,
|
||||
toolResult: '{"error":"bad path","result":"ok"}'
|
||||
},
|
||||
BuiltInTool.SERVER_EDIT_FILE
|
||||
);
|
||||
const meta = parseEditFileMeta(section);
|
||||
|
||||
expect(meta?.errorMessage).toBe('bad path');
|
||||
expect(meta?.resultMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still parses edits when args have no path-like field', () => {
|
||||
const meta = parseEditFileMeta(
|
||||
makeSection({
|
||||
toolArgs: '{"edits":[{"old_text":"a","new_text":"b"}]}',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE
|
||||
})
|
||||
);
|
||||
|
||||
expect(meta?.edits).toEqual([{ newText: 'b', oldText: 'a' }]);
|
||||
expect(meta?.filePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parses edits streamed before the path has arrived', () => {
|
||||
const meta = parseEditFileMeta(
|
||||
makeSection({
|
||||
// second edit is still mid-stream: only its old_text has landed
|
||||
toolArgs: '{"edits":[{"old_text":"a","new_text":"b"},{"old_text":"c',
|
||||
toolName: BuiltInTool.SERVER_EDIT_FILE
|
||||
})
|
||||
);
|
||||
|
||||
expect(meta?.edits).toEqual([
|
||||
{ newText: 'b', oldText: 'a' },
|
||||
{ newText: '', oldText: 'c' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseReadFileMeta', () => {
|
||||
it('parses file name alone (no range)', () => {
|
||||
const meta = parseReadFileMeta(
|
||||
makeSection({ toolArgs: '{"path":"/foo.txt"}' }, BuiltInTool.SERVER_READ_FILE)
|
||||
);
|
||||
|
||||
expect(meta?.fileName).toBe('foo.txt');
|
||||
expect(meta?.lineRange).toBeNull();
|
||||
});
|
||||
|
||||
it('parses start_line + end_line into a range', () => {
|
||||
const meta = parseReadFileMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"path":"/foo.ts","start_line":10,"end_line":20}' },
|
||||
BuiltInTool.SERVER_READ_FILE
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.lineRange).toEqual({ end: 20, start: 10 });
|
||||
});
|
||||
|
||||
it('parses start_line + line_count into a range', () => {
|
||||
const meta = parseReadFileMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"path":"/foo.ts","start_line":10,"line_count":5}' },
|
||||
BuiltInTool.SERVER_READ_FILE
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.lineRange).toEqual({ end: 14, start: 10 });
|
||||
});
|
||||
|
||||
it('returns null when args cannot be parsed', () => {
|
||||
expect(
|
||||
parseReadFileMeta(makeSection({ toolArgs: '{bad' }, BuiltInTool.SERVER_READ_FILE))
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseGrepSearchMeta', () => {
|
||||
it('returns null when path or pattern is missing', () => {
|
||||
expect(
|
||||
parseGrepSearchMeta(
|
||||
makeSection({ toolArgs: '{"pattern":"foo"}', toolName: BuiltInTool.SERVER_GREP_SEARCH })
|
||||
)
|
||||
).toBeNull();
|
||||
expect(
|
||||
parseGrepSearchMeta(
|
||||
makeSection({ toolArgs: '{"path":"/x"}', toolName: BuiltInTool.SERVER_GREP_SEARCH })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('parses structured plain_text_response into matches', () => {
|
||||
const meta = parseGrepSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/x","pattern":"foo"}',
|
||||
toolName: BuiltInTool.SERVER_GREP_SEARCH,
|
||||
toolResult: JSON.stringify({ plain_text_response: 'a.ts:hello\nb.ts:world' })
|
||||
},
|
||||
BuiltInTool.SERVER_GREP_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toHaveLength(2);
|
||||
expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts' });
|
||||
});
|
||||
|
||||
it('falls back to raw-text parsing when result is not JSON', () => {
|
||||
const meta = parseGrepSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/x","pattern":"foo"}',
|
||||
toolName: BuiltInTool.SERVER_GREP_SEARCH,
|
||||
toolResult: 'a.ts:hello\nb.ts:world'
|
||||
},
|
||||
BuiltInTool.SERVER_GREP_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('parses line numbers when return_line_numbers is true', () => {
|
||||
const meta = parseGrepSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/x","pattern":"foo","return_line_numbers":true}',
|
||||
toolName: BuiltInTool.SERVER_GREP_SEARCH,
|
||||
toolResult: 'a.ts:12:hello'
|
||||
},
|
||||
BuiltInTool.SERVER_GREP_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches[0]).toEqual({ content: 'hello', file: 'a.ts', line: 12 });
|
||||
expect(meta?.showLineNumbers).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseFileGlobSearchMeta', () => {
|
||||
it('falls back to raw-text parsing when result is not JSON', () => {
|
||||
const meta = parseFileGlobSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/x"}',
|
||||
toolName: BuiltInTool.SERVER_FILE_GLOB_SEARCH,
|
||||
toolResult: 'a.ts\nb.ts'
|
||||
},
|
||||
BuiltInTool.SERVER_FILE_GLOB_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
|
||||
});
|
||||
|
||||
it('parses plain_text_response from a JSON object', () => {
|
||||
const meta = parseFileGlobSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/x"}',
|
||||
toolName: BuiltInTool.SERVER_FILE_GLOB_SEARCH,
|
||||
toolResult: JSON.stringify({ plain_text_response: 'a.ts\nb.ts' })
|
||||
},
|
||||
BuiltInTool.SERVER_FILE_GLOB_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.matches).toEqual(['a.ts', 'b.ts']);
|
||||
});
|
||||
|
||||
it('surfaces errorMessage from the result blob', () => {
|
||||
const meta = parseFileGlobSearchMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"path":"/x"}',
|
||||
toolName: BuiltInTool.SERVER_FILE_GLOB_SEARCH,
|
||||
toolResult: JSON.stringify({ error: 'permission denied' })
|
||||
},
|
||||
BuiltInTool.SERVER_FILE_GLOB_SEARCH
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('permission denied');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseRunJavascriptMeta', () => {
|
||||
it('returns null when code is missing', () => {
|
||||
expect(
|
||||
parseRunJavascriptMeta(
|
||||
makeSection({ toolArgs: '{}', toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT })
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('reads code and timeout', () => {
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"code":"Math.PI","timeout_ms":5000}',
|
||||
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
||||
},
|
||||
BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.code).toBe('Math.PI');
|
||||
expect(meta?.timeoutMs).toBe(5000);
|
||||
});
|
||||
|
||||
it('reads error field from a JSON-object result', () => {
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"code":"throw new Error()"}',
|
||||
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT,
|
||||
toolResult: JSON.stringify({ error: 'undefined is not a function' })
|
||||
},
|
||||
BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('undefined is not a function');
|
||||
});
|
||||
|
||||
it('does NOT treat a JSON-array result as an error', () => {
|
||||
// SandboxService returns successful output as a JSON array;
|
||||
// only JSON objects carry `error`. Raw arrays must round-trip
|
||||
// through unchanged.
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"code":"[1,2,3]"}',
|
||||
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT,
|
||||
toolResult: '[1,2,3]'
|
||||
},
|
||||
BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('scans a non-JSON string result for an `Error:` line', () => {
|
||||
const meta = parseRunJavascriptMeta(
|
||||
makeSection(
|
||||
{
|
||||
toolArgs: '{"code":"foo"}',
|
||||
toolName: BuiltInTool.BROWSER_RUN_JAVASCRIPT,
|
||||
toolResult: 'Error: undefined is not a function\n at <anonymous>:1:1'
|
||||
},
|
||||
BuiltInTool.BROWSER_RUN_JAVASCRIPT
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.errorMessage).toBe('undefined is not a function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExecShellCommandMeta', () => {
|
||||
it('reads command from the args', () => {
|
||||
const meta = parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"command":"ls -la"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.command).toBe('ls -la');
|
||||
});
|
||||
|
||||
it('accepts cmd / shell_command aliases', () => {
|
||||
expect(
|
||||
parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"cmd":"ls"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
||||
)
|
||||
)?.command
|
||||
).toBe('ls');
|
||||
expect(
|
||||
parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"shell_command":"ls"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
||||
)
|
||||
)?.command
|
||||
).toBe('ls');
|
||||
});
|
||||
|
||||
it('returns null when no command alias is present', () => {
|
||||
expect(
|
||||
parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"cwd":"/x"}', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
||||
)
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('surfaces a partially streamed command before the JSON closes', () => {
|
||||
const meta = parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"command":"git sta', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.command).toBe('git sta');
|
||||
});
|
||||
|
||||
it('keeps a dangling escape from breaking the partial parse', () => {
|
||||
const meta = parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"command":"echo \\"he', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
||||
)
|
||||
);
|
||||
|
||||
expect(meta?.command).toBe('echo "he');
|
||||
});
|
||||
|
||||
it('returns null while the command value is still empty', () => {
|
||||
expect(
|
||||
parseExecShellCommandMeta(
|
||||
makeSection(
|
||||
{ toolArgs: '{"command":"', toolName: BuiltInTool.SERVER_EXEC_SHELL_COMMAND },
|
||||
BuiltInTool.SERVER_EXEC_SHELL_COMMAND
|
||||
)
|
||||
)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
import { URI_TEMPLATE_SYMBOLS } from '../../src/lib/constants/uri-template.constants';
|
||||
import {
|
||||
expandTemplate,
|
||||
extractTemplateVariables,
|
||||
isTemplateComplete,
|
||||
normalizeResourceUri
|
||||
} from '../../src/lib/utils/uri-template';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('extractTemplateVariables', () => {
|
||||
it('extracts simple variables', () => {
|
||||
const vars = extractTemplateVariables('file:///{path}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'path', operator: '' }]);
|
||||
});
|
||||
|
||||
it('extracts multiple variables', () => {
|
||||
const vars = extractTemplateVariables('db://{schema}/{table}');
|
||||
|
||||
expect(vars).toEqual([
|
||||
{ name: 'schema', operator: '' },
|
||||
{ name: 'table', operator: '' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('extracts variables with operators', () => {
|
||||
const vars = extractTemplateVariables('http://example.com{+path}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_SYMBOLS.RESERVED }]);
|
||||
});
|
||||
|
||||
it('extracts comma-separated variable lists', () => {
|
||||
const vars = extractTemplateVariables('{x,y,z}');
|
||||
|
||||
expect(vars).toEqual([
|
||||
{ name: 'x', operator: '' },
|
||||
{ name: 'y', operator: '' },
|
||||
{ name: 'z', operator: '' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('deduplicates variable names', () => {
|
||||
const vars = extractTemplateVariables('{name}/{name}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'name', operator: '' }]);
|
||||
});
|
||||
|
||||
it('handles fragment expansion', () => {
|
||||
const vars = extractTemplateVariables('http://example.com/page{#section}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'section', operator: URI_TEMPLATE_SYMBOLS.FRAGMENT }]);
|
||||
});
|
||||
|
||||
it('handles path segment expansion', () => {
|
||||
const vars = extractTemplateVariables('http://example.com{/path}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_SYMBOLS.PATH_SEGMENT }]);
|
||||
});
|
||||
|
||||
it('returns empty array for template without variables', () => {
|
||||
const vars = extractTemplateVariables('http://example.com/static');
|
||||
|
||||
expect(vars).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips explode modifier', () => {
|
||||
const vars = extractTemplateVariables('{list*}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'list', operator: '' }]);
|
||||
});
|
||||
|
||||
it('strips prefix modifier', () => {
|
||||
const vars = extractTemplateVariables('{value:5}');
|
||||
|
||||
expect(vars).toEqual([{ name: 'value', operator: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('expandTemplate', () => {
|
||||
it('expands simple variable', () => {
|
||||
const result = expandTemplate('file:///{path}', { path: 'src/main.rs' });
|
||||
|
||||
expect(result).toBe('file:///src%2Fmain.rs');
|
||||
});
|
||||
|
||||
it('expands reserved variable (no encoding)', () => {
|
||||
const result = expandTemplate('file:///{+path}', { path: 'src/main.rs' });
|
||||
|
||||
expect(result).toBe('file:///src/main.rs');
|
||||
});
|
||||
|
||||
it('expands multiple variables', () => {
|
||||
const result = expandTemplate('db://{schema}/{table}', {
|
||||
schema: 'public',
|
||||
table: 'users'
|
||||
});
|
||||
|
||||
expect(result).toBe('db://public/users');
|
||||
});
|
||||
|
||||
it('leaves empty for missing variables', () => {
|
||||
const result = expandTemplate('{missing}', {});
|
||||
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('expands fragment', () => {
|
||||
const result = expandTemplate('http://example.com/page{#section}', {
|
||||
section: 'intro'
|
||||
});
|
||||
|
||||
expect(result).toBe('http://example.com/page#intro');
|
||||
});
|
||||
|
||||
it('expands path segments', () => {
|
||||
const result = expandTemplate('http://example.com{/path}', { path: 'docs' });
|
||||
|
||||
expect(result).toBe('http://example.com/docs');
|
||||
});
|
||||
|
||||
it('expands query parameters', () => {
|
||||
const result = expandTemplate('http://example.com{?q}', { q: 'search term' });
|
||||
|
||||
expect(result).toBe('http://example.com?q=search%20term');
|
||||
});
|
||||
|
||||
it('expands multiple query parameters', () => {
|
||||
const result = expandTemplate('http://example.com{?q,sort}', {
|
||||
q: 'search term',
|
||||
sort: 'descending'
|
||||
});
|
||||
|
||||
expect(result).toBe('http://example.com?q=search%20term&sort=descending');
|
||||
});
|
||||
|
||||
it('keeps static parts unchanged', () => {
|
||||
const result = expandTemplate('http://example.com/static', {});
|
||||
|
||||
expect(result).toBe('http://example.com/static');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isTemplateComplete', () => {
|
||||
it('returns true when all variables are filled', () => {
|
||||
expect(isTemplateComplete('file:///{path}', { path: 'test.txt' })).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when a variable is missing', () => {
|
||||
expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when a variable is empty', () => {
|
||||
expect(isTemplateComplete('file:///{path}', { path: '' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when a variable is whitespace only', () => {
|
||||
expect(isTemplateComplete('file:///{path}', { path: ' ' })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true for template without variables', () => {
|
||||
expect(isTemplateComplete('http://example.com/static', {})).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when all multiple variables are filled', () => {
|
||||
expect(isTemplateComplete('db://{schema}/{table}', { schema: 'public', table: 'users' })).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('normalizeResourceUri', () => {
|
||||
it('passes through a normal URI unchanged', () => {
|
||||
expect(normalizeResourceUri('svelte://svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
|
||||
});
|
||||
|
||||
it('normalizes triple-slash URIs from path-style template expansion', () => {
|
||||
expect(normalizeResourceUri('svelte:///svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
|
||||
});
|
||||
|
||||
it('normalizes quadruple-slash URIs', () => {
|
||||
expect(normalizeResourceUri('svelte:////svelte/$effect.md')).toBe('svelte://svelte/$effect.md');
|
||||
});
|
||||
|
||||
it('handles file:// URIs', () => {
|
||||
expect(normalizeResourceUri('file:///home/user/doc.txt')).toBe('file://home/user/doc.txt');
|
||||
});
|
||||
|
||||
it('handles http URIs unchanged', () => {
|
||||
expect(normalizeResourceUri('http://example.com/path')).toBe('http://example.com/path');
|
||||
});
|
||||
|
||||
it('returns non-URI strings unchanged', () => {
|
||||
expect(normalizeResourceUri('not-a-uri')).toBe('not-a-uri');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
import { GLOB, SEARCH } from '$lib/constants';
|
||||
import {
|
||||
buildCaseInsensitiveGlob,
|
||||
buildGlobSearchArgs,
|
||||
highlightMatch,
|
||||
joinPath,
|
||||
rankEntries,
|
||||
splitPathQuery
|
||||
} from '$lib/utils';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('splitPathQuery', () => {
|
||||
it('treats a plain query as a home-relative glob (not navigation)', () => {
|
||||
expect(splitPathQuery('docs')).toBeNull();
|
||||
});
|
||||
|
||||
it('navigates the root for `/`', () => {
|
||||
expect(splitPathQuery('/')).toEqual({ last: '', parent: '/' });
|
||||
});
|
||||
|
||||
it('navigates home for `~`', () => {
|
||||
expect(splitPathQuery('~')).toEqual({ last: '', parent: '~' });
|
||||
});
|
||||
|
||||
it('splits an absolute path into parent and last segment', () => {
|
||||
expect(splitPathQuery('/Users/al/proj')).toEqual({ last: 'proj', parent: '/Users/al' });
|
||||
});
|
||||
|
||||
it('navigates a Windows drive path written with backslashes', () => {
|
||||
expect(splitPathQuery('C:\\repos\\llama.cpp')).toEqual({
|
||||
last: 'llama.cpp',
|
||||
parent: 'C:/repos'
|
||||
});
|
||||
});
|
||||
|
||||
it('navigates a Windows drive path written with forward slashes', () => {
|
||||
expect(splitPathQuery('D:/repos')).toEqual({ last: 'repos', parent: 'D:/' });
|
||||
});
|
||||
|
||||
it('treats a bare drive as its root', () => {
|
||||
expect(splitPathQuery('D:')).toEqual({ last: '', parent: 'D:/' });
|
||||
expect(splitPathQuery('D:\\')).toEqual({ last: '', parent: 'D:/' });
|
||||
});
|
||||
|
||||
it('navigates a UNC share', () => {
|
||||
expect(splitPathQuery('\\\\host\\share\\proj')).toEqual({
|
||||
last: 'proj',
|
||||
parent: '//host/share/'
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps a backslash as a POSIX filename character', () => {
|
||||
expect(splitPathQuery('/tmp/a\\b')).toEqual({ last: 'a\\b', parent: '/tmp' });
|
||||
});
|
||||
|
||||
it('splits a home-relative path into parent and last segment', () => {
|
||||
expect(splitPathQuery('~/Documents')).toEqual({ last: 'Documents', parent: '~' });
|
||||
});
|
||||
|
||||
it('strips trailing slashes before splitting', () => {
|
||||
expect(splitPathQuery('/Users/al/')).toEqual({ last: 'al', parent: '/Users' });
|
||||
});
|
||||
|
||||
it('handles a single-segment absolute path', () => {
|
||||
expect(splitPathQuery('/opt')).toEqual({ last: 'opt', parent: '/' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildCaseInsensitiveGlob', () => {
|
||||
it('wraps letters in case-insensitive character classes', () => {
|
||||
expect(buildCaseInsensitiveGlob('ab')).toBe('*[aA][bB]*');
|
||||
});
|
||||
|
||||
it('escapes glob metacharacters into literal fragments', () => {
|
||||
expect(buildCaseInsensitiveGlob('a*b')).toBe('*[aA][*][bB]*');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankEntries', () => {
|
||||
const entries = [
|
||||
{ path: '/h/README', type: 'dir' },
|
||||
{ path: '/h/read', type: 'dir' },
|
||||
{ path: '/h/readme.txt', type: 'dir' }
|
||||
];
|
||||
|
||||
it('ranks exact basename match first', () => {
|
||||
const ranked = rankEntries(entries, 'read');
|
||||
|
||||
expect(ranked[0].path).toBe('/h/read');
|
||||
});
|
||||
|
||||
it('breaks ties by shorter path, then alphabetically', () => {
|
||||
const ranked = rankEntries(entries, 'read');
|
||||
|
||||
expect(ranked[ranked.length - 1].path).toBe('/h/readme.txt');
|
||||
});
|
||||
|
||||
it('does not mutate the input', () => {
|
||||
const snapshot = [...entries];
|
||||
|
||||
rankEntries(entries, 'read');
|
||||
expect(entries).toEqual(snapshot);
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinPath', () => {
|
||||
it('joins base and relative avoiding a double slash', () => {
|
||||
expect(joinPath('/home/al/', 'docs')).toBe('/home/al/docs');
|
||||
});
|
||||
|
||||
it('returns the relative path when base is empty', () => {
|
||||
expect(joinPath('', 'docs')).toBe('docs');
|
||||
});
|
||||
});
|
||||
|
||||
describe('highlightMatch', () => {
|
||||
it('returns a single non-matching segment when query is empty', () => {
|
||||
expect(highlightMatch('abc', '')).toEqual([{ match: false, text: 'abc' }]);
|
||||
});
|
||||
|
||||
it('marks every case-insensitive occurrence of the query', () => {
|
||||
expect(highlightMatch('aXa', 'ax')).toEqual([
|
||||
{ match: true, text: 'aX' },
|
||||
{ match: false, text: 'a' }
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns non-matching text when the query is absent', () => {
|
||||
expect(highlightMatch('abc', 'z')).toEqual([{ match: false, text: 'abc' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGlobSearchArgs', () => {
|
||||
const DEPTH = 6;
|
||||
|
||||
it('glob-matches home-relative within the scope path', () => {
|
||||
const args = buildGlobSearchArgs('docs', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('/home');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('docs'));
|
||||
expect(args.maxDepth).toBe(DEPTH);
|
||||
expect(args.rankQuery).toBe('docs');
|
||||
expect(args.last).toBeUndefined();
|
||||
});
|
||||
|
||||
it('navigates home for a `~` path query', () => {
|
||||
const args = buildGlobSearchArgs('~/proj', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('proj'));
|
||||
expect(args.maxDepth).toBe(SEARCH.PATH_NAV_MAX_DEPTH);
|
||||
expect(args.rankQuery).toBe('proj');
|
||||
expect(args.last).toBe('proj');
|
||||
});
|
||||
|
||||
it('lists the scope root when a path query has no last segment', () => {
|
||||
const args = buildGlobSearchArgs('~/', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('~');
|
||||
expect(args.include).toBe(GLOB.WILDCARD);
|
||||
expect(args.maxDepth).toBe(SEARCH.PATH_NAV_MAX_DEPTH);
|
||||
});
|
||||
|
||||
it('navigates an absolute path under its root', () => {
|
||||
const args = buildGlobSearchArgs('/usr/local/bin', '/home', DEPTH);
|
||||
|
||||
expect(args.path).toBe('/usr/local');
|
||||
expect(args.include).toBe(buildCaseInsensitiveGlob('bin'));
|
||||
expect(args.maxDepth).toBe(SEARCH.PATH_NAV_MAX_DEPTH);
|
||||
expect(args.rankQuery).toBe('bin');
|
||||
expect(args.last).toBe('bin');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user