chore: import upstream snapshot with attribution
Copilot Setup Steps / copilot-setup-steps (push) Failing after 2s
Python check requirements.txt / check-requirements (push) Has been cancelled
Python Type-Check / python type-check (push) Has been cancelled
Update Operations Documentation / update-ops-docs (push) Has been cancelled
Check Pre-Tokenizer Hashes / pre-tokenizer-hashes (push) Has been cancelled
Copilot Setup Steps / copilot-setup-steps (push) Failing after 2s
Python check requirements.txt / check-requirements (push) Has been cancelled
Python Type-Check / python type-check (push) Has been cancelled
Update Operations Documentation / update-ops-docs (push) Has been cancelled
Check Pre-Tokenizer Hashes / pre-tokenizer-hashes (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { capImageDataURLSize } from '$lib/utils/cap-img-size';
|
||||
import { getJpegOrientationFromDataURL } from '$lib/utils/jpeg-orientation';
|
||||
|
||||
// Real 64x32 jpegs generated with Pillow, quality 90. The upright picture is
|
||||
// four solid quadrants: top left red, top right green, bottom left blue,
|
||||
// bottom right yellow. For each exif value the stored pixels are inverse
|
||||
// transposed so a conforming decoder shows the upright picture, exactly like
|
||||
// a rotated smartphone photo.
|
||||
const EXIF1 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAEAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
|
||||
const EXIF3 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAMAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD7Nooor/Mo/XQooooA/Cqiiiv+og/moKKKKAPuuiiiv+fw/lcKKKKAPEaKKK+9P+jMKKKKAP/Z`;
|
||||
const EXIF5 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAUAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UzwKiiiv9xj/HQ99ooor/Dk/wBizwKiiiv9xj/HQ+66KKK/5/D+Vz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/9k=`;
|
||||
const EXIF6 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAYAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwDCooor+Tz+Hz7qooor+ej/AE9PhWiiiv6FP8wj7qooor+ej/T0/Keiiiv7CP72PAqKKK/3GP8AHQ99ooor/Dk/2LPAqKKK/wBxj/HQ/9k=`;
|
||||
const EXIF8 = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/4QAiRXhpZgAATU0AKgAAAAgAAQESAAMAAAABAAgAAAAAAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCABAACADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD896KKK/1TPhz32iiiv8OT/Ys8Cooor/cY/wAdD32iiiv8OT/Ys/Viiiiv49P4JPhWiiiv6FP8wj7qooor+ej/AE9PhWiiiv6FP8wj/9k=`;
|
||||
const NOEXIF = `data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAMCAgMCAgMDAwMEAwMEBQgFBQQEBQoHBwYIDAoMDAsKCwsNDhIQDQ4RDgsLEBYQERMUFRUVDA8XGBYUGBIUFRT/2wBDAQMEBAUEBQkFBQkUDQsNFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBT/wAARCAAgAEADASIAAhEBAxEB/8QAHwAAAQUBAQEBAQEAAAAAAAAAAAECAwQFBgcICQoL/8QAtRAAAgEDAwIEAwUFBAQAAAF9AQIDAAQRBRIhMUEGE1FhByJxFDKBkaEII0KxwRVS0fAkM2JyggkKFhcYGRolJicoKSo0NTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqDhIWGh4iJipKTlJWWl5iZmqKjpKWmp6ipqrKztLW2t7i5usLDxMXGx8jJytLT1NXW19jZ2uHi4+Tl5ufo6erx8vP09fb3+Pn6/8QAHwEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoL/8QAtREAAgECBAQDBAcFBAQAAQJ3AAECAxEEBSExBhJBUQdhcRMiMoEIFEKRobHBCSMzUvAVYnLRChYkNOEl8RcYGRomJygpKjU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6goOEhYaHiImKkpOUlZaXmJmaoqOkpaanqKmqsrO0tba3uLm6wsPExcbHyMnK0tPU1dbX2Nna4uPk5ebn6Onq8vP09fb3+Pn6/9oADAMBAAIRAxEAPwD50ooor8MP9UwooooA9uooor4I/wCcwKKKKAPhSiiiv+gM/qgKKKKAP3Vooor/AJdz+lQooooA/9k=`;
|
||||
|
||||
const RED: Rgb = [255, 0, 0];
|
||||
const GREEN: Rgb = [0, 200, 0];
|
||||
const BLUE: Rgb = [0, 0, 255];
|
||||
const YELLOW: Rgb = [255, 220, 0];
|
||||
|
||||
// Wide tolerance per channel, jpeg compression shifts solid colors a bit
|
||||
const COLOR_TOLERANCE = 70;
|
||||
|
||||
// 0.000512 megapixels is 512 pixels, a quarter of the area of the 2048 pixel fixtures
|
||||
const QUARTER_AREA_MEGAPIXELS = 0.000512;
|
||||
|
||||
type Rgb = [number, number, number];
|
||||
|
||||
function loadImage(dataUrl: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error('Failed to decode image.'));
|
||||
img.src = dataUrl;
|
||||
});
|
||||
}
|
||||
|
||||
// Decodes a data URL and samples the center of each quadrant of the picture
|
||||
async function quadrantColors(dataUrl: string): Promise<Rgb[]> {
|
||||
const img = await loadImage(dataUrl);
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.naturalWidth;
|
||||
canvas.height = img.naturalHeight;
|
||||
const ctx = canvas.getContext('2d')!;
|
||||
ctx.drawImage(img, 0, 0);
|
||||
const points = [
|
||||
[0.25, 0.25],
|
||||
[0.75, 0.25],
|
||||
[0.25, 0.75],
|
||||
[0.75, 0.75]
|
||||
];
|
||||
return points.map(([fx, fy]) => {
|
||||
const d = ctx.getImageData(
|
||||
Math.floor(canvas.width * fx),
|
||||
Math.floor(canvas.height * fy),
|
||||
1,
|
||||
1
|
||||
).data;
|
||||
return [d[0], d[1], d[2]];
|
||||
});
|
||||
}
|
||||
|
||||
function expectUpright(colors: Rgb[]) {
|
||||
const targets = [RED, GREEN, BLUE, YELLOW];
|
||||
for (let i = 0; i < 4; i++) {
|
||||
for (let c = 0; c < 3; c++) {
|
||||
expect(Math.abs(colors[i][c] - targets[i][c])).toBeLessThan(COLOR_TOLERANCE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('capImageDataURLSize orientation and capping', () => {
|
||||
it('passes upright jpegs through untouched when capping is disabled', async () => {
|
||||
expect(await capImageDataURLSize(EXIF1, 0)).toBe(EXIF1);
|
||||
expect(await capImageDataURLSize(NOEXIF, 0)).toBe(NOEXIF);
|
||||
});
|
||||
|
||||
it('passes upright jpegs through untouched when under the cap threshold', async () => {
|
||||
expect(await capImageDataURLSize(EXIF1, 1)).toBe(EXIF1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['orientation 3', EXIF3],
|
||||
['orientation 5', EXIF5],
|
||||
['orientation 6', EXIF6],
|
||||
['orientation 8', EXIF8]
|
||||
])('bakes %s into upright pixels without capping', async (_label, fixture) => {
|
||||
const result = await capImageDataURLSize(fixture, 0);
|
||||
|
||||
expect(result).not.toBe(fixture);
|
||||
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(64);
|
||||
expect(img.naturalHeight).toBe(32);
|
||||
expectUpright(await quadrantColors(result));
|
||||
|
||||
// The re-encoded jpeg carries no orientation tag anymore
|
||||
expect(getJpegOrientationFromDataURL(result)).toBe(1);
|
||||
});
|
||||
|
||||
it('caps and bakes the orientation in a single output', async () => {
|
||||
const result = await capImageDataURLSize(EXIF6, QUARTER_AREA_MEGAPIXELS);
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(32);
|
||||
expect(img.naturalHeight).toBe(16);
|
||||
expectUpright(await quadrantColors(result));
|
||||
expect(getJpegOrientationFromDataURL(result)).toBe(1);
|
||||
});
|
||||
|
||||
it('caps upright jpegs without disturbing the picture', async () => {
|
||||
const result = await capImageDataURLSize(EXIF1, QUARTER_AREA_MEGAPIXELS);
|
||||
const img = await loadImage(result);
|
||||
|
||||
expect(img.naturalWidth).toBe(32);
|
||||
expect(img.naturalHeight).toBe(16);
|
||||
expectUpright(await quadrantColors(result));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import McpServerForm from '$lib/components/app/mcp/McpServerForm.svelte';
|
||||
|
||||
interface Props {
|
||||
headers?: string;
|
||||
}
|
||||
|
||||
let { headers = '' }: Props = $props();
|
||||
|
||||
let headersState = $state(untrack(() => headers));
|
||||
let lastCapturedHeaders = $state(untrack(() => headers));
|
||||
|
||||
$effect(() => {
|
||||
if (headers !== lastCapturedHeaders) {
|
||||
headersState = headers;
|
||||
lastCapturedHeaders = headers;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Drives McpServerForm with a controlled `headers` string and exposes the
|
||||
latest captured value through `data-captured-headers` so the client test
|
||||
can read it back without a custom binding API.
|
||||
-->
|
||||
<McpServerForm
|
||||
url="https://example.test/mcp"
|
||||
headers={headersState}
|
||||
onUrlChange={() => {}}
|
||||
onHeadersChange={(value) => {
|
||||
headersState = value;
|
||||
}}
|
||||
id="mcp-server-form-test"
|
||||
/>
|
||||
|
||||
<div data-testid="captured-headers" data-captured-headers={headersState} hidden></div>
|
||||
@@ -0,0 +1,12 @@
|
||||
<script lang="ts">
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import Page from '../../../src/routes/(chat)/+page.svelte';
|
||||
</script>
|
||||
|
||||
<!--
|
||||
Test wrapper that provides necessary context providers for component testing.
|
||||
This mirrors the providers from +layout.svelte.
|
||||
-->
|
||||
<Tooltip.Provider>
|
||||
<Page />
|
||||
</Tooltip.Provider>
|
||||
@@ -0,0 +1,133 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import McpServerFormWrapper from './components/McpServerFormWrapper.svelte';
|
||||
|
||||
const AUTHORIZATION_HEADER = 'Authorization';
|
||||
const BEARER_PREFIX = 'Bearer ';
|
||||
const BEARER_PLACEHOLDER = 'Paste token here';
|
||||
|
||||
/**
|
||||
* Client-side tests for the McpServerForm bearer UI.
|
||||
*
|
||||
* The dedicated UI only "owns" Authorization headers that already carry a
|
||||
* Bearer scheme (heuristic check on the value). Other Authorization values
|
||||
* stay in the KV section so the user can still edit them verbatim. Storage
|
||||
* always goes through the same custom-headers slot, so a round-trip via this
|
||||
* UI produces exactly one `Authorization: Bearer <token>` entry.
|
||||
*
|
||||
* Equivalent parser coverage lives in `tests/unit/headers.test.ts`.
|
||||
*/
|
||||
describe('McpServerForm - Authorization / bearer UI', () => {
|
||||
function bearerInput(screen: Awaited<ReturnType<typeof render>>) {
|
||||
return screen.locator.getByPlaceholder(BEARER_PLACEHOLDER);
|
||||
}
|
||||
|
||||
function capturedHeaders(screen: Awaited<ReturnType<typeof render>>) {
|
||||
return screen.getByTestId('captured-headers');
|
||||
}
|
||||
|
||||
it('mounts with the bearer input hidden when no auth header is present', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: /server url/i })).toBeVisible();
|
||||
|
||||
await expect.element(bearerInput(screen)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggling Authorization shows the bearer input', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect.element(bearerInput(screen)).toBeVisible();
|
||||
});
|
||||
|
||||
it('typing a token writes the Authorization row with the Bearer prefix prepended', async () => {
|
||||
const screen = await render(McpServerFormWrapper, { headers: '' });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
const token = 'super-secret';
|
||||
await bearerInput(screen).fill(token);
|
||||
|
||||
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}${token}` });
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', expected);
|
||||
});
|
||||
|
||||
it('pre-existing Bearer header pre-fills the bearer input with the token stripped', async () => {
|
||||
const existing = JSON.stringify({
|
||||
'X-Trace-Id': 'abc',
|
||||
[AUTHORIZATION_HEADER]: `${BEARER_PREFIX}preexisting`
|
||||
});
|
||||
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await expect.element(bearerInput(screen)).toBeVisible();
|
||||
await expect.element(bearerInput(screen)).toHaveValue('preexisting');
|
||||
});
|
||||
|
||||
it('non-Bearer Authorization is ignored by the dedicated UI and stays in the KV section', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
|
||||
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await expect.element(bearerInput(screen)).not.toBeInTheDocument();
|
||||
|
||||
const headerKeyInput = screen.getByPlaceholder('Header name');
|
||||
await expect.element(headerKeyInput).toBeVisible();
|
||||
});
|
||||
|
||||
it('engaging the token UI replaces a non-Bearer Authorization with the Bearer scheme', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic old' });
|
||||
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
await bearerInput(screen).fill('new');
|
||||
|
||||
const expected = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}new` });
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', expected);
|
||||
});
|
||||
|
||||
it('toggling Authorization off with no token drops the Bearer row but keeps non-Bearer schemes', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
|
||||
});
|
||||
|
||||
it('toggling Authorization off when no Bearer row is present leaves headers untouched', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: 'Basic czNjcjpwYXNz' });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
await screen.getByRole('switch', { name: /authorization/i }).click();
|
||||
|
||||
await expect
|
||||
.element(capturedHeaders(screen))
|
||||
.toHaveAttribute('data-captured-headers', existing);
|
||||
});
|
||||
|
||||
it('clearing the bearer input drops the Authorization row', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
await bearerInput(screen).fill('');
|
||||
|
||||
await expect.element(capturedHeaders(screen)).toHaveAttribute('data-captured-headers', '');
|
||||
});
|
||||
|
||||
it('does not surface Bearer Authorization in the KV section even when pre-existing', async () => {
|
||||
const existing = JSON.stringify({ [AUTHORIZATION_HEADER]: `${BEARER_PREFIX}xyz` });
|
||||
const screen = await render(McpServerFormWrapper, { headers: existing });
|
||||
|
||||
const headerKeyInput = screen.getByPlaceholder('Header name');
|
||||
await expect.element(headerKeyInput).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render } from 'vitest-browser-svelte';
|
||||
import TestWrapper from './components/TestWrapper.svelte';
|
||||
|
||||
describe('/+page.svelte', () => {
|
||||
it('should render page without throwing', async () => {
|
||||
// Basic smoke test - page should render without throwing errors.
|
||||
// API calls are mocked in vitest-setup-client.ts.
|
||||
await render(TestWrapper);
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test.describe('PWA Service Worker', () => {
|
||||
test('service worker is registered', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
const swURL = await page.evaluate(async () => {
|
||||
const registration = await Promise.race([
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore - type inference differs from browser runtime
|
||||
navigator.serviceWorker.ready,
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Service worker registration failed: timeout')), 15000)
|
||||
)
|
||||
]);
|
||||
// @ts-expect-error registration is of type unknown
|
||||
return registration.active?.scriptURL;
|
||||
});
|
||||
|
||||
expect(swURL).toBeTruthy();
|
||||
expect(swURL).toContain('/sw.js');
|
||||
});
|
||||
|
||||
test('service worker has precache configured', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
await page.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
});
|
||||
|
||||
const swActive = await page.evaluate(async () => {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
return reg.active?.scriptURL ?? null;
|
||||
});
|
||||
|
||||
expect(swActive).toBeTruthy();
|
||||
|
||||
const swResponse = await page.request.get(swActive!);
|
||||
const swContent = await swResponse.text();
|
||||
|
||||
// Precache contains SvelteKit content-hashed bundle paths
|
||||
expect(swContent).toMatch(/"_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"/);
|
||||
expect(swContent).toMatch(/"_app\/immutable\/assets\/bundle\.[a-zA-Z0-9_-]+\.css"/);
|
||||
expect(swContent).toMatch(/"manifest\.webmanifest"/);
|
||||
expect(swContent).toMatch(/"_app\/version\.json"/);
|
||||
// NavigationRoute is intentionally absent — server API endpoints
|
||||
// (e.g. /slots, /models) must not be intercepted by the PWA and
|
||||
// should return JSON directly from the server.
|
||||
expect(swContent).not.toMatch(/NavigationRoute/);
|
||||
expect(swContent).toMatch(/api-cache/);
|
||||
});
|
||||
|
||||
test('offline mode - page loads when offline after caching', async ({ browser }) => {
|
||||
const context = await browser.newContext();
|
||||
const offlinePage = await context.newPage();
|
||||
|
||||
await offlinePage.goto('/');
|
||||
await offlinePage.waitForLoadState('networkidle');
|
||||
|
||||
await offlinePage.evaluate(async () => {
|
||||
await navigator.serviceWorker.ready;
|
||||
});
|
||||
|
||||
await offlinePage.waitForTimeout(2000);
|
||||
|
||||
await context.setOffline(true);
|
||||
await offlinePage.goto('/');
|
||||
|
||||
const bodyText = await offlinePage.locator('body').textContent();
|
||||
expect(bodyText).toBeTruthy();
|
||||
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('version.json is accessible and contains version', async ({ page }) => {
|
||||
const versionResponse = await page.request.get('/_app/version.json');
|
||||
expect(versionResponse.ok()).toBeTruthy();
|
||||
|
||||
const versionData = await versionResponse.json();
|
||||
expect(versionData).toHaveProperty('version');
|
||||
expect(typeof versionData.version).toBe('string');
|
||||
expect(versionData.version.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('manifest.webmanifest is accessible and valid', async ({ page }) => {
|
||||
const response = await page.request.get('/manifest.webmanifest');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const manifest = await response.json();
|
||||
expect(manifest).toHaveProperty('name', 'llama-ui');
|
||||
expect(manifest).toHaveProperty('short_name', 'llama-ui');
|
||||
expect(manifest).toHaveProperty('start_url', './');
|
||||
expect(manifest).toHaveProperty('display', 'standalone');
|
||||
expect(manifest.icons).toBeTruthy();
|
||||
expect(manifest.icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('index.html contains content-hashed bundle references', async ({ page }) => {
|
||||
const response = await page.request.get('/');
|
||||
expect(response.ok()).toBeTruthy();
|
||||
|
||||
const html = await response.text();
|
||||
|
||||
// SvelteKit outputs content-hashed bundle names in _app/immutable/
|
||||
expect(html).toMatch(/href="(\.\/|\/)_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"/);
|
||||
expect(html).toMatch(/href="(\.\/|\/)_app\/immutable\/assets\/bundle\.[a-zA-Z0-9_-]+\.css"/);
|
||||
expect(html).toMatch(/import\("(\.\/|\/)_app\/immutable\/bundle\.[a-zA-Z0-9_-]+\.js"\)/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,207 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatMessage from '$lib/components/app/chat/ChatMessages/ChatMessage/ChatMessage.svelte';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatMessage',
|
||||
component: ChatMessage,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
|
||||
// Mock messages for different scenarios
|
||||
const userMessage: DatabaseMessage = {
|
||||
id: '1',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 5,
|
||||
role: 'user',
|
||||
content: 'What is the meaning of life, the universe, and everything?',
|
||||
parent: '',
|
||||
thinking: '',
|
||||
children: []
|
||||
};
|
||||
|
||||
const assistantMessage: DatabaseMessage = {
|
||||
id: '2',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 3,
|
||||
role: 'assistant',
|
||||
content:
|
||||
'The answer to the ultimate question of life, the universe, and everything is **42**.\n\nThis comes from Douglas Adams\' "The Hitchhiker\'s Guide to the Galaxy," where a supercomputer named Deep Thought calculated this answer over 7.5 million years. However, the question itself was never properly formulated, which is why the answer seems meaningless without context.',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
};
|
||||
|
||||
const assistantWithReasoning: DatabaseMessage = {
|
||||
id: '3',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60 * 2,
|
||||
role: 'assistant',
|
||||
content: "Here's the concise answer, now that I've thought it through carefully for you.",
|
||||
parent: '1',
|
||||
thinking:
|
||||
"Let's consider the user's question step by step:\\n\\n1. Identify the core problem\\n2. Evaluate relevant information\\n3. Formulate a clear answer\\n\\nFollowing this process ensures the final response stays focused and accurate.",
|
||||
children: []
|
||||
};
|
||||
const rawOutputMessage: DatabaseMessage = {
|
||||
id: '6',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: Date.now() - 1000 * 60,
|
||||
role: 'assistant',
|
||||
content:
|
||||
'<|channel|>analysis<|message|>User greeted me. Initiating overcomplicated analysis: Is this a trap? No, just a normal hello. Respond calmly, act like a helpful assistant, and do not start explaining quantum physics again. Confidence 0.73. Engaging socially acceptable greeting protocol...<|end|>Hello there! How can I help you today?',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
};
|
||||
|
||||
let processingMessage = $state({
|
||||
id: '4',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: 0, // No timestamp = processing
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
});
|
||||
|
||||
let streamingMessage = $state({
|
||||
id: '5',
|
||||
convId: 'conv-1',
|
||||
type: 'message',
|
||||
timestamp: 0, // No timestamp = streaming
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
parent: '1',
|
||||
thinking: '',
|
||||
children: []
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="User"
|
||||
args={{
|
||||
message: userMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Assistant"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: assistantMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="AssistantWithReasoning"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: assistantWithReasoning
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="RawLlmOutput"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
message: rawOutputMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="WithReasoningContent"
|
||||
args={{
|
||||
message: streamingMessage
|
||||
}}
|
||||
asChild
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
// Phase 1: Stream reasoning content in chunks
|
||||
let reasoningText =
|
||||
'I need to think about this carefully. Let me break down the problem:\n\n1. The user is asking for help with something complex\n2. I should provide a thorough and helpful response\n3. I need to consider multiple approaches\n4. The best solution would be to explain step by step\n\nThis approach will ensure clarity and understanding.';
|
||||
|
||||
let reasoningChunk = 'I';
|
||||
let i = 0;
|
||||
while (i < reasoningText.length) {
|
||||
const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters
|
||||
const chunk = reasoningText.slice(i, i + chunkSize);
|
||||
reasoningChunk += chunk;
|
||||
|
||||
// Update the reactive state directly
|
||||
streamingMessage.thinking = reasoningChunk;
|
||||
|
||||
i += chunkSize;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
const regularText =
|
||||
"Based on my analysis, here's the solution:\n\n**Step 1:** First, we need to understand the requirements clearly.\n\n**Step 2:** Then we can implement the solution systematically.\n\n**Step 3:** Finally, we test and validate the results.\n\nThis approach ensures we cover all aspects of the problem effectively.";
|
||||
|
||||
let contentChunk = '';
|
||||
i = 0;
|
||||
|
||||
while (i < regularText.length) {
|
||||
const chunkSize = Math.floor(Math.random() * 5) + 3; // Random 3-7 characters
|
||||
const chunk = regularText.slice(i, i + chunkSize);
|
||||
contentChunk += chunk;
|
||||
|
||||
// Update the reactive state directly
|
||||
streamingMessage.content = contentChunk;
|
||||
|
||||
i += chunkSize;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
streamingMessage.timestamp = Date.now();
|
||||
}}
|
||||
>
|
||||
<div class="w-[56rem]">
|
||||
<ChatMessage message={streamingMessage} />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
name="Processing"
|
||||
args={{
|
||||
message: processingMessage
|
||||
}}
|
||||
play={async () => {
|
||||
const { settingsStore } = await import('$lib/stores/settings.svelte');
|
||||
settingsStore.updateConfig('showRawOutputSwitch', false);
|
||||
// Import the chat store to simulate loading state
|
||||
const { chatStore } = await import('$lib/stores/chat.svelte');
|
||||
|
||||
// Set loading state to true to trigger the processing UI
|
||||
chatStore.isLoading = true;
|
||||
|
||||
// Simulate the processing state hook behavior
|
||||
// This will show the "Generating..." text and parameter details
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,94 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
import jpgAsset from './fixtures/assets/1.jpg?url';
|
||||
import svgAsset from './fixtures/assets/hf-logo.svg?url';
|
||||
import pdfAsset from './fixtures/assets/example.pdf?raw';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatScreenForm',
|
||||
component: ChatScreenForm,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
|
||||
let fileAttachments = $state([
|
||||
{
|
||||
id: '1',
|
||||
name: '1.jpg',
|
||||
type: 'image/jpeg',
|
||||
size: 44891,
|
||||
preview: jpgAsset,
|
||||
file: new File([''], '1.jpg', { type: 'image/jpeg' })
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: 'hf-logo.svg',
|
||||
type: 'image/svg+xml',
|
||||
size: 1234,
|
||||
preview: svgAsset,
|
||||
file: new File([''], 'hf-logo.svg', { type: 'image/svg+xml' })
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: 'example.pdf',
|
||||
type: 'application/pdf',
|
||||
size: 351048,
|
||||
file: new File([pdfAsset], 'example.pdf', { type: 'application/pdf' })
|
||||
}
|
||||
]);
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const textarea = await canvas.findByRole('textbox');
|
||||
const submitButton = await canvas.findByRole('button', { name: 'Send' });
|
||||
|
||||
// Expect the input to be focused after the component is mounted
|
||||
await expect(textarea).toHaveFocus();
|
||||
|
||||
// Expect the submit button to be disabled
|
||||
await expect(submitButton).toBeDisabled();
|
||||
|
||||
const text = 'What is the meaning of life?';
|
||||
|
||||
await userEvent.clear(textarea);
|
||||
await userEvent.type(textarea, text);
|
||||
|
||||
await expect(textarea).toHaveValue(text);
|
||||
|
||||
const fileInput = document.querySelector('input[type="file"]');
|
||||
await expect(fileInput).not.toHaveAttribute('accept');
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story name="Loading" args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]', isLoading: true }} />
|
||||
|
||||
<Story
|
||||
name="FileAttachments"
|
||||
args={{
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]',
|
||||
uploadedFiles: fileAttachments
|
||||
}}
|
||||
play={async ({ canvas }) => {
|
||||
const jpgAttachment = canvas.getByAltText('1.jpg');
|
||||
const svgAttachment = canvas.getByAltText('hf-logo.svg');
|
||||
const pdfFileExtension = canvas.getByText('PDF');
|
||||
const pdfAttachment = canvas.getByText('example.pdf');
|
||||
const pdfSize = canvas.getByText('342.82 KB');
|
||||
|
||||
await expect(jpgAttachment).toBeInTheDocument();
|
||||
await expect(jpgAttachment).toHaveAttribute('src', jpgAsset);
|
||||
|
||||
await expect(svgAttachment).toBeInTheDocument();
|
||||
await expect(svgAttachment).toHaveAttribute('src', svgAsset);
|
||||
|
||||
await expect(pdfFileExtension).toBeInTheDocument();
|
||||
await expect(pdfAttachment).toBeInTheDocument();
|
||||
await expect(pdfSize).toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Meta } from '@storybook/addon-docs/blocks';
|
||||
|
||||
<Meta title="Introduction" />
|
||||
|
||||
# llama.cpp Web UI
|
||||
|
||||
Welcome to the **llama-ui** component library! This Storybook showcases the components used in the modern web interface for the llama-server.
|
||||
|
||||
## 🚀 About This Project
|
||||
|
||||
Llama UI is a modern web interface for the llama-server, built with SvelteKit and ShadCN UI. Features include:
|
||||
|
||||
- **Real-time chat conversations** with AI assistants
|
||||
- **Multi-conversation management** with persistent storage
|
||||
- **Advanced parameter tuning** for model behavior
|
||||
- **File upload support** for multimodal interactions
|
||||
- **Responsive design** that works on desktop and mobile
|
||||
|
||||
## 🎨 Design System
|
||||
|
||||
The UI is built using:
|
||||
|
||||
- **SvelteKit** - Modern web framework with excellent performance
|
||||
- **Tailwind CSS** - Utility-first CSS framework for rapid styling
|
||||
- **ShadCN/UI** - High-quality, accessible component library
|
||||
- **Lucide Icons** - Beautiful, consistent icon set
|
||||
|
||||
## 🔧 Development
|
||||
|
||||
This Storybook serves as both documentation and a development environment for the UI components. Each story demonstrates:
|
||||
|
||||
- **Component variations** - Different states and configurations
|
||||
- **Interactive examples** - Live components you can interact with
|
||||
- **Usage patterns** - How components work together
|
||||
- **Styling consistency** - Unified design language
|
||||
|
||||
## 🚀 Getting Started
|
||||
|
||||
To explore the components:
|
||||
|
||||
1. **Browse the sidebar** to see all available components
|
||||
2. **Click on stories** to see different component states
|
||||
3. **Use the controls panel** to interact with component props
|
||||
4. **Check the docs tab** for detailed component information
|
||||
@@ -0,0 +1,132 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { expect } from 'storybook/test';
|
||||
import { MarkdownContent } from '$lib/components/app';
|
||||
import { AI_TUTORIAL_MD } from './fixtures/ai-tutorial.js';
|
||||
import { API_DOCS_MD } from './fixtures/api-docs.js';
|
||||
import { BLOG_POST_MD } from './fixtures/blog-post.js';
|
||||
import { DATA_ANALYSIS_MD } from './fixtures/data-analysis.js';
|
||||
import { README_MD } from './fixtures/readme.js';
|
||||
import { MATH_FORMULAS_MD } from './fixtures/math-formulas.js';
|
||||
import { EMPTY_MD } from './fixtures/empty.js';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/MarkdownContent',
|
||||
component: MarkdownContent,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story name="Empty" args={{ content: EMPTY_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }} />
|
||||
|
||||
<Story
|
||||
name="AI Tutorial"
|
||||
args={{ content: AI_TUTORIAL_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="API Documentation"
|
||||
args={{ content: API_DOCS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Technical Blog"
|
||||
args={{ content: BLOG_POST_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Data Analysis"
|
||||
args={{ content: DATA_ANALYSIS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="README file"
|
||||
args={{ content: README_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Math Formulas"
|
||||
args={{ content: MATH_FORMULAS_MD, class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="URL Links"
|
||||
args={{
|
||||
content: `# URL Links Test
|
||||
|
||||
Here are some example URLs that should open in new tabs:
|
||||
|
||||
- [Hugging Face Homepage](https://huggingface.co)
|
||||
- [GitHub Repository](https://github.com/ggml-org/llama.cpp)
|
||||
- [OpenAI Website](https://openai.com)
|
||||
- [Google Search](https://www.google.com)
|
||||
|
||||
You can also test inline links like https://example.com or https://docs.python.org.
|
||||
|
||||
All links should have \`target="_blank"\` and \`rel="noopener noreferrer"\` attributes for security.`,
|
||||
class: 'max-w-[56rem] w-[calc(100vw-2rem)]'
|
||||
}}
|
||||
play={async (context) => {
|
||||
const { canvasElement } = context;
|
||||
// Wait for component to render
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Find all links in the rendered content
|
||||
const links = (canvasElement as HTMLElement).querySelectorAll(
|
||||
'a[href]'
|
||||
) as NodeListOf<HTMLAnchorElement>;
|
||||
const linkList = Array.from(links) as HTMLAnchorElement[];
|
||||
|
||||
// Test that we have the expected number of links
|
||||
expect(links.length).toBeGreaterThan(0);
|
||||
|
||||
// Test each link for proper attributes
|
||||
links.forEach((link: HTMLAnchorElement) => {
|
||||
const href = link.getAttribute('href');
|
||||
|
||||
// Test that external links have proper security attributes
|
||||
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
|
||||
expect(link.getAttribute('target')).toBe('_blank');
|
||||
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
||||
}
|
||||
});
|
||||
|
||||
// Test specific links exist
|
||||
const hugginFaceLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://huggingface.co'
|
||||
);
|
||||
expect(hugginFaceLink).toBeTruthy();
|
||||
expect(hugginFaceLink?.textContent).toBe('Hugging Face Homepage');
|
||||
|
||||
const githubLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://github.com/ggml-org/llama.cpp'
|
||||
);
|
||||
expect(githubLink).toBeTruthy();
|
||||
expect(githubLink?.textContent).toBe('GitHub Repository');
|
||||
|
||||
const openaiLink = linkList.find((link) => link.getAttribute('href') === 'https://openai.com');
|
||||
expect(openaiLink).toBeTruthy();
|
||||
expect(openaiLink?.textContent).toBe('OpenAI Website');
|
||||
|
||||
const googleLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://www.google.com'
|
||||
);
|
||||
expect(googleLink).toBeTruthy();
|
||||
expect(googleLink?.textContent).toBe('Google Search');
|
||||
|
||||
// Test inline links (auto-linked URLs)
|
||||
const exampleLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://example.com'
|
||||
);
|
||||
expect(exampleLink).toBeTruthy();
|
||||
|
||||
const pythonDocsLink = linkList.find(
|
||||
(link) => link.getAttribute('href') === 'https://docs.python.org'
|
||||
);
|
||||
expect(pythonDocsLink).toBeTruthy();
|
||||
|
||||
console.log(`✅ URL Links test passed - Found ${links.length} links with proper attributes`);
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,269 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ModelsSelectorList from '$lib/components/app/models/ModelsSelectorList.svelte';
|
||||
import ModelsSelectorOption from '$lib/components/app/models/ModelsSelectorOption.svelte';
|
||||
import type { GroupedModelOptions, ModelItem } from '$lib/components/app/models/utils';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
import { ServerModelStatus } from '$lib/enums';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ModelsSelector',
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
|
||||
const mockModel = (id: string, name: string, orgName?: string, tags?: string[]): ModelOption => ({
|
||||
id,
|
||||
name,
|
||||
model: orgName ? `${orgName}/${name}` : name,
|
||||
capabilities: [],
|
||||
parsedId: {
|
||||
raw: orgName ? `${orgName}/${name}` : name,
|
||||
orgName: orgName ?? null,
|
||||
modelName: name,
|
||||
params: null,
|
||||
activatedParams: null,
|
||||
quantization: null,
|
||||
tags: tags ?? []
|
||||
},
|
||||
tags
|
||||
});
|
||||
|
||||
const mockRouterEntry = (modelName: string, status: ServerModelStatus): ApiModelDataEntry => ({
|
||||
id: modelName,
|
||||
object: 'model',
|
||||
owned_by: 'llamacpp',
|
||||
created: Date.now(),
|
||||
in_cache: true,
|
||||
path: `/models/${modelName}`,
|
||||
status: { value: status }
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
let selectedModel = $state<string | null>(null);
|
||||
let activeId = $state<string | null>(null);
|
||||
|
||||
function mockModelsStore() {
|
||||
modelsStore.favoriteModelIds = new Set(['qwen2.5-7b', 'llama3.2-3b']);
|
||||
|
||||
// Mock router models with various statuses for ModelLoadedStates story
|
||||
modelsStore.routerModels = [
|
||||
mockRouterEntry('meta/Model (loading)', ServerModelStatus.LOADING),
|
||||
mockRouterEntry('meta/Model (loaded)', ServerModelStatus.LOADED),
|
||||
mockRouterEntry('meta/Model (sleeping)', ServerModelStatus.SLEEPING),
|
||||
mockRouterEntry('meta/Model (failed)', ServerModelStatus.FAILED)
|
||||
];
|
||||
}
|
||||
|
||||
mockModelsStore();
|
||||
|
||||
const loadedModels: ModelItem[] = [
|
||||
{ option: mockModel('llama3.1-8b', 'Llama-3.1-8B-Instruct', 'meta'), flatIndex: 0 },
|
||||
{ option: mockModel('mistral-7b', 'Mistral-7B-v0.3', 'mistralai'), flatIndex: 1 }
|
||||
];
|
||||
|
||||
const favoriteModels: ModelItem[] = [
|
||||
{ option: mockModel('qwen2.5-7b', 'Qwen2.5-7B-Instruct', 'Qwen'), flatIndex: 2 },
|
||||
{ option: mockModel('llama3.2-3b', 'Llama-3.2-3B-Instruct', 'meta'), flatIndex: 3 }
|
||||
];
|
||||
|
||||
const availableModels: ModelItem[] = [
|
||||
{
|
||||
option: mockModel('deepseek-coder-6.7b', 'DeepSeek-Coder-6.7B', 'deepseek', ['coding']),
|
||||
flatIndex: 4
|
||||
},
|
||||
{ option: mockModel('gemma-2-9b', 'Gemma-2-9B-IT', 'google'), flatIndex: 5 },
|
||||
{ option: mockModel('phi-3-mini', 'Phi-3-mini-4k', 'microsoft'), flatIndex: 6 },
|
||||
{ option: mockModel('codellama-7b', 'CodeLlama-7B', 'codellama', ['coding']), flatIndex: 7 },
|
||||
{ option: mockModel('neural-chat-7b', 'Neural-Chat-7B-v3-3', 'intel'), flatIndex: 8 }
|
||||
];
|
||||
|
||||
const groupedOptions: GroupedModelOptions = {
|
||||
loaded: loadedModels,
|
||||
favorites: favoriteModels,
|
||||
available: [
|
||||
{
|
||||
orgName: 'deepseek',
|
||||
items: [availableModels[0]]
|
||||
},
|
||||
{
|
||||
orgName: 'google',
|
||||
items: [availableModels[1]]
|
||||
},
|
||||
{
|
||||
orgName: 'microsoft',
|
||||
items: [availableModels[2]]
|
||||
},
|
||||
{
|
||||
orgName: 'codellama',
|
||||
items: [availableModels[3]]
|
||||
},
|
||||
{
|
||||
orgName: 'intel',
|
||||
items: [availableModels[4]]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
function handleSelect(modelId: string) {
|
||||
const opt = [...loadedModels, ...favoriteModels, ...availableModels].find(
|
||||
(m) => m.option.id === modelId
|
||||
);
|
||||
if (opt) {
|
||||
selectedModel = opt.option.model;
|
||||
activeId = modelId;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<Story name="List">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
groups={groupedOptions}
|
||||
currentModel={selectedModel}
|
||||
{activeId}
|
||||
onSelect={handleSelect}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="SingleLoaded">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
groups={{
|
||||
loaded: [loadedModels[0]],
|
||||
favorites: [],
|
||||
available: []
|
||||
}}
|
||||
currentModel={null}
|
||||
activeId={null}
|
||||
onSelect={handleSelect}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="WithFavoritesOnly">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<ModelsSelectorList
|
||||
groups={{
|
||||
loaded: [],
|
||||
favorites: favoriteModels,
|
||||
available: []
|
||||
}}
|
||||
currentModel={null}
|
||||
activeId={null}
|
||||
onSelect={handleSelect}
|
||||
onInfoClick={(modelName) => console.log('Info clicked:', modelName)}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="ModelLoadedStates">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<div class="px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none">
|
||||
Server model states
|
||||
</div>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('model-idle', 'Model (idle)', 'meta')}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('model-loading', 'Model (loading)', 'meta')}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('model-loaded', 'Model (loaded)', 'meta')}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('model-sleeping', 'Model (sleeping)', 'meta')}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('model-failed', 'Model (failed)', 'meta')}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story name="ModelSelectedStates">
|
||||
<div class="w-80 rounded-lg border border-border bg-popover p-2 shadow-md">
|
||||
<div class="px-2 py-2 text-[13px] font-semibold text-muted-foreground/70 select-none">
|
||||
Selection states
|
||||
</div>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('normal-model', 'Normal Model', 'meta')}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('selected-model', 'Selected Model', 'meta')}
|
||||
isSelected={true}
|
||||
isHighlighted={false}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('highlighted-model', 'Highlighted Model', 'meta')}
|
||||
isSelected={false}
|
||||
isHighlighted={true}
|
||||
isFav={false}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
<ModelsSelectorOption
|
||||
option={mockModel('fav-model', 'Favorite Model', 'Qwen')}
|
||||
isSelected={false}
|
||||
isHighlighted={false}
|
||||
isFav={true}
|
||||
hideOrgName={true}
|
||||
onSelect={() => {}}
|
||||
onMouseEnter={() => {}}
|
||||
onKeyDown={() => {}}
|
||||
/>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import PwaRefreshAlert from '$lib/components/pwa/PwaRefreshAlert.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/PwaRefreshAlert',
|
||||
component: PwaRefreshAlert,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="Default"
|
||||
args={{ needRefresh: true, updateServiceWorker: () => console.log('reload') }}
|
||||
play={async ({ canvas }) => {
|
||||
const title = canvas.getByText('Update available');
|
||||
await expect(title).toBeInTheDocument();
|
||||
|
||||
const description = canvas.getByText(/A new version is available/);
|
||||
await expect(description).toBeInTheDocument();
|
||||
|
||||
const button = canvas.getByRole('button', { name: 'Reload' });
|
||||
await expect(button).toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="Hidden"
|
||||
args={{ needRefresh: false, updateServiceWorker: () => console.log('reload') }}
|
||||
play={async ({ canvas }) => {
|
||||
const title = canvas.queryByText('Update available');
|
||||
await expect(title).not.toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="ClickReload"
|
||||
args={{
|
||||
needRefresh: true,
|
||||
updateServiceWorker: () => console.log('reload')
|
||||
}}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const button = canvas.getByRole('button', { name: 'Reload' });
|
||||
await expect(button).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(button);
|
||||
|
||||
const title = canvas.queryByText('Update available');
|
||||
await expect(title).not.toBeInTheDocument();
|
||||
|
||||
const reloadBtn = canvas.queryByRole('button', { name: 'Reload' });
|
||||
await expect(reloadBtn).not.toBeInTheDocument();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import SidebarNavigation from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigation.svelte';
|
||||
import { waitFor } from 'storybook/test';
|
||||
import { screen } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/SidebarNavigation',
|
||||
component: SidebarNavigation,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
// Mock conversations for the sidebar
|
||||
const mockConversations: DatabaseConversation[] = [
|
||||
{
|
||||
id: 'conv-1',
|
||||
name: 'Getting Started with AI',
|
||||
lastModified: Date.now() - 1000 * 60 * 5, // 5 minutes ago
|
||||
currNode: 'msg-1'
|
||||
},
|
||||
{
|
||||
id: 'conv-2',
|
||||
name: 'Python Programming Help',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 2, // 2 hours ago
|
||||
currNode: 'msg-2'
|
||||
},
|
||||
{
|
||||
id: 'conv-3',
|
||||
name: 'Creative Writing Ideas',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24, // 1 day ago
|
||||
currNode: 'msg-3'
|
||||
},
|
||||
{
|
||||
id: 'conv-4',
|
||||
name: 'This is a very long conversation title that should be truncated properly when displayed',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 3, // 3 days ago
|
||||
currNode: 'msg-4'
|
||||
},
|
||||
{
|
||||
id: 'conv-5',
|
||||
name: 'Math Problem Solving',
|
||||
lastModified: Date.now() - 1000 * 60 * 60 * 24 * 7, // 1 week ago
|
||||
currNode: 'msg-5'
|
||||
}
|
||||
];
|
||||
</script>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="Default"
|
||||
play={async () => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
waitFor(() =>
|
||||
setTimeout(() => {
|
||||
conversationsStore.conversations = mockConversations;
|
||||
}, 0)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<div class="flex-column h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="SearchActive"
|
||||
play={async ({ userEvent }) => {
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
|
||||
waitFor(() =>
|
||||
setTimeout(() => {
|
||||
conversationsStore.conversations = mockConversations;
|
||||
}, 0)
|
||||
);
|
||||
|
||||
// Expand sidebar first, then click Search in the expanded button list
|
||||
const logoTrigger = screen.getByRole('button', { name: /expand navigation/i });
|
||||
await userEvent.click(logoTrigger);
|
||||
const searchTrigger = screen.getByText('Search');
|
||||
userEvent.click(searchTrigger);
|
||||
}}
|
||||
>
|
||||
<div class="flex-column h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="Empty"
|
||||
play={async () => {
|
||||
// Mock empty conversations store
|
||||
const { conversationsStore } = await import('$lib/stores/conversations.svelte');
|
||||
conversationsStore.conversations = [];
|
||||
}}
|
||||
>
|
||||
<div class="flex-column h-screen w-72 bg-background">
|
||||
<SidebarNavigation />
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,34 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import { Copy } from '@lucide/svelte';
|
||||
import ActionIcon from '$lib/components/app/actions/ActionIcon.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ActionIcon/Accessibility',
|
||||
component: ActionIcon,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="SingleTabStop"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const before = await canvas.findByRole('button', { name: 'before' });
|
||||
const target = await canvas.findByRole('button', { name: 'Copy' });
|
||||
|
||||
before.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(target).toHaveFocus();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
<ActionIcon icon={Copy} tooltip="Copy" onclick={() => {}} />
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatMessageStatistics from '$lib/components/app/chat/ChatMessages/ChatMessageStatistics/ChatMessageStatistics.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatMessageStatistics/Accessibility',
|
||||
component: ChatMessageStatistics,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="ViewButtonsSingleTabStop"
|
||||
args={{
|
||||
promptTokens: 100,
|
||||
promptMs: 500,
|
||||
predictedTokens: 200,
|
||||
predictedMs: 1000,
|
||||
agenticTimings: {
|
||||
turns: 1,
|
||||
toolCallsCount: 1,
|
||||
toolsMs: 500,
|
||||
llm: { predicted_n: 200, predicted_ms: 1000, prompt_n: 100, prompt_ms: 500 }
|
||||
},
|
||||
hideSummary: false,
|
||||
isLive: false
|
||||
}}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const reading = await canvas.findByRole('button', { name: 'Reading' });
|
||||
const generation = await canvas.findByRole('button', { name: 'Generation' });
|
||||
const tools = await canvas.findByRole('button', { name: 'Tools' });
|
||||
const summary = await canvas.findByRole('button', { name: 'Summary' });
|
||||
|
||||
reading.focus();
|
||||
await expect(reading).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
await expect(generation).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
await expect(tools).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
await expect(summary).toHaveFocus();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import ChatScreenForm from '$lib/components/app/chat/ChatScreen/ChatScreenForm.svelte';
|
||||
import { expect, screen, waitFor } from 'storybook/test';
|
||||
import { ATTACHMENT_TOOLTIP_TEXT } from '$lib/constants';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/ChatScreen/ChatScreenForm/Accessibility',
|
||||
component: ChatScreenForm,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="AddButtonSingleTabStop"
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const textarea = await canvas.findByRole('textbox');
|
||||
await userEvent.clear(textarea);
|
||||
await userEvent.type(textarea, 'What is the meaning of life?');
|
||||
|
||||
const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
|
||||
|
||||
trigger.focus();
|
||||
await expect(trigger).toHaveFocus();
|
||||
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(trigger).not.toHaveFocus();
|
||||
}}
|
||||
/>
|
||||
|
||||
<Story
|
||||
name="AddDropdownFocusesFirstEnabled"
|
||||
args={{ class: 'max-w-[56rem] w-[calc(100vw-2rem)]' }}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const trigger = await canvas.findByRole('button', { name: ATTACHMENT_TOOLTIP_TEXT });
|
||||
|
||||
trigger.focus();
|
||||
await userEvent.keyboard('{Enter}');
|
||||
await screen.findByRole('menu');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.activeElement).toHaveTextContent('Add files');
|
||||
});
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,69 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import HorizontalScrollCarousel from '$lib/components/app/misc/HorizontalScrollCarousel.svelte';
|
||||
import { expect, waitFor } from 'storybook/test';
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/HorizontalScrollCarousel/Accessibility',
|
||||
component: HorizontalScrollCarousel,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="ArrowsNotInTabOrderWhenNotScrollable"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const before = await canvas.findByRole('button', { name: 'before' });
|
||||
const after = await canvas.findByRole('button', { name: 'after' });
|
||||
const leftArrow = await canvas.findByRole('button', { name: 'Scroll left' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(leftArrow).toBeDisabled();
|
||||
});
|
||||
|
||||
before.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(after).toHaveFocus();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
<HorizontalScrollCarousel class="w-96">
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
<div class="h-12 w-12 shrink-0 bg-muted"></div>
|
||||
</HorizontalScrollCarousel>
|
||||
<button type="button">after</button>
|
||||
</div>
|
||||
</Story>
|
||||
|
||||
<Story
|
||||
asChild
|
||||
name="ArrowsInTabOrderWhenScrollable"
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const before = await canvas.findByRole('button', { name: 'before' });
|
||||
const rightArrow = await canvas.findByRole('button', { name: 'Scroll right' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(rightArrow).not.toBeDisabled();
|
||||
});
|
||||
|
||||
before.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(rightArrow).toHaveFocus();
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<button type="button">before</button>
|
||||
<HorizontalScrollCarousel class="w-48">
|
||||
{#each [...Array(20).keys()] as i (i)}
|
||||
<div class="h-12 w-24 shrink-0 bg-muted">{i}</div>
|
||||
{/each}
|
||||
</HorizontalScrollCarousel>
|
||||
</div>
|
||||
</Story>
|
||||
@@ -0,0 +1,36 @@
|
||||
<script module lang="ts">
|
||||
import { defineMeta } from '@storybook/addon-svelte-csf';
|
||||
import SidebarNavigationConversationItem from '$lib/components/app/navigation/SidebarNavigation/SidebarNavigationConversationItem.svelte';
|
||||
import { expect } from 'storybook/test';
|
||||
|
||||
const mockForkedConversation: DatabaseConversation = {
|
||||
id: 'conv-2',
|
||||
name: 'Forked Conversation',
|
||||
lastModified: Date.now(),
|
||||
currNode: 'msg-2',
|
||||
forkedFromConversationId: 'conv-1'
|
||||
};
|
||||
|
||||
const { Story } = defineMeta({
|
||||
title: 'Components/SidebarNavigationConversationItem/Accessibility',
|
||||
component: SidebarNavigationConversationItem,
|
||||
parameters: {
|
||||
layout: 'centered'
|
||||
},
|
||||
tags: ['!dev']
|
||||
});
|
||||
</script>
|
||||
|
||||
<Story
|
||||
name="ForkIconSingleTabStop"
|
||||
args={{ conversation: mockForkedConversation, depth: 1 }}
|
||||
play={async ({ canvas, userEvent }) => {
|
||||
const row = await canvas.findByRole('button', { name: /Forked Conversation/ });
|
||||
const forkIcon = await canvas.findByRole('link');
|
||||
|
||||
row.focus();
|
||||
await userEvent.tab();
|
||||
|
||||
await expect(forkIcon).toHaveFocus();
|
||||
}}
|
||||
/>
|
||||
@@ -0,0 +1,164 @@
|
||||
// AI Assistant Tutorial Response
|
||||
export const AI_TUTORIAL_MD = String.raw`
|
||||
# Building a Modern Chat Application with SvelteKit
|
||||
|
||||
I'll help you create a **production-ready chat application** using SvelteKit, TypeScript, and WebSockets. This implementation includes real-time messaging, user authentication, and message persistence.
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
First, let's set up the project:
|
||||
|
||||
${'```'}bash
|
||||
npm create svelte@latest chat-app
|
||||
cd chat-app
|
||||
npm install
|
||||
npm install socket.io socket.io-client
|
||||
npm install @prisma/client prisma
|
||||
npm run dev
|
||||
${'```'}
|
||||
|
||||
## 📁 Project Structure
|
||||
|
||||
${'```'}
|
||||
chat-app/
|
||||
├── src/
|
||||
│ ├── routes/
|
||||
│ │ ├── +layout.svelte
|
||||
│ │ ├── +page.svelte
|
||||
│ │ └── api/
|
||||
│ │ └── socket/+server.ts
|
||||
│ ├── lib/
|
||||
│ │ ├── components/
|
||||
│ │ │ ├── ChatMessage.svelte
|
||||
│ │ │ └── ChatInput.svelte
|
||||
│ │ └── stores/
|
||||
│ │ └── chat.ts
|
||||
│ └── app.html
|
||||
├── prisma/
|
||||
│ └── schema.prisma
|
||||
└── package.json
|
||||
${'```'}
|
||||
|
||||
## 💻 Implementation
|
||||
|
||||
### WebSocket Server
|
||||
|
||||
${'```'}typescript
|
||||
// src/lib/server/socket.ts
|
||||
import { Server } from 'socket.io';
|
||||
import type { ViteDevServer } from 'vite';
|
||||
|
||||
export function initializeSocketIO(server: ViteDevServer) {
|
||||
const io = new Server(server.httpServer || server, {
|
||||
cors: {
|
||||
origin: process.env.ORIGIN || 'http://localhost:5173',
|
||||
credentials: true
|
||||
}
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
console.log('User connected:', socket.id);
|
||||
|
||||
socket.on('message', async (data) => {
|
||||
// Broadcast to all clients
|
||||
io.emit('new-message', {
|
||||
id: crypto.randomUUID(),
|
||||
userId: socket.id,
|
||||
content: data.content,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('User disconnected:', socket.id);
|
||||
});
|
||||
});
|
||||
|
||||
return io;
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Client Store
|
||||
|
||||
${'```'}typescript
|
||||
// src/lib/stores/chat.ts
|
||||
import { writable } from 'svelte/store';
|
||||
import io from 'socket.io-client';
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
userId: string;
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
function createChatStore() {
|
||||
const { subscribe, update } = writable<Message[]>([]);
|
||||
let socket: ReturnType<typeof io>;
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
connect: () => {
|
||||
socket = io('http://localhost:5173');
|
||||
|
||||
socket.on('new-message', (message: Message) => {
|
||||
update(messages => [...messages, message]);
|
||||
});
|
||||
},
|
||||
sendMessage: (content: string) => {
|
||||
if (socket && content.trim()) {
|
||||
socket.emit('message', { content });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const chatStore = createChatStore();
|
||||
${'```'}
|
||||
|
||||
## 🎯 Key Features
|
||||
|
||||
✅ **Real-time messaging** with WebSockets
|
||||
✅ **Message persistence** using Prisma + PostgreSQL
|
||||
✅ **Type-safe** with TypeScript
|
||||
✅ **Responsive UI** for all devices
|
||||
✅ **Auto-reconnection** on connection loss
|
||||
|
||||
## 📊 Performance Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| **Message Latency** | < 50ms |
|
||||
| **Concurrent Users** | 10,000+ |
|
||||
| **Messages/Second** | 5,000+ |
|
||||
| **Uptime** | 99.9% |
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Environment Variables
|
||||
|
||||
${'```'}env
|
||||
DATABASE_URL="postgresql://user:password@localhost:5432/chat"
|
||||
JWT_SECRET="your-secret-key"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
${'```'}
|
||||
|
||||
## 🚢 Deployment
|
||||
|
||||
Deploy to production using Docker:
|
||||
|
||||
${'```'}dockerfile
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci --only=production
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build"]
|
||||
${'```'}
|
||||
|
||||
---
|
||||
|
||||
*Need help? Check the [documentation](https://kit.svelte.dev) or [open an issue](https://github.com/sveltejs/kit/issues)*
|
||||
`;
|
||||
@@ -0,0 +1,160 @@
|
||||
// API Documentation
|
||||
export const API_DOCS_MD = String.raw`
|
||||
# REST API Documentation
|
||||
|
||||
## 🔐 Authentication
|
||||
|
||||
All API requests require authentication using **Bearer tokens**. Include your API key in the Authorization header:
|
||||
|
||||
${'```'}http
|
||||
GET /api/v1/users
|
||||
Host: api.example.com
|
||||
Authorization: Bearer YOUR_API_KEY
|
||||
Content-Type: application/json
|
||||
${'```'}
|
||||
|
||||
## 📍 Endpoints
|
||||
|
||||
### Users API
|
||||
|
||||
#### **GET** /api/v1/users
|
||||
|
||||
Retrieve a paginated list of users.
|
||||
|
||||
**Query Parameters:**
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| page | integer | 1 | Page number |
|
||||
| limit | integer | 20 | Items per page |
|
||||
| sort | string | "created_at" | Sort field |
|
||||
| order | string | "desc" | Sort order |
|
||||
|
||||
**Response:** 200 OK
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"id": "usr_1234567890",
|
||||
"email": "user@example.com",
|
||||
"name": "John Doe",
|
||||
"role": "admin",
|
||||
"created_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"page": 1,
|
||||
"limit": 20,
|
||||
"total": 156,
|
||||
"pages": 8
|
||||
}
|
||||
}
|
||||
${'```'}
|
||||
|
||||
#### **POST** /api/v1/users
|
||||
|
||||
Create a new user account.
|
||||
|
||||
**Request Body:**
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"email": "newuser@example.com",
|
||||
"password": "SecurePassword123!",
|
||||
"name": "Jane Smith",
|
||||
"role": "user"
|
||||
}
|
||||
${'```'}
|
||||
|
||||
**Response:** 201 Created
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"id": "usr_9876543210",
|
||||
"email": "newuser@example.com",
|
||||
"name": "Jane Smith",
|
||||
"role": "user",
|
||||
"created_at": "2024-01-21T09:15:00Z"
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Error Responses
|
||||
|
||||
The API returns errors in a consistent format:
|
||||
|
||||
${'```'}json
|
||||
{
|
||||
"error": {
|
||||
"code": "VALIDATION_ERROR",
|
||||
"message": "Invalid request parameters",
|
||||
"details": [
|
||||
{
|
||||
"field": "email",
|
||||
"message": "Email format is invalid"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
| Tier | Requests/Hour | Burst |
|
||||
|------|--------------|-------|
|
||||
| **Free** | 1,000 | 100 |
|
||||
| **Pro** | 10,000 | 500 |
|
||||
| **Enterprise** | Unlimited | - |
|
||||
|
||||
**Headers:**
|
||||
- X-RateLimit-Limit
|
||||
- X-RateLimit-Remaining
|
||||
- X-RateLimit-Reset
|
||||
|
||||
### Webhooks
|
||||
|
||||
Configure webhooks to receive real-time events:
|
||||
|
||||
${'```'}javascript
|
||||
// Webhook payload
|
||||
{
|
||||
"event": "user.created",
|
||||
"timestamp": "2024-01-21T09:15:00Z",
|
||||
"data": {
|
||||
"id": "usr_9876543210",
|
||||
"email": "newuser@example.com"
|
||||
},
|
||||
"signature": "sha256=abcd1234..."
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### SDK Examples
|
||||
|
||||
**JavaScript/TypeScript:**
|
||||
|
||||
${'```'}typescript
|
||||
import { ApiClient } from '@example/api-sdk';
|
||||
|
||||
const client = new ApiClient({
|
||||
apiKey: process.env.API_KEY
|
||||
});
|
||||
|
||||
const users = await client.users.list({
|
||||
page: 1,
|
||||
limit: 20
|
||||
});
|
||||
${'```'}
|
||||
|
||||
**Python:**
|
||||
|
||||
${'```'}python
|
||||
from example_api import Client
|
||||
|
||||
client = Client(api_key=os.environ['API_KEY'])
|
||||
users = client.users.list(page=1, limit=20)
|
||||
${'```'}
|
||||
|
||||
---
|
||||
|
||||
📚 [Full API Reference](https://api.example.com/docs) | 💬 [Support](https://support.example.com)
|
||||
`;
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 798 KiB |
Binary file not shown.
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 34 KiB |
@@ -0,0 +1,125 @@
|
||||
// Blog Post Content
|
||||
export const BLOG_POST_MD = String.raw`
|
||||
# Understanding Rust's Ownership System
|
||||
|
||||
*Published on March 15, 2024 • 8 min read*
|
||||
|
||||
Rust's ownership system is one of its most distinctive features, enabling memory safety without garbage collection. In this post, we'll explore how ownership works and why it's revolutionary for systems programming.
|
||||
|
||||
## What is Ownership?
|
||||
|
||||
Ownership is a set of rules that governs how Rust manages memory. These rules are checked at compile time, ensuring memory safety without runtime overhead.
|
||||
|
||||
### The Three Rules of Ownership
|
||||
|
||||
1. **Each value has a single owner**
|
||||
2. **There can only be one owner at a time**
|
||||
3. **When the owner goes out of scope, the value is dropped**
|
||||
|
||||
## Memory Management Without GC
|
||||
|
||||
Traditional approaches to memory management:
|
||||
|
||||
- **Manual management** (C/C++): Error-prone, leads to bugs
|
||||
- **Garbage collection** (Java, Python): Runtime overhead
|
||||
- **Ownership** (Rust): Compile-time safety, zero runtime cost
|
||||
|
||||
## Basic Examples
|
||||
|
||||
### Variable Scope
|
||||
|
||||
${'```'}rust
|
||||
fn main() {
|
||||
let s = String::from("hello"); // s comes into scope
|
||||
|
||||
// s is valid here
|
||||
println!("{}", s);
|
||||
|
||||
} // s goes out of scope and is dropped
|
||||
${'```'}
|
||||
|
||||
### Move Semantics
|
||||
|
||||
${'```'}rust
|
||||
fn main() {
|
||||
let s1 = String::from("hello");
|
||||
let s2 = s1; // s1 is moved to s2
|
||||
|
||||
// println!("{}", s1); // ❌ ERROR: s1 is no longer valid
|
||||
println!("{}", s2); // ✅ OK: s2 owns the string
|
||||
}
|
||||
${'```'}
|
||||
|
||||
## Borrowing and References
|
||||
|
||||
Instead of transferring ownership, you can **borrow** values:
|
||||
|
||||
### Immutable References
|
||||
|
||||
${'```'}rust
|
||||
fn calculate_length(s: &String) -> usize {
|
||||
s.len() // s is a reference, doesn't own the String
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let s1 = String::from("hello");
|
||||
let len = calculate_length(&s1); // Borrow s1
|
||||
println!("Length of '{}' is {}", s1, len); // s1 still valid
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### Mutable References
|
||||
|
||||
${'```'}rust
|
||||
fn main() {
|
||||
let mut s = String::from("hello");
|
||||
|
||||
let r1 = &mut s;
|
||||
r1.push_str(", world");
|
||||
println!("{}", r1);
|
||||
|
||||
// let r2 = &mut s; // ❌ ERROR: cannot borrow twice
|
||||
}
|
||||
${'```'}
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Dangling References
|
||||
|
||||
${'```'}rust
|
||||
fn dangle() -> &String { // ❌ ERROR: missing lifetime specifier
|
||||
let s = String::from("hello");
|
||||
&s // s will be dropped, leaving a dangling reference
|
||||
}
|
||||
${'```'}
|
||||
|
||||
### ✅ Solution
|
||||
|
||||
${'```'}rust
|
||||
fn no_dangle() -> String {
|
||||
let s = String::from("hello");
|
||||
s // Ownership is moved out
|
||||
}
|
||||
${'```'}
|
||||
|
||||
## Benefits
|
||||
|
||||
- ✅ **No null pointer dereferences**
|
||||
- ✅ **No data races**
|
||||
- ✅ **No use-after-free**
|
||||
- ✅ **No memory leaks**
|
||||
|
||||
## Conclusion
|
||||
|
||||
Rust's ownership system eliminates entire classes of bugs at compile time. While it has a learning curve, the benefits in safety and performance are worth it.
|
||||
|
||||
## Further Reading
|
||||
|
||||
- [The Rust Book - Ownership](https://doc.rust-lang.org/book/ch04-00-understanding-ownership.html)
|
||||
- [Rust by Example - Ownership](https://doc.rust-lang.org/rust-by-example/scope/move.html)
|
||||
- [Rustlings Exercises](https://github.com/rust-lang/rustlings)
|
||||
|
||||
---
|
||||
|
||||
*Questions? Reach out on [Twitter](https://twitter.com/rustlang) or join the [Rust Discord](https://discord.gg/rust-lang)*
|
||||
`;
|
||||
@@ -0,0 +1,124 @@
|
||||
// Data Analysis Report
|
||||
export const DATA_ANALYSIS_MD = String.raw`
|
||||
# Q4 2024 Business Analytics Report
|
||||
|
||||
*Executive Summary • Generated on January 15, 2025*
|
||||
|
||||
## 📊 Key Performance Indicators
|
||||
|
||||
${'```'}
|
||||
Daily Active Users (DAU): 1.2M (+65% YoY)
|
||||
Monthly Active Users (MAU): 4.5M (+48% YoY)
|
||||
User Retention (Day 30): 68% (+12pp YoY)
|
||||
Average Session Duration: 24min (+35% YoY)
|
||||
${'```'}
|
||||
|
||||
## 🎯 Product Performance
|
||||
|
||||
### Feature Adoption Rates
|
||||
|
||||
1. **AI Assistant**: 78% of users (↑ from 45%)
|
||||
2. **Collaboration Tools**: 62% of users (↑ from 38%)
|
||||
3. **Analytics Dashboard**: 54% of users (↑ from 31%)
|
||||
4. **Mobile App**: 41% of users (↑ from 22%)
|
||||
|
||||
### Customer Satisfaction
|
||||
|
||||
| Metric | Q4 2024 | Q3 2024 | Change |
|
||||
|--------|---------|---------|--------|
|
||||
| **NPS Score** | 72 | 68 | +4 |
|
||||
| **CSAT** | 4.6/5 | 4.4/5 | +0.2 |
|
||||
| **Support Tickets** | 2,340 | 2,890 | -19% |
|
||||
| **Resolution Time** | 4.2h | 5.1h | -18% |
|
||||
|
||||
## 💰 Revenue Metrics
|
||||
|
||||
### Monthly Recurring Revenue (MRR)
|
||||
|
||||
- **Current MRR**: $2.8M (+42% YoY)
|
||||
- **New MRR**: $340K
|
||||
- **Expansion MRR**: $180K
|
||||
- **Churned MRR**: $95K
|
||||
- **Net New MRR**: $425K
|
||||
|
||||
### Customer Acquisition
|
||||
|
||||
${'```'}
|
||||
Cost per Acquisition (CAC): $127 (-23% YoY)
|
||||
Customer Lifetime Value: $1,840 (+31% YoY)
|
||||
LTV:CAC Ratio: 14.5:1
|
||||
Payback Period: 3.2 months
|
||||
${'```'}
|
||||
|
||||
## 🌍 Geographic Performance
|
||||
|
||||
### Revenue by Region
|
||||
|
||||
1. **North America**: 45% ($1.26M)
|
||||
2. **Europe**: 32% ($896K)
|
||||
3. **Asia-Pacific**: 18% ($504K)
|
||||
4. **Other**: 5% ($140K)
|
||||
|
||||
### Growth Opportunities
|
||||
|
||||
- **APAC**: 89% YoY growth potential
|
||||
- **Latin America**: Emerging market entry
|
||||
- **Middle East**: Enterprise expansion
|
||||
|
||||
## 📱 Channel Performance
|
||||
|
||||
### Traffic Sources
|
||||
|
||||
| Channel | Sessions | Conversion | Revenue |
|
||||
|---------|----------|------------|---------|
|
||||
| **Organic Search** | 45% | 3.2% | $1.1M |
|
||||
| **Direct** | 28% | 4.1% | $850K |
|
||||
| **Social Media** | 15% | 2.8% | $420K |
|
||||
| **Paid Ads** | 12% | 5.5% | $430K |
|
||||
|
||||
### Marketing ROI
|
||||
|
||||
- **Content Marketing**: 340% ROI
|
||||
- **Email Campaigns**: 280% ROI
|
||||
- **Social Media**: 190% ROI
|
||||
- **Paid Search**: 220% ROI
|
||||
|
||||
## 🔍 User Behavior Analysis
|
||||
|
||||
### Session Patterns
|
||||
|
||||
- **Peak Hours**: 9-11 AM, 2-4 PM EST
|
||||
- **Mobile Usage**: 67% of sessions
|
||||
- **Average Pages/Session**: 4.8
|
||||
- **Bounce Rate**: 23% (↓ from 31%)
|
||||
|
||||
### Feature Usage Heatmap
|
||||
|
||||
Most used features in order:
|
||||
1. Dashboard (89% of users)
|
||||
2. Search (76% of users)
|
||||
3. Reports (64% of users)
|
||||
4. Settings (45% of users)
|
||||
5. Integrations (32% of users)
|
||||
|
||||
## 💡 Recommendations
|
||||
|
||||
1. **Invest** in AI capabilities (+$2M budget)
|
||||
2. **Expand** sales team in APAC region
|
||||
3. **Improve** onboarding to reduce churn
|
||||
4. **Launch** enterprise security features
|
||||
|
||||
## Appendix
|
||||
|
||||
### Methodology
|
||||
|
||||
Data collected from:
|
||||
- Internal analytics (Amplitude)
|
||||
- Customer surveys (n=2,450)
|
||||
- Financial systems (NetSuite)
|
||||
- Market research (Gartner)
|
||||
|
||||
---
|
||||
|
||||
*Report prepared by Data Analytics Team • [View Interactive Dashboard](https://analytics.example.com)*
|
||||
`;
|
||||
@@ -0,0 +1,2 @@
|
||||
// Empty state
|
||||
export const EMPTY_MD = '';
|
||||
@@ -0,0 +1,221 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
// Math Formulas Content
|
||||
export const MATH_FORMULAS_MD = String.raw`
|
||||
# Mathematical Formulas and Expressions
|
||||
|
||||
This document demonstrates various mathematical notation and formulas that can be rendered using LaTeX syntax in markdown.
|
||||
|
||||
## Basic Arithmetic
|
||||
|
||||
### Addition and Summation
|
||||
$$\sum_{i=1}^{n} i = \frac{n(n+1)}{2}$$
|
||||
|
||||
## Algebra
|
||||
|
||||
### Quadratic Formula
|
||||
The solutions to $ax^2 + bx + c = 0$ are:
|
||||
$$x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$$
|
||||
|
||||
### Binomial Theorem
|
||||
$$(x + y)^n = \sum_{k=0}^{n} \binom{n}{k} x^{n-k} y^k$$
|
||||
|
||||
## Calculus
|
||||
|
||||
### Derivatives
|
||||
The derivative of $f(x) = x^n$ is:
|
||||
$$f'(x) = nx^{n-1}$$
|
||||
|
||||
### Integration
|
||||
$$\int_a^b f(x) \, dx = F(b) - F(a)$$
|
||||
|
||||
### Fundamental Theorem of Calculus
|
||||
$$\frac{d}{dx} \int_a^x f(t) \, dt = f(x)$$
|
||||
|
||||
## Linear Algebra
|
||||
|
||||
### Matrix Multiplication
|
||||
If $A$ is an $m \times n$ matrix and $B$ is an $n \times p$ matrix, then:
|
||||
$$C_{ij} = \sum_{k=1}^{n} A_{ik} B_{kj}$$
|
||||
|
||||
### Eigenvalues and Eigenvectors
|
||||
For a square matrix $A$, if $Av = \lambda v$ for some non-zero vector $v$, then:
|
||||
- $\lambda$ is an eigenvalue
|
||||
- $v$ is an eigenvector
|
||||
|
||||
## Statistics and Probability
|
||||
|
||||
### Normal Distribution
|
||||
The probability density function is:
|
||||
$$f(x) = \frac{1}{\sigma\sqrt{2\pi}} e^{-\frac{1}{2}\left(\frac{x-\mu}{\sigma}\right)^2}$$
|
||||
|
||||
### Bayes' Theorem
|
||||
$$P(A|B) = \frac{P(B|A) \cdot P(A)}{P(B)}$$
|
||||
|
||||
### Central Limit Theorem
|
||||
For large $n$, the sample mean $\bar{X}$ is approximately:
|
||||
$$\bar{X} \sim N\left(\mu, \frac{\sigma^2}{n}\right)$$
|
||||
|
||||
## Trigonometry
|
||||
|
||||
### Pythagorean Identity
|
||||
$$\sin^2\theta + \cos^2\theta = 1$$
|
||||
|
||||
### Euler's Formula
|
||||
$$e^{i\theta} = \cos\theta + i\sin\theta$$
|
||||
|
||||
### Taylor Series for Sine
|
||||
$$\sin x = \sum_{n=0}^{\infty} \frac{(-1)^n}{(2n+1)!} x^{2n+1} = x - \frac{x^3}{3!} + \frac{x^5}{5!} - \frac{x^7}{7!} + \cdots$$
|
||||
|
||||
## Complex Analysis
|
||||
|
||||
### Complex Numbers
|
||||
A complex number can be written as:
|
||||
$$z = a + bi = r e^{i\theta}$$
|
||||
|
||||
where $r = |z| = \sqrt{a^2 + b^2}$ and $\theta = \arg(z)$
|
||||
|
||||
### Cauchy-Riemann Equations
|
||||
For a function $f(z) = u(x,y) + iv(x,y)$ to be analytic:
|
||||
$$\frac{\partial u}{\partial x} = \frac{\partial v}{\partial y}, \quad \frac{\partial u}{\partial y} = -\frac{\partial v}{\partial x}$$
|
||||
|
||||
## Differential Equations
|
||||
|
||||
### First-order Linear ODE
|
||||
$$\frac{dy}{dx} + P(x)y = Q(x)$$
|
||||
|
||||
Solution: $y = e^{-\int P(x)dx}\left[\int Q(x)e^{\int P(x)dx}dx + C\right]$
|
||||
|
||||
### Heat Equation
|
||||
$$\frac{\partial u}{\partial t} = \alpha \frac{\partial^2 u}{\partial x^2}$$
|
||||
|
||||
## Number Theory
|
||||
|
||||
### Prime Number Theorem
|
||||
$$\pi(x) \sim \frac{x}{\ln x}$$
|
||||
|
||||
where $\pi(x)$ is the number of primes less than or equal to $x$.
|
||||
|
||||
### Fermat's Last Theorem
|
||||
For $n > 2$, there are no positive integers $a$, $b$, and $c$ such that:
|
||||
$$a^n + b^n = c^n$$
|
||||
|
||||
## Set Theory
|
||||
|
||||
### De Morgan's Laws
|
||||
$$\overline{A \cup B} = \overline{A} \cap \overline{B}$$
|
||||
$$\overline{A \cap B} = \overline{A} \cup \overline{B}$$
|
||||
|
||||
## Advanced Topics
|
||||
|
||||
### Riemann Zeta Function
|
||||
$$\zeta(s) = \sum_{n=1}^{\infty} \frac{1}{n^s} = \prod_{p \text{ prime}} \frac{1}{1-p^{-s}}$$
|
||||
|
||||
### Maxwell's Equations
|
||||
$$\nabla \cdot \mathbf{E} = \frac{\rho}{\epsilon_0}$$
|
||||
$$\nabla \cdot \mathbf{B} = 0$$
|
||||
$$\nabla \times \mathbf{E} = -\frac{\partial \mathbf{B}}{\partial t}$$
|
||||
$$\nabla \times \mathbf{B} = \mu_0\mathbf{J} + \mu_0\epsilon_0\frac{\partial \mathbf{E}}{\partial t}$$
|
||||
|
||||
### Schrödinger Equation
|
||||
$$i\hbar\frac{\partial}{\partial t}\Psi(\mathbf{r},t) = \hat{H}\Psi(\mathbf{r},t)$$
|
||||
|
||||
## Inline Math Examples
|
||||
|
||||
Here are some inline mathematical expressions:
|
||||
|
||||
- The golden ratio: $\phi = \frac{1 + \sqrt{5}}{2} \approx 1.618$
|
||||
- Euler's number: $e = \lim_{n \to \infty} \left(1 + \frac{1}{n}\right)^n$
|
||||
- Pi: $\pi = 4 \sum_{n=0}^{\infty} \frac{(-1)^n}{2n+1}$
|
||||
- Square root of 2: $\sqrt{2} = 1.41421356...$
|
||||
|
||||
## Fractions and Radicals
|
||||
|
||||
Complex fraction: $\frac{\frac{a}{b} + \frac{c}{d}}{\frac{e}{f} - \frac{g}{h}}$
|
||||
|
||||
Nested radicals: $\sqrt{2 + \sqrt{3 + \sqrt{4 + \sqrt{5}}}}$
|
||||
|
||||
## Summations and Products
|
||||
|
||||
### Geometric Series
|
||||
$$\sum_{n=0}^{\infty} ar^n = \frac{a}{1-r} \quad \text{for } |r| < 1$$
|
||||
|
||||
### Product Notation
|
||||
$$n! = \prod_{k=1}^{n} k$$
|
||||
|
||||
### Double Summation
|
||||
$$\sum_{i=1}^{m} \sum_{j=1}^{n} a_{ij}$$
|
||||
|
||||
## Limits
|
||||
|
||||
$$\lim_{x \to 0} \frac{\sin x}{x} = 1$$
|
||||
|
||||
$$\lim_{n \to \infty} \left(1 + \frac{x}{n}\right)^n = e^x$$
|
||||
|
||||
## Further Bracket Styles and Amounts
|
||||
|
||||
- \( \mathrm{GL}_2(\mathbb{F}_7) \): Group of invertible matrices with entries in \(\mathbb{F}_7\).
|
||||
- Some kernel of \(\mathrm{SL}_2(\mathbb{F}_7)\):
|
||||
\[
|
||||
\left\{ \begin{pmatrix} 1 & 0 \\ 0 & 1 \end{pmatrix}, \begin{pmatrix} -1 & 0 \\ 0 & -1 \end{pmatrix} \right\} = \{\pm I\}
|
||||
\]
|
||||
- Algebra:
|
||||
\[
|
||||
x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a}
|
||||
\]
|
||||
- $100 and $12.99 are amounts, not LaTeX.
|
||||
- I have $10, $3.99 and $x + y$ and $100x$. The amount is $2,000.
|
||||
- Emma buys 2 cupcakes for $3 each and 1 cookie for $1.50. How much money does she spend in total?
|
||||
- Maria has $20. She buys a notebook for $4.75 and a pack of pencils for $3.25. How much change does she receive?
|
||||
- 1 kg の質量は
|
||||
\[
|
||||
E = (1\ \text{kg}) \times (3.0 \times 10^8\ \text{m/s})^2 \approx 9.0 \times 10^{16}\ \text{J}
|
||||
\]
|
||||
というエネルギーに相当します。これは約 21 百万トンの TNT が爆発したときのエネルギーに匹敵します。
|
||||
- Algebra: \[
|
||||
x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a}
|
||||
\]
|
||||
- Algebraic topology, Homotopy Groups of $\mathbb{S}^3$:
|
||||
$$\pi_n(\mathbb{S}^3) = \begin{cases}
|
||||
\mathbb{Z} & n = 3 \\
|
||||
0 & n > 3, n \neq 4 \\
|
||||
\mathbb{Z}_2 & n = 4 \\
|
||||
\end{cases}$$
|
||||
- Spacer preceded by backslash:
|
||||
\[
|
||||
\boxed{
|
||||
\begin{aligned}
|
||||
N_{\text{att}}^{\text{(MHA)}} &=
|
||||
h \bigl[\, d_{\text{model}}\;d_{k} + d_{\text{model}}\;d_{v}\, \bigr] && (\text{Q,K,V の重み})\\
|
||||
&\quad+ h(d_{k}+d_{k}+d_{v}) && (\text{バイアス Q,K,V)}\\[4pt]
|
||||
&\quad+ (h d_{v})\, d_{\text{model}} && (\text{出力射影 }W^{O})\\
|
||||
&\quad+ d_{\text{model}} && (\text{バイアス }b^{O})
|
||||
\end{aligned}}
|
||||
\]
|
||||
|
||||
## Formulas in a Table
|
||||
|
||||
| Area | Expression | Comment |
|
||||
|------|------------|---------|
|
||||
| **Algebra** | \[
|
||||
x = \frac{-b \pm \sqrt{\,b^{2}-4ac\,}}{2a}
|
||||
\] | Quadratic formula |
|
||||
| | \[
|
||||
(a+b)^{n} = \sum_{k=0}^{n}\binom{n}{k}\,a^{\,n-k}\,b^{\,k}
|
||||
\] | Binomial theorem |
|
||||
| | \(\displaystyle \prod_{k=1}^{n}k = n! \) | Factorial definition |
|
||||
| **Geometry** | \( \mathbf{a}\cdot \mathbf{b} = \|\mathbf{a}\|\,\|\mathbf{b}\|\,\cos\theta \) | Dot product & angle |
|
||||
|
||||
## No math (but chemical)
|
||||
|
||||
Balanced chemical reaction with states:
|
||||
|
||||
\[
|
||||
\ce{2H2(g) + O2(g) -> 2H2O(l)}
|
||||
\]
|
||||
|
||||
The standard enthalpy change for the reaction is: $\Delta H^\circ = \pu{-572 kJ mol^{-1}}$.
|
||||
|
||||
---
|
||||
|
||||
*This document showcases various mathematical notation and formulas that can be rendered in markdown using LaTeX syntax.*
|
||||
`;
|
||||
@@ -0,0 +1,136 @@
|
||||
// README Content
|
||||
export const README_MD = String.raw`
|
||||
# 🚀 Awesome Web Framework
|
||||
|
||||
[](https://www.npmjs.com/package/awesome-framework)
|
||||
[](https://github.com/awesome/framework/actions)
|
||||
[](https://codecov.io/gh/awesome/framework)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
|
||||
> A modern, fast, and flexible web framework for building scalable applications
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- 🎯 **Type-Safe** - Full TypeScript support out of the box
|
||||
- ⚡ **Lightning Fast** - Built on Vite for instant HMR
|
||||
- 📦 **Zero Config** - Works out of the box for most use cases
|
||||
- 🎨 **Flexible** - Unopinionated with sensible defaults
|
||||
- 🔧 **Extensible** - Plugin system for custom functionality
|
||||
- 📱 **Responsive** - Mobile-first approach
|
||||
- 🌍 **i18n Ready** - Built-in internationalization
|
||||
- 🔒 **Secure** - Security best practices by default
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
${'```'}bash
|
||||
npm install awesome-framework
|
||||
# or
|
||||
yarn add awesome-framework
|
||||
# or
|
||||
pnpm add awesome-framework
|
||||
${'```'}
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Create a new project
|
||||
|
||||
${'```'}bash
|
||||
npx create-awesome-app my-app
|
||||
cd my-app
|
||||
npm run dev
|
||||
${'```'}
|
||||
|
||||
### Basic Example
|
||||
|
||||
${'```'}javascript
|
||||
import { createApp } from 'awesome-framework';
|
||||
|
||||
const app = createApp({
|
||||
port: 3000,
|
||||
middleware: ['cors', 'helmet', 'compression']
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.json({ message: 'Hello World!' });
|
||||
});
|
||||
|
||||
app.listen(() => {
|
||||
console.log('Server running on http://localhost:3000');
|
||||
});
|
||||
${'```'}
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
### Core Concepts
|
||||
|
||||
- [Getting Started](https://docs.awesome.dev/getting-started)
|
||||
- [Configuration](https://docs.awesome.dev/configuration)
|
||||
- [Routing](https://docs.awesome.dev/routing)
|
||||
- [Middleware](https://docs.awesome.dev/middleware)
|
||||
- [Database](https://docs.awesome.dev/database)
|
||||
- [Authentication](https://docs.awesome.dev/authentication)
|
||||
|
||||
### Advanced Topics
|
||||
|
||||
- [Performance Optimization](https://docs.awesome.dev/performance)
|
||||
- [Deployment](https://docs.awesome.dev/deployment)
|
||||
- [Testing](https://docs.awesome.dev/testing)
|
||||
- [Security](https://docs.awesome.dev/security)
|
||||
|
||||
## 🛠️ Development
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js >= 18
|
||||
- pnpm >= 8
|
||||
|
||||
### Setup
|
||||
|
||||
${'```'}bash
|
||||
git clone https://github.com/awesome/framework.git
|
||||
cd framework
|
||||
pnpm install
|
||||
pnpm dev
|
||||
${'```'}
|
||||
|
||||
### Testing
|
||||
|
||||
${'```'}bash
|
||||
pnpm test # Run unit tests
|
||||
pnpm test:e2e # Run end-to-end tests
|
||||
pnpm test:watch # Run tests in watch mode
|
||||
${'```'}
|
||||
|
||||
## 🤝 Contributing
|
||||
|
||||
We welcome contributions! Please see our [Contributing Guide](CONTRIBUTING.md) for details.
|
||||
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/awesome/framework/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=awesome/framework" />
|
||||
</a>
|
||||
|
||||
## 📊 Benchmarks
|
||||
|
||||
| Framework | Requests/sec | Latency (ms) | Memory (MB) |
|
||||
|-----------|-------------|--------------|-------------|
|
||||
| **Awesome** | **45,230** | **2.1** | **42** |
|
||||
| Express | 28,450 | 3.5 | 68 |
|
||||
| Fastify | 41,200 | 2.3 | 48 |
|
||||
| Koa | 32,100 | 3.1 | 52 |
|
||||
|
||||
*Benchmarks performed on MacBook Pro M2, Node.js 20.x*
|
||||
|
||||
## 📝 License
|
||||
|
||||
MIT © [Awesome Team](https://github.com/awesome)
|
||||
|
||||
## 🙏 Acknowledgments
|
||||
|
||||
Special thanks to all our sponsors and contributors who make this project possible.
|
||||
|
||||
---
|
||||
|
||||
**[Website](https://awesome.dev)** • **[Documentation](https://docs.awesome.dev)** • **[Discord](https://discord.gg/awesome)** • **[Twitter](https://twitter.com/awesomeframework)**
|
||||
`;
|
||||
@@ -0,0 +1,86 @@
|
||||
import { serverStore } from '$lib/stores/server.svelte';
|
||||
import { modelsStore } from '$lib/stores/models.svelte';
|
||||
|
||||
/**
|
||||
* Mock server properties for Storybook testing
|
||||
* This utility allows setting mock server configurations without polluting production code
|
||||
*/
|
||||
export function mockServerProps(props: Partial<ApiLlamaCppServerProps>): void {
|
||||
// Reset any pointer-events from previous tests (dropdown cleanup)
|
||||
const body = document.querySelector('body');
|
||||
if (body) body.style.pointerEvents = '';
|
||||
|
||||
// Directly set the props for testing purposes
|
||||
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
|
||||
model_path: props.model_path || 'test-model',
|
||||
modalities: {
|
||||
vision: props.modalities?.vision ?? false,
|
||||
audio: props.modalities?.audio ?? false,
|
||||
video: props.modalities?.video ?? false
|
||||
},
|
||||
...props
|
||||
} as ApiLlamaCppServerProps;
|
||||
|
||||
// Set router mode role so activeModelId can be set
|
||||
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props.role = 'ROUTER';
|
||||
|
||||
// Also mock modelsStore methods for modality checking
|
||||
const vision = props.modalities?.vision ?? false;
|
||||
const audio = props.modalities?.audio ?? false;
|
||||
const video = props.modalities?.video ?? false;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).modelSupportsVision = () => vision;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).modelSupportsAudio = () => audio;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).modelSupportsVideo = () => video;
|
||||
|
||||
// Mock models list with a test model so activeModelId can be resolved
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).models = [
|
||||
{
|
||||
id: 'test-model',
|
||||
name: 'Test Model',
|
||||
model: 'test-model'
|
||||
}
|
||||
];
|
||||
|
||||
// Mock selectedModelId
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(modelsStore as any).selectedModelId = 'test-model';
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset server store to clean state for testing
|
||||
*/
|
||||
export function resetServerStore(): void {
|
||||
(serverStore as unknown as { props: ApiLlamaCppServerProps }).props = {
|
||||
model_path: '',
|
||||
modalities: {
|
||||
vision: false,
|
||||
audio: false,
|
||||
video: false
|
||||
}
|
||||
} as ApiLlamaCppServerProps;
|
||||
(serverStore as unknown as { error: string }).error = '';
|
||||
(serverStore as unknown as { loading: boolean }).loading = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common mock configurations for Storybook stories
|
||||
*/
|
||||
export const mockConfigs = {
|
||||
visionOnly: {
|
||||
modalities: { vision: true, audio: false }
|
||||
},
|
||||
audioOnly: {
|
||||
modalities: { vision: false, audio: true }
|
||||
},
|
||||
bothModalities: {
|
||||
modalities: { vision: true, audio: true }
|
||||
},
|
||||
noModalities: {
|
||||
modalities: { vision: false, audio: false, video: false }
|
||||
}
|
||||
} as const;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isAbortError } from '$lib/utils/abort';
|
||||
|
||||
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,257 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { deriveAgenticSections, hasAgenticContent } from '$lib/utils/agentic';
|
||||
import { AgenticSectionType, MessageRole } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
import type { ApiChatCompletionToolCall } from '$lib/types/api';
|
||||
|
||||
function makeAssistant(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
id: overrides.id ?? 'ast-1',
|
||||
convId: 'conv-1',
|
||||
type: 'text',
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: overrides.content ?? '',
|
||||
parent: null,
|
||||
children: [],
|
||||
...overrides
|
||||
} as DatabaseMessage;
|
||||
}
|
||||
|
||||
function makeToolMsg(overrides: Partial<DatabaseMessage> = {}): DatabaseMessage {
|
||||
return {
|
||||
id: overrides.id ?? 'tool-1',
|
||||
convId: 'conv-1',
|
||||
type: 'text',
|
||||
timestamp: Date.now(),
|
||||
role: MessageRole.TOOL,
|
||||
content: overrides.content ?? 'tool result',
|
||||
parent: null,
|
||||
children: [],
|
||||
toolCallId: overrides.toolCallId ?? 'call_1',
|
||||
...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([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"test"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const toolResult = makeToolMsg({
|
||||
toolCallId: 'call_1',
|
||||
content: 'Found 3 results'
|
||||
});
|
||||
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([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'bash', arguments: '{}' } }
|
||||
])
|
||||
});
|
||||
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('multi-turn: two assistant turns grouped as one session', () => {
|
||||
const assistant1 = makeAssistant({
|
||||
id: 'ast-1',
|
||||
content: 'Turn 1 text',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'search', arguments: '{"q":"foo"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'result 1' });
|
||||
const assistant2 = makeAssistant({
|
||||
id: 'ast-2',
|
||||
content: 'Final answer based on results.'
|
||||
});
|
||||
|
||||
// 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({
|
||||
id: 'ast-1',
|
||||
content: '',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_1',
|
||||
type: 'function',
|
||||
function: { name: 'list_files', arguments: '{}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ id: 'tool-1', toolCallId: 'call_1', content: 'file1 file2' });
|
||||
const assistant2 = makeAssistant({
|
||||
id: 'ast-2',
|
||||
content: 'Reading file1...',
|
||||
toolCalls: JSON.stringify([
|
||||
{
|
||||
id: 'call_2',
|
||||
type: 'function',
|
||||
function: { name: 'read_file', arguments: '{"path":"file1"}' }
|
||||
}
|
||||
])
|
||||
});
|
||||
const tool2 = makeToolMsg({
|
||||
id: 'tool-2',
|
||||
toolCallId: 'call_2',
|
||||
content: 'contents of file1'
|
||||
});
|
||||
const assistant3 = makeAssistant({
|
||||
id: 'ast-3',
|
||||
content: 'Here is the analysis.',
|
||||
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([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'search', arguments: '{}' } }
|
||||
])
|
||||
});
|
||||
const tool1 = makeToolMsg({ toolCallId: 'call_1', content: 'result' });
|
||||
const assistant2 = makeAssistant({ id: 'ast-2', content: '' });
|
||||
|
||||
const streamingToolCalls: ApiChatCompletionToolCall[] = [
|
||||
{ id: 'call_2', type: 'function', function: { name: 'write_file', arguments: '{"pa' } }
|
||||
];
|
||||
|
||||
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([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'test', arguments: '{}' } }
|
||||
])
|
||||
});
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { LEGACY_AGENTIC_REGEX } from '$lib/constants/agentic';
|
||||
|
||||
/**
|
||||
* 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,423 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { AttachmentType } from '$lib/enums';
|
||||
import {
|
||||
formatMessageForClipboard,
|
||||
parseClipboardContent,
|
||||
hasClipboardAttachments
|
||||
} from '$lib/utils/clipboard';
|
||||
|
||||
describe('formatMessageForClipboard', () => {
|
||||
it('returns plain content when no extras', () => {
|
||||
const result = formatMessageForClipboard('Hello world', undefined);
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('returns plain content when extras is empty array', () => {
|
||||
const result = formatMessageForClipboard('Hello world', []);
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('handles empty string content', () => {
|
||||
const result = formatMessageForClipboard('', undefined);
|
||||
expect(result).toBe('');
|
||||
});
|
||||
|
||||
it('returns plain content when extras has only non-text attachments', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.IMAGE as const,
|
||||
name: 'image.png',
|
||||
base64Url: 'data:image/png;base64,...'
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello world', extras);
|
||||
expect(result).toBe('Hello world');
|
||||
});
|
||||
|
||||
it('filters non-text attachments and keeps only text ones', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.IMAGE as const,
|
||||
name: 'image.png',
|
||||
base64Url: 'data:image/png;base64,...'
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file.txt',
|
||||
content: 'Text content'
|
||||
},
|
||||
{
|
||||
type: AttachmentType.PDF as const,
|
||||
name: 'doc.pdf',
|
||||
base64Data: 'data:application/pdf;base64,...',
|
||||
content: 'PDF content',
|
||||
processedAsImages: false
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file1.txt',
|
||||
content: 'File 1 content'
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file2.txt',
|
||||
content: 'File 2 content'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'test.txt',
|
||||
content: 'Test content'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.LEGACY_CONTEXT as const,
|
||||
name: 'legacy.txt',
|
||||
content: 'Legacy content'
|
||||
}
|
||||
];
|
||||
const result = formatMessageForClipboard('Hello', extras);
|
||||
|
||||
expect(result).toContain('"type": "TEXT"');
|
||||
expect(result).not.toContain('"context"');
|
||||
});
|
||||
|
||||
it('handles attachment content with special characters', () => {
|
||||
const extras = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'code.js',
|
||||
content: 'const x = "hello\\nworld";\nconst y = `template ${var}`;'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'unicode.txt',
|
||||
content: '日本語テスト 🎉 émojis'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file1.txt',
|
||||
content: 'File 1 content'
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file2.txt',
|
||||
content: 'File 2 content'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'test.txt',
|
||||
content: 'Test content'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file1.txt',
|
||||
content: 'Content with\nnewlines and "quotes"'
|
||||
},
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file2.txt',
|
||||
content: 'Another file'
|
||||
}
|
||||
];
|
||||
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'file.txt',
|
||||
content: 'Content only'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'empty.txt',
|
||||
content: ''
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'path.txt',
|
||||
content: 'D:\\\\Data\\\\file'
|
||||
}
|
||||
];
|
||||
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 = [
|
||||
{
|
||||
type: AttachmentType.TEXT as const,
|
||||
name: 'whitespace.txt',
|
||||
content: '\t\t\n\n '
|
||||
}
|
||||
];
|
||||
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,166 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { classifyContinueIntent } from '$lib/utils/agentic';
|
||||
import { ContinueIntentKind, MessageRole, MessageType } from '$lib/enums';
|
||||
import type { DatabaseMessage } from '$lib/types/database';
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
id: `msg-${nextId}`,
|
||||
convId: 'conv-1',
|
||||
type: MessageType.TEXT,
|
||||
timestamp: nextId,
|
||||
role,
|
||||
content: '',
|
||||
parent: null,
|
||||
children: [],
|
||||
...opts
|
||||
};
|
||||
}
|
||||
|
||||
function toolCall(id: string, name: string, args: string = '{}'): string {
|
||||
return JSON.stringify([{ id, type: 'function', function: { name, arguments: args } }]);
|
||||
}
|
||||
|
||||
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([
|
||||
{ id: 'call_1', type: 'function', function: { name: 'a', arguments: '{}' } },
|
||||
{ id: 'call_2', type: 'function', function: { name: 'b', arguments: '{}' } }
|
||||
])
|
||||
}),
|
||||
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,198 @@
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
colorizeFaviconSvg,
|
||||
padFaviconSvg,
|
||||
writeThemeFavicons
|
||||
} from '../../scripts/favicon-colorize';
|
||||
|
||||
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 { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
expect(light).toContain('fill="#ff00aa"');
|
||||
expect(dark).toContain('fill="#ff00aa"');
|
||||
});
|
||||
|
||||
it('does not alter any other part of the SVG', () => {
|
||||
const { light, dark } = colorizeFaviconSvg(SOURCE_SVG, '#111111', '#fafafa');
|
||||
const stripColors = (s: string) =>
|
||||
s.replaceAll('#111111', '').replaceAll('#fafafa', '').replaceAll('currentColor', '');
|
||||
const expected = stripColors(SOURCE_SVG);
|
||||
expect(stripColors(light)).toBe(expected);
|
||||
expect(stripColors(dark)).toBe(expected);
|
||||
});
|
||||
|
||||
it('returns the same SVG for light and dark when called with the same color', () => {
|
||||
const result = colorizeFaviconSvg(SOURCE_SVG, '#abcdef', '#abcdef');
|
||||
expect(result.light).toBe(result.dark);
|
||||
});
|
||||
|
||||
it('returns the source unchanged when given a color that does not appear (no currentColor in source)', () => {
|
||||
const plain = '<svg><path fill="#000"/></svg>';
|
||||
const { light, dark } = colorizeFaviconSvg(plain, '#111111', '#fafafa');
|
||||
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, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function setupSource() {
|
||||
const sourcePath = join(tmpDir, 'logo.svg');
|
||||
writeFileSync(sourcePath, LOGO);
|
||||
return {
|
||||
sourcePath,
|
||||
lightPath: join(tmpDir, 'favicon.svg'),
|
||||
darkPath: join(tmpDir, 'favicon-dark.svg')
|
||||
};
|
||||
}
|
||||
|
||||
it('writes colorized, un-padded favicons without modifying the source', () => {
|
||||
const { sourcePath, lightPath, darkPath } = setupSource();
|
||||
const before = readFileSync(sourcePath, 'utf-8');
|
||||
|
||||
writeThemeFavicons('#abcdef', '#012345', {
|
||||
sourcePath,
|
||||
lightOutPath: lightPath,
|
||||
darkOutPath: darkPath
|
||||
});
|
||||
|
||||
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 { sourcePath, lightPath, darkPath } = setupSource();
|
||||
// mirror the production wiring: PWA_ASSET_GENERATOR.FAVICON_PADDING
|
||||
const padding = 0.04;
|
||||
// scale = 1 - 0.04 = 0.96; translate = (0.04 * 512) / 2 = 10.24
|
||||
const expectedTransform = 'transform="translate(10.24 10.24) scale(0.96)"';
|
||||
|
||||
writeThemeFavicons('#111111', '#fafafa', {
|
||||
sourcePath,
|
||||
lightOutPath: lightPath,
|
||||
darkOutPath: darkPath,
|
||||
padding
|
||||
});
|
||||
|
||||
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,126 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { parseHeadersToArray, serializeHeaders } from '$lib/utils/headers';
|
||||
|
||||
/**
|
||||
* 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-C': '3', 'X-A': '1', 'X-B': '2' });
|
||||
});
|
||||
});
|
||||
|
||||
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,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getJpegOrientationFromDataURL, isJpegMimeType } from '$lib/utils/jpeg-orientation';
|
||||
|
||||
// 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,376 @@
|
||||
/* eslint-disable no-irregular-whitespace */
|
||||
import { describe, it, expect, test } from 'vitest';
|
||||
import { maskInlineLaTeX, preprocessLaTeX } from '$lib/utils/latex-protection';
|
||||
|
||||
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$.');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client';
|
||||
import { MCPService } from '$lib/services/mcp.service';
|
||||
import { MCPConnectionPhase, MCPTransportType } from '$lib/enums';
|
||||
import type { MCPConnectionLog, MCPServerConfig } from '$lib/types';
|
||||
import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
|
||||
|
||||
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('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, { method: 'POST', body: '{}' });
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs.every((log) => log.message.includes('https://example.com/mcp'))).toBe(true);
|
||||
|
||||
controller.disable();
|
||||
await controller.fetch(config.url, { method: 'POST', body: '{}' });
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('redacts all configured custom headers in diagnostic request logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
headers: {
|
||||
'x-auth-token': 'secret-token',
|
||||
'x-vendor-api-key': 'secret-key'
|
||||
}
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log), {
|
||||
headers: config.headers
|
||||
});
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: '{}'
|
||||
});
|
||||
|
||||
expect(logs).toHaveLength(2);
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
headers: {
|
||||
'x-auth-token': '[redacted]',
|
||||
'x-vendor-api-key': '[redacted]',
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
const fetchMock = vi.fn().mockResolvedValue(response);
|
||||
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
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', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-request-12345'
|
||||
},
|
||||
body: '{}'
|
||||
});
|
||||
|
||||
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 = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP,
|
||||
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: { status: 200, isFake: true }
|
||||
});
|
||||
});
|
||||
|
||||
it('partially redacts mcp-session-id in diagnostic request and response logs', async () => {
|
||||
const logs: MCPConnectionLog[] = [];
|
||||
const response = new Response('{}', {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-response-67890'
|
||||
}
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'mcp-session-id': 'session-request-12345'
|
||||
},
|
||||
body: '{}'
|
||||
});
|
||||
|
||||
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('{}', {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' }
|
||||
});
|
||||
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(response));
|
||||
|
||||
const config: MCPServerConfig = {
|
||||
url: 'https://example.com/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await controller.fetch(config.url, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify([
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
])
|
||||
});
|
||||
|
||||
expect(logs[0].details).toMatchObject({
|
||||
request: {
|
||||
method: 'POST',
|
||||
body: {
|
||||
kind: 'string',
|
||||
size: expect.any(Number)
|
||||
},
|
||||
jsonRpcMethods: ['initialize', 'notifications/initialized']
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 = {
|
||||
url: 'http://localhost:8000/mcp',
|
||||
transport: MCPTransportType.STREAMABLE_HTTP
|
||||
};
|
||||
|
||||
const controller = createDiagnosticFetch(config, (log) => logs.push(log));
|
||||
|
||||
await expect(controller.fetch(config.url, { method: 'POST', body: '{}' })).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({
|
||||
transport: {} as never,
|
||||
type: MCPTransportType.WEBSOCKET,
|
||||
stopPhaseLogging
|
||||
});
|
||||
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',
|
||||
{
|
||||
url: 'ws://example.com/mcp',
|
||||
transport: MCPTransportType.WEBSOCKET
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
(phase, log) => phaseLogs.push({ phase, log })
|
||||
);
|
||||
|
||||
expect(stopPhaseLogging).toHaveBeenCalledTimes(1);
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ phase, log }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: handshake protocol error'
|
||||
)
|
||||
).toHaveLength(1);
|
||||
|
||||
emitClientError?.(new Error('runtime protocol error'));
|
||||
|
||||
expect(
|
||||
phaseLogs.filter(
|
||||
({ phase, log }) =>
|
||||
phase === MCPConnectionPhase.ERROR &&
|
||||
log.message === 'Protocol error: runtime protocol error'
|
||||
)
|
||||
).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,270 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ModelsService } from '$lib/services/models.service';
|
||||
|
||||
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',
|
||||
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',
|
||||
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 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('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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
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',
|
||||
tags: []
|
||||
});
|
||||
});
|
||||
|
||||
it('handles ambiguous model names', () => {
|
||||
// Qwen3.5 Instruct vs Thinking — tags should distinguish them
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Instruct')).toMatchObject({
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
activatedParams: 'A3B',
|
||||
tags: ['Instruct']
|
||||
});
|
||||
|
||||
expect(parseModelId('Qwen/Qwen3.5-30B-A3B-Thinking')).toMatchObject({
|
||||
modelName: 'Qwen3.5',
|
||||
params: '30B',
|
||||
activatedParams: 'A3B',
|
||||
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']
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isValidModelName, normalizeModelName } from '$lib/utils/model-names';
|
||||
|
||||
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,144 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||
|
||||
/**
|
||||
* Tests for the mcpServers settings parser.
|
||||
*
|
||||
* The branch seeds the MCP servers setting with a default value of
|
||||
* `JSON.stringify(RECOMMENDED_MCP_SERVERS)`, so 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([{ url: 'https://a.test', enabled: true }, { 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('falls back to the configured default requestTimeoutSeconds only for nullish values', () => {
|
||||
const fallback = DEFAULT_MCP_CONFIG.requestTimeoutSeconds;
|
||||
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'a', url: 'https://a.test' },
|
||||
{ id: 'b', url: 'https://b.test', requestTimeoutSeconds: undefined },
|
||||
{ id: 'c', url: 'https://c.test', requestTimeoutSeconds: 0 },
|
||||
{ id: 'd', url: 'https://d.test', requestTimeoutSeconds: 45 }
|
||||
])
|
||||
);
|
||||
|
||||
// The parser uses ?? for timeout fallback, which only triggers on
|
||||
// null/undefined. Explicit 0 is preserved at face value.
|
||||
expect(parsed[0]?.requestTimeoutSeconds).toBe(fallback);
|
||||
expect(parsed[1]?.requestTimeoutSeconds).toBe(fallback);
|
||||
expect(parsed[2]?.requestTimeoutSeconds).toBe(0);
|
||||
expect(parsed[3]?.requestTimeoutSeconds).toBe(45);
|
||||
});
|
||||
|
||||
it('treats whitespace-only headers strings as undefined', () => {
|
||||
const parsed = parseMcpServerSettings(
|
||||
JSON.stringify([
|
||||
{ id: 'a', url: 'https://a.test', headers: ' ' },
|
||||
{ id: 'b', url: 'https://b.test', headers: '{"X-Foo":"bar"}' }
|
||||
])
|
||||
);
|
||||
|
||||
// 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' },
|
||||
{ id: 'b', url: 'https://b.test', enabled: true },
|
||||
{ id: 'c', url: 'https://c.test', enabled: false },
|
||||
{ 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('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' },
|
||||
{ id: 'b', url: 'https://b.test', enabled: true }
|
||||
]);
|
||||
|
||||
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,193 @@
|
||||
import { existsSync, readFileSync, readdirSync } 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,89 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { MessageRole } from '$lib/enums';
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'Let me think: 2+2=4, basic arithmetic.'
|
||||
};
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
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 = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'The answer is 4.',
|
||||
reasoningContent: 'internal thinking'
|
||||
};
|
||||
|
||||
const excludeReasoningFromContext = true;
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
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 = {
|
||||
role: MessageRole.ASSISTANT,
|
||||
content: 'No reasoning here.',
|
||||
reasoningContent: undefined
|
||||
};
|
||||
|
||||
const apiMessage: Record<string, unknown> = {
|
||||
role: dbMessage.role,
|
||||
content: dbMessage.content
|
||||
};
|
||||
if (dbMessage.reasoningContent) {
|
||||
apiMessage.reasoning_content = dbMessage.reasoningContent;
|
||||
}
|
||||
|
||||
expect(apiMessage.content).toBe('No reasoning here.');
|
||||
expect(apiMessage.reasoning_content).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
RECOMMENDED_MCP_SERVER_IDS,
|
||||
RECOMMENDED_MCP_SERVERS
|
||||
} from '$lib/constants/recommended-mcp-servers';
|
||||
import { parseMcpServerSettings } from '$lib/utils/mcp';
|
||||
import { DEFAULT_MCP_CONFIG, MCP_SERVER_ID_PREFIX } from '$lib/constants/mcp';
|
||||
|
||||
/**
|
||||
* Tests for the predefined recommended MCP servers.
|
||||
*
|
||||
* These are surfaced to first-time users via
|
||||
* DialogMcpServerRecommendations and used as the default value of the MCP
|
||||
* servers setting, so a regression that breaks the round-trip through the
|
||||
* settings parser would silently break onboarding for new users.
|
||||
*/
|
||||
describe('RECOMMENDED_MCP_SERVERS', () => {
|
||||
it('lists at least one entry and uses stable, unique ids', () => {
|
||||
expect(RECOMMENDED_MCP_SERVERS.length).toBeGreaterThan(0);
|
||||
|
||||
const ids = RECOMMENDED_MCP_SERVERS.map((server) => server.id);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
|
||||
for (const id of ids) {
|
||||
expect(id).toMatch(/^[a-z0-9-]+$/);
|
||||
expect(id.toLowerCase()).not.toContain(MCP_SERVER_ID_PREFIX.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
it('requires a name, description and url for every entry', () => {
|
||||
for (const server of RECOMMENDED_MCP_SERVERS) {
|
||||
expect(server.name?.trim().length ?? 0).toBeGreaterThan(0);
|
||||
expect(server.description.trim().length).toBeGreaterThan(0);
|
||||
expect(server.url.trim().length).toBeGreaterThan(0);
|
||||
expect(() => new URL(server.url)).not.toThrow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('RECOMMENDED_MCP_SERVER_IDS', () => {
|
||||
it('matches the ids declared in RECOMMENDED_MCP_SERVERS', () => {
|
||||
expect(RECOMMENDED_MCP_SERVER_IDS.size).toBe(RECOMMENDED_MCP_SERVERS.length);
|
||||
|
||||
for (const server of RECOMMENDED_MCP_SERVERS) {
|
||||
expect(RECOMMENDED_MCP_SERVER_IDS.has(server.id)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('recommended-mcp-servers default value', () => {
|
||||
it('round-trips cleanly through parseMcpServerSettings', () => {
|
||||
const serialized = JSON.stringify(RECOMMENDED_MCP_SERVERS);
|
||||
const parsed = parseMcpServerSettings(serialized);
|
||||
|
||||
expect(parsed).toHaveLength(RECOMMENDED_MCP_SERVERS.length);
|
||||
|
||||
for (let index = 0; index < RECOMMENDED_MCP_SERVERS.length; index++) {
|
||||
const source = RECOMMENDED_MCP_SERVERS[index];
|
||||
const entry = parsed[index];
|
||||
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.id).toBe(source.id);
|
||||
expect(entry?.url).toBe(source.url);
|
||||
expect(entry?.enabled).toBe(source.enabled);
|
||||
expect(entry?.requestTimeoutSeconds).toBe(source.requestTimeoutSeconds);
|
||||
expect(entry?.name).toBe(source.name);
|
||||
|
||||
// Headers and useProxy are not set on recommended servers; the
|
||||
// parser must fall back to the inactive defaults rather than
|
||||
// surfacing undefined-boundary states.
|
||||
expect(entry?.headers).toBeUndefined();
|
||||
expect(entry?.useProxy).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('uses the global default timeout when one is not specified on an entry', () => {
|
||||
const sourceOnlyRequired = {
|
||||
id: 'roundtrip-only',
|
||||
name: 'Only required fields',
|
||||
url: 'https://example.test/mcp',
|
||||
description: 'Smoke entry for parser roundtrip with default timeout.',
|
||||
enabled: true
|
||||
};
|
||||
|
||||
const parsed = parseMcpServerSettings(JSON.stringify([sourceOnlyRequired]));
|
||||
const entry = parsed[0];
|
||||
|
||||
expect(entry?.requestTimeoutSeconds).toBe(DEFAULT_MCP_CONFIG.requestTimeoutSeconds);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { redactValue } from '$lib/utils/redact';
|
||||
|
||||
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,124 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
getRequestUrl,
|
||||
getRequestMethod,
|
||||
getRequestBody,
|
||||
summarizeRequestBody,
|
||||
formatDiagnosticErrorMessage,
|
||||
extractJsonRpcMethods
|
||||
} from '$lib/utils/request-helpers';
|
||||
|
||||
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([
|
||||
{ jsonrpc: '2.0', id: 1, method: 'initialize' },
|
||||
{ jsonrpc: '2.0', method: 'notifications/initialized' }
|
||||
]);
|
||||
expect(extractJsonRpcMethods(body)).toEqual(['initialize', 'notifications/initialized']);
|
||||
});
|
||||
|
||||
it('extracts method from a single JSON-RPC message', () => {
|
||||
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
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,73 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { sanitizeHeaders } from '$lib/utils/api-headers';
|
||||
import { CORS_PROXY_HEADER_PREFIX } from '$lib/constants';
|
||||
|
||||
describe('sanitizeHeaders', () => {
|
||||
it('returns empty object for undefined input', () => {
|
||||
expect(sanitizeHeaders()).toEqual({});
|
||||
});
|
||||
|
||||
it('passes through non-sensitive headers', () => {
|
||||
const headers = new Headers({ 'content-type': 'application/json', accept: 'text/html' });
|
||||
expect(sanitizeHeaders(headers)).toEqual({
|
||||
'content-type': 'application/json',
|
||||
accept: 'text/html'
|
||||
});
|
||||
});
|
||||
|
||||
it('redacts known sensitive headers', () => {
|
||||
const headers = new Headers({
|
||||
authorization: 'Bearer secret',
|
||||
'x-api-key': 'key-123',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
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({
|
||||
'x-vendor-key': 'vendor-secret',
|
||||
'content-type': 'application/json'
|
||||
});
|
||||
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,74 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ChatService } from '$lib/services/chat.service';
|
||||
import type { ApiStreamSession } from '$lib/types';
|
||||
|
||||
function makeSession(overrides: Partial<ApiStreamSession>): ApiStreamSession {
|
||||
return {
|
||||
conversation_id: 'conv',
|
||||
is_done: true,
|
||||
total_bytes: 0,
|
||||
started_at: 0,
|
||||
completed_at: 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,128 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
// node env unit project has no DOM, install a minimal localStorage backed by a Map
|
||||
beforeAll(() => {
|
||||
const store = new Map<string, string>();
|
||||
const polyfill: Storage = {
|
||||
get length() {
|
||||
return store.size;
|
||||
},
|
||||
clear: () => store.clear(),
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
key: (i) => Array.from(store.keys())[i] ?? null,
|
||||
removeItem: (k) => {
|
||||
store.delete(k);
|
||||
},
|
||||
setItem: (k, v) => {
|
||||
store.set(k, String(v));
|
||||
}
|
||||
};
|
||||
(globalThis as unknown as { localStorage: Storage }).localStorage = polyfill;
|
||||
});
|
||||
|
||||
import { ChatService } from '$lib/services/chat.service';
|
||||
import { STREAM_RESUME_LOCALSTORAGE_KEY_PREFIX } from '$lib/constants';
|
||||
|
||||
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('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,176 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
extractTemplateVariables,
|
||||
expandTemplate,
|
||||
isTemplateComplete,
|
||||
normalizeResourceUri
|
||||
} from '../../src/lib/utils/uri-template';
|
||||
import { URI_TEMPLATE_OPERATORS } from '../../src/lib/constants/uri-template';
|
||||
|
||||
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_OPERATORS.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_OPERATORS.FRAGMENT }]);
|
||||
});
|
||||
|
||||
it('handles path segment expansion', () => {
|
||||
const vars = extractTemplateVariables('http://example.com{/path}');
|
||||
expect(vars).toEqual([{ name: 'path', operator: URI_TEMPLATE_OPERATORS.PATH_SEGMENT }]);
|
||||
});
|
||||
|
||||
it('returns empty array for template without variables', () => {
|
||||
const vars = extractTemplateVariables('http://example.com/static');
|
||||
expect(vars).toEqual([]);
|
||||
});
|
||||
|
||||
it('strips explode modifier', () => {
|
||||
const vars = extractTemplateVariables('{list*}');
|
||||
expect(vars).toEqual([{ name: 'list', operator: '' }]);
|
||||
});
|
||||
|
||||
it('strips prefix modifier', () => {
|
||||
const vars = extractTemplateVariables('{value:5}');
|
||||
expect(vars).toEqual([{ name: 'value', operator: '' }]);
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user