diff --git a/AGENTS.md b/AGENTS.md index 76ba6277..d73be455 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -18,6 +18,7 @@ alwaysApply: true - [GT1a-l] Git, history safety, hooks/signing, lock files, and clean commits - [CC1a-d] Clean Code & DDD (Mandatory) - [ID1a-d] Idiomatic Patterns & Defaults +- [EV1a-f] Secrets and Env Vars (no secrets in properties; OpenAI/Qdrant via .env/env) - [RC1a-f] Root Cause Resolution (single implementation, no fallbacks, no shims/workarounds) - [FS1a-k] File Creation & Clean Architecture (search first, strict types, single responsibility) - [TY1a-d] Type Safety (strict generics, no raw types, no unchecked casts) @@ -70,6 +71,17 @@ alwaysApply: true - [ID1c] **No Reinventing**: Do not build custom utilities for things the platform already does (e.g., use standard `Optional`, `Stream`, Spring `RestTemplate`/`WebClient`). - [ID1d] **Dependencies**: Make careful use of dependencies. Do not make assumptions—use the correct idiomatic behavior to avoid boilerplate. +## [EV1] Secrets and Env Vars + +- [EV1a] **No Secrets In Properties/YAML**: Secrets are PROHIBITED in `.properties` and `.yml/.yaml` (including examples). Do not add secret-looking keys with blank defaults to property files. +- [EV1b] **Secrets Source Of Truth**: Secrets MUST be provided via `.env` (local) and environment variables (deployment). Deployment uses Coolify containers with BuildKit secrets; keep secrets out of tracked config. +- [EV1c] **Non-Secret Defaults In Properties**: All non-secret defaults and environment-specific overrides MUST live in Spring property files (`src/main/resources/application*.properties`) and Spring profiles. +- [EV1d] **Allowed `.env`/Env Vars For External Services**: OpenAI/GitHub Models and Qdrant connectivity MUST come from `.env`/environment variables: + - LLM (model/base-url/api-key): `OPENAI_API_KEY`, `OPENAI_BASE_URL`, `OPENAI_MODEL`, `GITHUB_TOKEN`, `GITHUB_MODELS_BASE_URL`, `GITHUB_MODELS_CHAT_MODEL` + - Qdrant (host/ports/tls/api-key): `QDRANT_HOST`, `QDRANT_PORT`, `QDRANT_REST_PORT`, `QDRANT_SSL`, `QDRANT_API_KEY` +- [EV1e] **No New Env Var Settings**: Do not introduce any additional env-var-driven settings without explicit written approval. +- [EV1f] **Dotenv Handling**: `.env` loading MUST remain supported for local development and scripts. Do not add dotenv libraries into the Java runtime; load env vars at the process level (Makefile/scripts/Coolify). + ## [RC1] Root Cause Resolution — No Fallbacks - [RC1a] **One Way**: Ship one proven implementation—no fallback paths, no "try X then Y", no silent degradation. @@ -183,13 +195,13 @@ alwaysApply: true - [TL1b] **Docker**: `docker compose up -d` for Qdrant vector store. - [TL1c] **Ingest**: `curl -X POST http://localhost:8080/api/ingest ...`. - [TL1d] **Stream**: `curl -N http://localhost:8080/api/chat/stream ...`. -- [TL1e] **Secrets**: `.env` for secrets (`GITHUB_TOKEN`, `QDRANT_URL`); never commit secrets. +- [TL1e] **Secrets**: Never commit secrets. Secrets MUST live in `.env` (local) and environment variables (deployment). Secrets are PROHIBITED in `.properties`/`.yml` files. ## [LM1] LLM & Streaming - [LM1a] **Settings**: Do not change any LLM settings without explicit written approval. - [LM1b] **No Fallback**: Do not auto-fallback or regress models across providers; surface error to user. -- [LM1c] **Config**: Use values from environment variables and `application.properties` exactly as configured. +- [LM1c] **Config**: OpenAI/GitHub Models model/base-url/api-key MUST come from `.env`/environment variables (see [EV1d]). All other LLM settings MUST come from Spring property files and `@ConfigurationProperties`. - [LM1d] **Behavior**: Allowed: logging diagnostics. Not allowed: silently changing LLM behavior. - [LM1e] **Streaming**: TTFB < 200ms, streaming start < 500ms. - [LM1f] **Events**: `text`, `citation`, `code`, `enrichment`, `suggestion`, `status`. diff --git a/frontend/config/oxlintrc.json b/frontend/config/oxlintrc.json index e28bec1b..fc460a63 100644 --- a/frontend/config/oxlintrc.json +++ b/frontend/config/oxlintrc.json @@ -31,9 +31,12 @@ "rules": { "no-empty": ["error", { "allowEmptyCatch": false }], "no-await-in-loop": "off", - "import/no-unassigned-import": ["error", { - "allow": ["**/*.css", "@testing-library/**"] - }], + "import/no-unassigned-import": [ + "error", + { + "allow": ["**/*.css", "@testing-library/**"] + } + ], "typescript/no-explicit-any": "error", "typescript/no-unsafe-type-assertion": "warn", "typescript/no-floating-promises": "warn", diff --git a/frontend/index.html b/frontend/index.html index 060a2e69..06ce72b3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,4 +1,4 @@ - + @@ -12,7 +12,10 @@ name="description" content="Learn Java faster with an AI tutor: streaming answers, code examples, and citations to official docs." /> - + Java Chat - AI-Powered Java Learning With Citations @@ -22,7 +25,11 @@ - + - + - + - + diff --git a/frontend/package.json b/frontend/package.json index df4abcc1..920c1b84 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,11 +1,8 @@ { "name": "java-chat-frontend", - "private": true, "version": "1.0.0", + "private": true, "type": "module", - "engines": { - "node": "22.17.0" - }, "scripts": { "dev": "vite", "build": "vite build", @@ -23,6 +20,13 @@ "format:check": "oxfmt --check .", "validate": "npm run format:check && npm run lint && npm run check" }, + "dependencies": { + "@types/dompurify": "^3.0.5", + "dompurify": "^3.3.1", + "highlight.js": "^11.10.0", + "marked": "^15.0.0", + "zod": "^3.25.76" + }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^5.0.0", "@testing-library/jest-dom": "^6.9.1", @@ -41,12 +45,10 @@ "vite": "^6.0.0", "vitest": "^4.0.18" }, - "dependencies": { - "@types/dompurify": "^3.0.5", - "dompurify": "^3.3.1", - "highlight.js": "^11.10.0", - "marked": "^15.0.0", - "zod": "^3.25.76" + "overrides": { + "eslint-plugin-zod": { + "zod": "$zod" + } }, "lint-staged": { "*.{js,jsx,ts,tsx}": [ @@ -54,9 +56,7 @@ "oxlint -c config/oxlintrc.json --type-aware" ] }, - "overrides": { - "eslint-plugin-zod": { - "zod": "$zod" - } + "engines": { + "node": "22.17.0" } } diff --git a/frontend/src/lib/components/ChatView.test.ts b/frontend/src/lib/components/ChatView.test.ts index 958907b3..25ba4831 100644 --- a/frontend/src/lib/components/ChatView.test.ts +++ b/frontend/src/lib/components/ChatView.test.ts @@ -1,69 +1,73 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, fireEvent } from '@testing-library/svelte' -import { tick } from 'svelte' +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, fireEvent } from "@testing-library/svelte"; +import { tick } from "svelte"; -const streamChatMock = vi.fn() +const streamChatMock = vi.fn(); -vi.mock('../services/chat', async () => { - const actualChatService = await vi.importActual('../services/chat') +vi.mock("../services/chat", async () => { + const actualChatService = + await vi.importActual("../services/chat"); return { ...actualChatService, - streamChat: streamChatMock - } -}) + streamChat: streamChatMock, + }; +}); async function renderChatView() { - const ChatViewComponent = (await import('./ChatView.svelte')).default - return render(ChatViewComponent) + const ChatViewComponent = (await import("./ChatView.svelte")).default; + return render(ChatViewComponent); } -describe('ChatView streaming stability', () => { +describe("ChatView streaming stability", () => { beforeEach(() => { - streamChatMock.mockReset() - }) + streamChatMock.mockReset(); + }); - it('keeps the assistant message DOM node stable when the stream completes', async () => { + it("keeps the assistant message DOM node stable when the stream completes", async () => { let completeStream: () => void = () => { - throw new Error('Expected stream completion callback to be set') - } + throw new Error("Expected stream completion callback to be set"); + }; streamChatMock.mockImplementation(async (_sessionId, _message, onChunk, options) => { - options?.onStatus?.({ message: 'Searching', details: 'Loading sources' }) + options?.onStatus?.({ message: "Searching", details: "Loading sources" }); - await Promise.resolve() - onChunk('Hello') + await Promise.resolve(); + onChunk("Hello"); - await Promise.resolve() - options?.onCitations?.([{ url: 'https://example.com', title: 'Example' }]) + await Promise.resolve(); + options?.onCitations?.([{ url: "https://example.com", title: "Example" }]); return new Promise((resolve) => { - completeStream = resolve - }) - }) + completeStream = resolve; + }); + }); - const { getByLabelText, getByRole, container, findByText } = await renderChatView() + const { getByLabelText, getByRole, container, findByText } = await renderChatView(); - const inputElement = getByLabelText('Message input') as HTMLTextAreaElement - await fireEvent.input(inputElement, { target: { value: 'Hi' } }) + const inputElement = getByLabelText("Message input"); + if (!(inputElement instanceof HTMLTextAreaElement)) { + throw new Error("Expected message input element to be a textarea"); + } + await fireEvent.input(inputElement, { target: { value: "Hi" } }); - const sendButton = getByRole('button', { name: 'Send message' }) - await fireEvent.click(sendButton) + const sendButton = getByRole("button", { name: "Send message" }); + await fireEvent.click(sendButton); - const assistantTextElement = await findByText('Hello') - await tick() + const assistantTextElement = await findByText("Hello"); + await tick(); - const assistantMessageElement = assistantTextElement.closest('.message.assistant') - expect(assistantMessageElement).not.toBeNull() + const assistantMessageElement = assistantTextElement.closest(".message.assistant"); + expect(assistantMessageElement).not.toBeNull(); - expect(container.querySelector('.message.assistant .cursor.visible')).not.toBeNull() + expect(container.querySelector(".message.assistant .cursor.visible")).not.toBeNull(); - completeStream() - await tick() + completeStream(); + await tick(); - const assistantTextElementAfter = await findByText('Hello') - const assistantMessageElementAfter = assistantTextElementAfter.closest('.message.assistant') + const assistantTextElementAfter = await findByText("Hello"); + const assistantMessageElementAfter = assistantTextElementAfter.closest(".message.assistant"); - expect(assistantMessageElementAfter).toBe(assistantMessageElement) - expect(container.querySelector('.message.assistant .cursor.visible')).toBeNull() - }) -}) + expect(assistantMessageElementAfter).toBe(assistantMessageElement); + expect(container.querySelector(".message.assistant .cursor.visible")).toBeNull(); + }); +}); diff --git a/frontend/src/lib/components/LearnView.test.ts b/frontend/src/lib/components/LearnView.test.ts index 8cc7ef20..326218c0 100644 --- a/frontend/src/lib/components/LearnView.test.ts +++ b/frontend/src/lib/components/LearnView.test.ts @@ -1,173 +1,197 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' -import { render, fireEvent } from '@testing-library/svelte' -import { tick } from 'svelte' - -const fetchTocMock = vi.fn() -const fetchLessonContentMock = vi.fn() -const fetchGuidedLessonCitationsMock = vi.fn() -const streamGuidedChatMock = vi.fn() - -vi.mock('../services/guided', async () => { - const actualGuidedService = await vi.importActual('../services/guided') +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, fireEvent } from "@testing-library/svelte"; +import { tick } from "svelte"; + +const fetchTocMock = vi.fn(); +const fetchLessonContentMock = vi.fn(); +const fetchGuidedLessonCitationsMock = vi.fn(); +const streamGuidedChatMock = vi.fn(); + +vi.mock("../services/guided", async () => { + const actualGuidedService = + await vi.importActual("../services/guided"); return { ...actualGuidedService, fetchTOC: fetchTocMock, fetchLessonContent: fetchLessonContentMock, fetchGuidedLessonCitations: fetchGuidedLessonCitationsMock, - streamGuidedChat: streamGuidedChatMock - } -}) + streamGuidedChat: streamGuidedChatMock, + }; +}); async function renderLearnView() { - const LearnViewComponent = (await import('./LearnView.svelte')).default - return render(LearnViewComponent) + const LearnViewComponent = (await import("./LearnView.svelte")).default; + return render(LearnViewComponent); } -describe('LearnView guided chat streaming stability', () => { +describe("LearnView guided chat streaming stability", () => { beforeEach(() => { - fetchTocMock.mockReset() - fetchLessonContentMock.mockReset() - fetchGuidedLessonCitationsMock.mockReset() - streamGuidedChatMock.mockReset() - }) + fetchTocMock.mockReset(); + fetchLessonContentMock.mockReset(); + fetchGuidedLessonCitationsMock.mockReset(); + streamGuidedChatMock.mockReset(); + }); afterEach(() => { - vi.unstubAllGlobals() - }) + vi.unstubAllGlobals(); + }); - it('keeps the guided assistant message DOM node stable when the stream completes', async () => { - fetchTocMock.mockResolvedValue([{ slug: 'intro', title: 'Test Lesson', summary: 'Lesson summary', keywords: [] }]) + it("keeps the guided assistant message DOM node stable when the stream completes", async () => { + fetchTocMock.mockResolvedValue([ + { slug: "intro", title: "Test Lesson", summary: "Lesson summary", keywords: [] }, + ]); - fetchLessonContentMock.mockResolvedValue({ markdown: '# Lesson', cached: false }) - fetchGuidedLessonCitationsMock.mockResolvedValue({ success: true, citations: [] }) + fetchLessonContentMock.mockResolvedValue({ markdown: "# Lesson", cached: false }); + fetchGuidedLessonCitationsMock.mockResolvedValue({ success: true, citations: [] }); let completeStream: () => void = () => { - throw new Error('Expected guided stream completion callback to be set') - } + throw new Error("Expected guided stream completion callback to be set"); + }; streamGuidedChatMock.mockImplementation(async (_sessionId, _slug, _message, callbacks) => { - callbacks.onStatus?.({ message: 'Searching', details: 'Loading sources' }) + callbacks.onStatus?.({ message: "Searching", details: "Loading sources" }); - await Promise.resolve() - callbacks.onChunk('Hello') + await Promise.resolve(); + callbacks.onChunk("Hello"); - await Promise.resolve() - callbacks.onCitations?.([{ url: 'https://example.com', title: 'Example' }]) + await Promise.resolve(); + callbacks.onCitations?.([{ url: "https://example.com", title: "Example" }]); return new Promise((resolve) => { - completeStream = resolve - }) - }) + completeStream = resolve; + }); + }); - const { findByRole, getByLabelText, getByRole, container, findByText } = await renderLearnView() + const { findByRole, getByLabelText, getByRole, container, findByText } = + await renderLearnView(); - const lessonButton = await findByRole('button', { name: /test lesson/i }) - await fireEvent.click(lessonButton) + const lessonButton = await findByRole("button", { name: /test lesson/i }); + await fireEvent.click(lessonButton); - const inputElement = getByLabelText('Message input') as HTMLTextAreaElement - await fireEvent.input(inputElement, { target: { value: 'Hi' } }) + const inputElement = getByLabelText("Message input"); + if (!(inputElement instanceof HTMLTextAreaElement)) { + throw new Error("Expected message input element to be a textarea"); + } + await fireEvent.input(inputElement, { target: { value: "Hi" } }); - const sendButton = getByRole('button', { name: 'Send message' }) - await fireEvent.click(sendButton) + const sendButton = getByRole("button", { name: "Send message" }); + await fireEvent.click(sendButton); - const assistantTextElement = await findByText('Hello') - await tick() + const assistantTextElement = await findByText("Hello"); + await tick(); - const assistantMessageElement = assistantTextElement.closest('.chat-panel--desktop .message.assistant') - expect(assistantMessageElement).not.toBeNull() + const assistantMessageElement = assistantTextElement.closest( + ".chat-panel--desktop .message.assistant", + ); + expect(assistantMessageElement).not.toBeNull(); - expect(container.querySelector('.chat-panel--desktop .message.assistant .cursor.visible')).not.toBeNull() + expect( + container.querySelector(".chat-panel--desktop .message.assistant .cursor.visible"), + ).not.toBeNull(); - completeStream() - await tick() + completeStream(); + await tick(); - const assistantTextElementAfter = await findByText('Hello') - const assistantMessageElementAfter = assistantTextElementAfter.closest('.chat-panel--desktop .message.assistant') + const assistantTextElementAfter = await findByText("Hello"); + const assistantMessageElementAfter = assistantTextElementAfter.closest( + ".chat-panel--desktop .message.assistant", + ); - expect(assistantMessageElementAfter).toBe(assistantMessageElement) - expect(container.querySelector('.chat-panel--desktop .message.assistant .cursor.visible')).toBeNull() - }) + expect(assistantMessageElementAfter).toBe(assistantMessageElement); + expect( + container.querySelector(".chat-panel--desktop .message.assistant .cursor.visible"), + ).toBeNull(); + }); - it('cancels the guided stream and clears messages without late writes after clear chat', async () => { - fetchTocMock.mockResolvedValue([{ slug: 'intro', title: 'Test Lesson', summary: 'Lesson summary', keywords: [] }]) + it("cancels the guided stream and clears messages without late writes after clear chat", async () => { + fetchTocMock.mockResolvedValue([ + { slug: "intro", title: "Test Lesson", summary: "Lesson summary", keywords: [] }, + ]); - fetchLessonContentMock.mockResolvedValue({ markdown: '# Lesson', cached: false }) - fetchGuidedLessonCitationsMock.mockResolvedValue({ success: true, citations: [] }) + fetchLessonContentMock.mockResolvedValue({ markdown: "# Lesson", cached: false }); + fetchGuidedLessonCitationsMock.mockResolvedValue({ success: true, citations: [] }); const fetchMock = vi.fn().mockResolvedValue({ ok: true, status: 200, - statusText: 'OK', - text: async () => '' - }) - vi.stubGlobal('fetch', fetchMock) + statusText: "OK", + text: async () => "", + }); + vi.stubGlobal("fetch", fetchMock); - const guidedSessionIds: string[] = [] - const abortSignalsByStream: Array = [] - let hasIssuedClear = false + const guidedSessionIds: string[] = []; + const abortSignalsByStream: Array = []; + let hasIssuedClear = false; streamGuidedChatMock.mockImplementation(async (sessionId, _slug, _message, callbacks) => { - guidedSessionIds.push(sessionId) - abortSignalsByStream.push(callbacks.signal) - callbacks.onChunk(hasIssuedClear ? 'Hello again' : 'Hello') + guidedSessionIds.push(sessionId); + abortSignalsByStream.push(callbacks.signal); + callbacks.onChunk(hasIssuedClear ? "Hello again" : "Hello"); if (hasIssuedClear) { - return + return; } - const streamAbortSignal = callbacks.signal + const streamAbortSignal = callbacks.signal; if (!streamAbortSignal) { - throw new Error('Expected LearnView to pass an AbortSignal for guided streaming') + throw new Error("Expected LearnView to pass an AbortSignal for guided streaming"); } return new Promise((resolve) => { streamAbortSignal.addEventListener( - 'abort', + "abort", () => { // Simulate a late chunk arriving after Clear Chat. - Promise.resolve().then(() => callbacks.onChunk('Late chunk')) - resolve() + void Promise.resolve().then(() => callbacks.onChunk("Late chunk")); + resolve(); }, - { once: true } - ) - }) - }) + { once: true }, + ); + }); + }); - const { findByRole, getByLabelText, getByRole, findByText, queryByText } = await renderLearnView() + const { findByRole, getByLabelText, getByRole, findByText, queryByText } = + await renderLearnView(); - const lessonButton = await findByRole('button', { name: /test lesson/i }) - await fireEvent.click(lessonButton) + const lessonButton = await findByRole("button", { name: /test lesson/i }); + await fireEvent.click(lessonButton); - const inputElement = getByLabelText('Message input') as HTMLTextAreaElement - await fireEvent.input(inputElement, { target: { value: 'Hi' } }) + const inputElement = getByLabelText("Message input"); + if (!(inputElement instanceof HTMLTextAreaElement)) { + throw new Error("Expected message input element to be a textarea"); + } + await fireEvent.input(inputElement, { target: { value: "Hi" } }); - const sendButton = getByRole('button', { name: 'Send message' }) - await fireEvent.click(sendButton) + const sendButton = getByRole("button", { name: "Send message" }); + await fireEvent.click(sendButton); - await findByText('Hello') + await findByText("Hello"); - const clearChatButton = getByRole('button', { name: 'Clear chat' }) - await fireEvent.click(clearChatButton) - hasIssuedClear = true - await tick() + const clearChatButton = getByRole("button", { name: "Clear chat" }); + await fireEvent.click(clearChatButton); + hasIssuedClear = true; + await tick(); - expect(queryByText('Hello')).toBeNull() - expect(queryByText('Late chunk')).toBeNull() + expect(queryByText("Hello")).toBeNull(); + expect(queryByText("Late chunk")).toBeNull(); - const inputElementAfterClear = getByLabelText('Message input') as HTMLTextAreaElement - await fireEvent.input(inputElementAfterClear, { target: { value: 'Hi again' } }) + const inputElementAfterClear = getByLabelText("Message input"); + if (!(inputElementAfterClear instanceof HTMLTextAreaElement)) { + throw new Error("Expected message input element to be a textarea"); + } + await fireEvent.input(inputElementAfterClear, { target: { value: "Hi again" } }); - const sendButtonAfterClear = getByRole('button', { name: 'Send message' }) - await fireEvent.click(sendButtonAfterClear) + const sendButtonAfterClear = getByRole("button", { name: "Send message" }); + await fireEvent.click(sendButtonAfterClear); - await findByText('Hello again') + await findByText("Hello again"); - expect(guidedSessionIds).toHaveLength(2) - expect(guidedSessionIds[1]).not.toBe(guidedSessionIds[0]) + expect(guidedSessionIds).toHaveLength(2); + expect(guidedSessionIds[1]).not.toBe(guidedSessionIds[0]); expect(fetchMock).toHaveBeenCalledWith( `/api/chat/clear?sessionId=${encodeURIComponent(guidedSessionIds[0])}`, - expect.objectContaining({ method: 'POST' }) - ) - expect(abortSignalsByStream[0]?.aborted ?? false).toBe(true) - }) -}) + expect.objectContaining({ method: "POST" }), + ); + expect(abortSignalsByStream[0]?.aborted ?? false).toBe(true); + }); +}); diff --git a/frontend/src/lib/composables/createScrollAnchor.svelte.ts b/frontend/src/lib/composables/createScrollAnchor.svelte.ts index d263f912..69b9b348 100644 --- a/frontend/src/lib/composables/createScrollAnchor.svelte.ts +++ b/frontend/src/lib/composables/createScrollAnchor.svelte.ts @@ -49,7 +49,7 @@ * ``` */ -import { tick } from 'svelte' +import { tick } from "svelte"; /** Configuration options for scroll indicator behavior. */ export interface ScrollAnchorOptions { @@ -58,19 +58,19 @@ export interface ScrollAnchorOptions { * When user scrolls past this percentage, the indicator hides. * @default 0.95 (95% - user is within 5% of bottom) */ - nearBottomThreshold?: number + nearBottomThreshold?: number; /** * Delay before showing the new content indicator (in milliseconds). * Prevents flicker for brief scroll-aways. * @default 150 */ - indicatorDelayMs?: number + indicatorDelayMs?: number; } /** Default configuration values. */ -const DEFAULT_NEAR_BOTTOM_THRESHOLD = 0.95 -const DEFAULT_INDICATOR_DELAY_MS = 150 +const DEFAULT_NEAR_BOTTOM_THRESHOLD = 0.95; +const DEFAULT_INDICATOR_DELAY_MS = 150; /** * Creates a reactive scroll indicator for chat containers. @@ -80,33 +80,33 @@ const DEFAULT_INDICATOR_DELAY_MS = 150 * only the "new content" indicator and manual jump-to-bottom are provided. */ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { - const nearBottomThreshold = options.nearBottomThreshold ?? DEFAULT_NEAR_BOTTOM_THRESHOLD - const indicatorDelayMs = options.indicatorDelayMs ?? DEFAULT_INDICATOR_DELAY_MS + const nearBottomThreshold = options.nearBottomThreshold ?? DEFAULT_NEAR_BOTTOM_THRESHOLD; + const indicatorDelayMs = options.indicatorDelayMs ?? DEFAULT_INDICATOR_DELAY_MS; // Internal state - let container: HTMLElement | null = null - let indicatorTimeoutId: ReturnType | null = null + let container: HTMLElement | null = null; + let indicatorTimeoutId: ReturnType | null = null; // Reactive state (Svelte 5 runes) - let unseenCount = $state(0) - let showIndicator = $state(false) + let unseenCount = $state(0); + let showIndicator = $state(false); /** * Checks if the container is scrolled near the bottom. * Uses percentage-based threshold (default 95%). */ function isNearBottom(): boolean { - if (!container) return true - const { scrollTop, scrollHeight, clientHeight } = container + if (!container) return true; + const { scrollTop, scrollHeight, clientHeight } = container; // Handle edge case: content fits without scrolling - if (scrollHeight <= clientHeight) return true + if (scrollHeight <= clientHeight) return true; // Calculate scroll percentage (0 = top, 1 = bottom) - const maxScroll = scrollHeight - clientHeight - const scrollPercentage = scrollTop / maxScroll + const maxScroll = scrollHeight - clientHeight; + const scrollPercentage = scrollTop / maxScroll; - return scrollPercentage >= nearBottomThreshold + return scrollPercentage >= nearBottomThreshold; } /** @@ -114,17 +114,17 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { */ function updateIndicatorVisibility(): void { if (indicatorTimeoutId) { - clearTimeout(indicatorTimeoutId) - indicatorTimeoutId = null + clearTimeout(indicatorTimeoutId); + indicatorTimeoutId = null; } if (unseenCount > 0 && !isNearBottom()) { // Delay showing indicator to prevent flicker indicatorTimeoutId = setTimeout(() => { - showIndicator = true - }, indicatorDelayMs) + showIndicator = true; + }, indicatorDelayMs); } else { - showIndicator = false + showIndicator = false; } } @@ -133,12 +133,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { * Internal helper that doesn't rely on `this` binding. */ function clearIndicatorStateInternal(): void { - unseenCount = 0 - showIndicator = false + unseenCount = 0; + showIndicator = false; if (indicatorTimeoutId) { - clearTimeout(indicatorTimeoutId) - indicatorTimeoutId = null + clearTimeout(indicatorTimeoutId); + indicatorTimeoutId = null; } } @@ -146,15 +146,15 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { * Performs the actual scroll-to-bottom with motion preferences. */ async function performScroll(): Promise { - await tick() - if (!container) return + await tick(); + if (!container) return; - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; container.scrollTo({ top: container.scrollHeight, - behavior: prefersReducedMotion ? 'auto' : 'smooth' - }) + behavior: prefersReducedMotion ? "auto" : "smooth", + }); } return { @@ -164,12 +164,12 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { /** Number of content updates since user was not at bottom. */ get unseenCount(): number { - return unseenCount + return unseenCount; }, /** Whether to show the "new content" indicator. */ get showIndicator(): boolean { - return showIndicator + return showIndicator; }, // ───────────────────────────────────────────────────────────────────────── @@ -181,7 +181,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { * Call this when the container is mounted or changes. */ attach(element: HTMLElement | null): void { - container = element + container = element; }, /** @@ -190,8 +190,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { */ cleanup(): void { if (indicatorTimeoutId) { - clearTimeout(indicatorTimeoutId) - indicatorTimeoutId = null + clearTimeout(indicatorTimeoutId); + indicatorTimeoutId = null; } }, @@ -207,16 +207,16 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { * - Hides indicator */ onUserScroll(): void { - if (!container) return + if (!container) return; if (isNearBottom()) { // User reached near-bottom, clear indicator - unseenCount = 0 - showIndicator = false + unseenCount = 0; + showIndicator = false; if (indicatorTimeoutId) { - clearTimeout(indicatorTimeoutId) - indicatorTimeoutId = null + clearTimeout(indicatorTimeoutId); + indicatorTimeoutId = null; } } }, @@ -230,8 +230,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { */ onNewMessageStarted(): void { if (!isNearBottom()) { - unseenCount++ - updateIndicatorVisibility() + unseenCount++; + updateIndicatorVisibility(); } }, @@ -246,7 +246,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { if (!isNearBottom()) { // User is scrolled up - update visibility but don't increment count // (count is incremented once per message via onNewMessageStarted) - updateIndicatorVisibility() + updateIndicatorVisibility(); } // User at bottom - no need for indicator }, @@ -256,7 +256,7 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { * Public API that delegates to internal helper. */ clearIndicatorState(): void { - clearIndicatorStateInternal() + clearIndicatorStateInternal(); }, /** @@ -267,8 +267,8 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { * - Simply scrolls once and clears the indicator */ async scrollOnce(): Promise { - clearIndicatorStateInternal() - await performScroll() + clearIndicatorStateInternal(); + await performScroll(); }, /** @@ -279,18 +279,18 @@ export function createScrollAnchor(options: ScrollAnchorOptions = {}) { * issues when passed as a callback prop. */ async jumpToBottom(): Promise { - clearIndicatorStateInternal() - await performScroll() + clearIndicatorStateInternal(); + await performScroll(); }, /** * Resets all state. Use when clearing chat or switching contexts. */ reset(): void { - clearIndicatorStateInternal() - } - } + clearIndicatorStateInternal(); + }, + }; } /** Type for the scroll anchor instance. */ -export type ScrollAnchor = ReturnType +export type ScrollAnchor = ReturnType; diff --git a/frontend/src/lib/composables/createStreamingState.svelte.ts b/frontend/src/lib/composables/createStreamingState.svelte.ts index 58ab5594..5a384d59 100644 --- a/frontend/src/lib/composables/createStreamingState.svelte.ts +++ b/frontend/src/lib/composables/createStreamingState.svelte.ts @@ -5,7 +5,7 @@ * with optional timer-based status message persistence. */ -import type { StreamStatus } from '../validation/schemas' +import type { StreamStatus } from "../validation/schemas"; /** Configuration options for streaming state behavior. */ export interface StreamingStateOptions { @@ -17,28 +17,28 @@ export interface StreamingStateOptions { * * ChatView uses 800ms to let users read "Done" status; LearnView uses 0. */ - statusClearDelayMs?: number + statusClearDelayMs?: number; } /** Streaming state with reactive getters and action methods. */ export interface StreamingState { /** Whether a stream is currently active. */ - readonly isStreaming: boolean + readonly isStreaming: boolean; /** Current status message (e.g., "Searching...", "Done"). */ - readonly statusMessage: string + readonly statusMessage: string; /** Additional status details. */ - readonly statusDetails: string + readonly statusDetails: string; /** Marks stream as active and resets content/status. */ - startStream: () => void + startStream: () => void; /** Updates status message and optional details. */ - updateStatus: (status: StreamStatus) => void + updateStatus: (status: StreamStatus) => void; /** Marks stream as complete and schedules status clearing. */ - finishStream: () => void + finishStream: () => void; /** Immediately resets all state (cancels any pending timers). */ - reset: () => void + reset: () => void; /** Cleanup function to clear timers - call from $effect cleanup. */ - cleanup: () => void + cleanup: () => void; } /** @@ -74,82 +74,82 @@ export interface StreamingState { * ``` */ export function createStreamingState(options: StreamingStateOptions = {}): StreamingState { - const { statusClearDelayMs = 0 } = options + const { statusClearDelayMs = 0 } = options; // Internal reactive state - let isStreaming = $state(false) - let statusMessage = $state('') - let statusDetails = $state('') + let isStreaming = $state(false); + let statusMessage = $state(""); + let statusDetails = $state(""); // Timer for delayed status clearing - let statusClearTimer: ReturnType | null = null + let statusClearTimer: ReturnType | null = null; function cancelStatusTimer(): void { if (statusClearTimer) { - clearTimeout(statusClearTimer) - statusClearTimer = null + clearTimeout(statusClearTimer); + statusClearTimer = null; } } function clearStatusNow(): void { - cancelStatusTimer() - statusMessage = '' - statusDetails = '' + cancelStatusTimer(); + statusMessage = ""; + statusDetails = ""; } function clearStatusDelayed(): void { if (statusClearDelayMs <= 0) { - clearStatusNow() - return + clearStatusNow(); + return; } - cancelStatusTimer() + cancelStatusTimer(); statusClearTimer = setTimeout(() => { - statusMessage = '' - statusDetails = '' - statusClearTimer = null - }, statusClearDelayMs) + statusMessage = ""; + statusDetails = ""; + statusClearTimer = null; + }, statusClearDelayMs); } return { // Reactive getters get isStreaming() { - return isStreaming + return isStreaming; }, get statusMessage() { - return statusMessage + return statusMessage; }, get statusDetails() { - return statusDetails + return statusDetails; }, // Actions startStream() { - cancelStatusTimer() - isStreaming = true - statusMessage = '' - statusDetails = '' + cancelStatusTimer(); + isStreaming = true; + statusMessage = ""; + statusDetails = ""; }, updateStatus(status: StreamStatus) { - statusMessage = status.message - statusDetails = status.details ?? '' + statusMessage = status.message; + statusDetails = status.details ?? ""; }, finishStream() { - isStreaming = false - clearStatusDelayed() + isStreaming = false; + clearStatusDelayed(); }, reset() { - cancelStatusTimer() - isStreaming = false - statusMessage = '' - statusDetails = '' + cancelStatusTimer(); + isStreaming = false; + statusMessage = ""; + statusDetails = ""; }, cleanup() { - cancelStatusTimer() - } - } + cancelStatusTimer(); + }, + }; } diff --git a/frontend/src/lib/services/chat.test.ts b/frontend/src/lib/services/chat.test.ts index 2a422e24..c05f620a 100644 --- a/frontend/src/lib/services/chat.test.ts +++ b/frontend/src/lib/services/chat.test.ts @@ -1,100 +1,100 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from "vitest"; const { streamSseMock } = vi.hoisted(() => { - return { streamSseMock: vi.fn() } -}) + return { streamSseMock: vi.fn() }; +}); -vi.mock('./sse', () => { - return { streamSse: streamSseMock } -}) +vi.mock("./sse", () => { + return { streamSse: streamSseMock }; +}); -import { streamChat } from './chat' +import { streamChat } from "./chat"; -describe('streamChat recovery', () => { +describe("streamChat recovery", () => { beforeEach(() => { - streamSseMock.mockReset() - }) + streamSseMock.mockReset(); + }); - it('retries once for recoverable overflow failure before any streamed chunk', async () => { - streamSseMock.mockRejectedValueOnce(new Error('OverflowException: malformed response frame')) + it("retries once for recoverable overflow failure before any streamed chunk", async () => { + streamSseMock.mockRejectedValueOnce(new Error("OverflowException: malformed response frame")); streamSseMock.mockImplementationOnce(async (_url, _body, callbacks) => { - callbacks.onText('Recovered response') - }) + callbacks.onText("Recovered response"); + }); - const onChunk = vi.fn() - const onStatus = vi.fn() - const onError = vi.fn() + const onChunk = vi.fn(); + const onStatus = vi.fn(); + const onError = vi.fn(); await expect( - streamChat('session-1', 'What is new in Java 25?', onChunk, { onStatus, onError }) - ).resolves.toBeUndefined() + streamChat("session-1", "What is new in Java 25?", onChunk, { onStatus, onError }), + ).resolves.toBeUndefined(); - expect(streamSseMock).toHaveBeenCalledTimes(2) + expect(streamSseMock).toHaveBeenCalledTimes(2); expect(onStatus).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Temporary stream issue detected' - }) - ) + message: "Temporary stream issue detected", + }), + ); expect(onStatus).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Streaming recovered' - }) - ) - expect(onChunk).toHaveBeenCalledWith('Recovered response') - expect(onError).not.toHaveBeenCalled() - }) - - it('does not retry when a chunk already streamed to the UI', async () => { + message: "Streaming recovered", + }), + ); + expect(onChunk).toHaveBeenCalledWith("Recovered response"); + expect(onError).not.toHaveBeenCalled(); + }); + + it("does not retry when a chunk already streamed to the UI", async () => { streamSseMock.mockImplementationOnce(async (_url, _body, callbacks) => { - callbacks.onText('Partial answer') - throw new Error('OverflowException: malformed response frame') - }) + callbacks.onText("Partial answer"); + throw new Error("OverflowException: malformed response frame"); + }); - const onChunk = vi.fn() - const onStatus = vi.fn() - const onError = vi.fn() + const onChunk = vi.fn(); + const onStatus = vi.fn(); + const onError = vi.fn(); await expect( - streamChat('session-2', 'Explain records', onChunk, { onStatus, onError }) - ).rejects.toThrow('OverflowException: malformed response frame') + streamChat("session-2", "Explain records", onChunk, { onStatus, onError }), + ).rejects.toThrow("OverflowException: malformed response frame"); - expect(streamSseMock).toHaveBeenCalledTimes(1) - expect(onChunk).toHaveBeenCalledWith('Partial answer') + expect(streamSseMock).toHaveBeenCalledTimes(1); + expect(onChunk).toHaveBeenCalledWith("Partial answer"); expect(onStatus).not.toHaveBeenCalledWith( expect.objectContaining({ - message: 'Temporary stream issue detected' - }) - ) + message: "Temporary stream issue detected", + }), + ); expect(onError).toHaveBeenCalledWith({ - message: 'OverflowException: malformed response frame' - }) - }) + message: "OverflowException: malformed response frame", + }); + }); - it('honors backend non-retryable stream status metadata', async () => { + it("honors backend non-retryable stream status metadata", async () => { streamSseMock.mockImplementationOnce(async (_url, _body, callbacks) => { callbacks.onStatus?.({ - message: 'Provider returned fatal stream error', - code: 'stream.provider.fatal-error', + message: "Provider returned fatal stream error", + code: "stream.provider.fatal-error", retryable: false, - stage: 'stream' - }) - throw new Error('Unexpected provider failure') - }) + stage: "stream", + }); + throw new Error("Unexpected provider failure"); + }); - const onChunk = vi.fn() - const onStatus = vi.fn() - const onError = vi.fn() + const onChunk = vi.fn(); + const onStatus = vi.fn(); + const onError = vi.fn(); await expect( - streamChat('session-3', 'Explain virtual threads', onChunk, { onStatus, onError }) - ).rejects.toThrow('Unexpected provider failure') + streamChat("session-3", "Explain virtual threads", onChunk, { onStatus, onError }), + ).rejects.toThrow("Unexpected provider failure"); - expect(streamSseMock).toHaveBeenCalledTimes(1) + expect(streamSseMock).toHaveBeenCalledTimes(1); expect(onStatus).toHaveBeenCalledWith( expect.objectContaining({ - code: 'stream.provider.fatal-error', - retryable: false - }) - ) - }) -}) + code: "stream.provider.fatal-error", + retryable: false, + }), + ); + }); +}); diff --git a/frontend/src/lib/services/chat.ts b/frontend/src/lib/services/chat.ts index 54684525..6e13d6bf 100644 --- a/frontend/src/lib/services/chat.ts +++ b/frontend/src/lib/services/chat.ts @@ -9,34 +9,34 @@ import { CitationsArraySchema, type StreamStatus, type StreamError, - type Citation -} from '../validation/schemas' -import { validateFetchJson } from '../validation/validate' -import { csrfHeader, extractApiErrorMessage, fetchWithCsrfRetry } from './csrf' -import { streamWithRetry } from './streamRecovery' + type Citation, +} from "../validation/schemas"; +import { validateFetchJson } from "../validation/validate"; +import { csrfHeader, extractApiErrorMessage, fetchWithCsrfRetry } from "./csrf"; +import { streamWithRetry } from "./streamRecovery"; -export type { StreamStatus, StreamError, Citation } +export type { StreamStatus, StreamError, Citation }; export interface ChatMessage { /** Stable client-side identifier for rendering and list keying. */ - messageId: string - role: 'user' | 'assistant' - messageText: string - timestamp: number - isError?: boolean + messageId: string; + role: "user" | "assistant"; + messageText: string; + timestamp: number; + isError?: boolean; } export interface StreamChatOptions { - onStatus?: (status: StreamStatus) => void - onError?: (error: StreamError) => void - onCitations?: (citations: Citation[]) => void - signal?: AbortSignal + onStatus?: (status: StreamStatus) => void; + onError?: (error: StreamError) => void; + onCitations?: (citations: Citation[]) => void; + signal?: AbortSignal; } /** Result type for citation fetches - distinguishes empty results from errors. */ export type CitationFetchResult = | { success: true; citations: Citation[] } - | { success: false; error: string } + | { success: false; error: string }; /** * Stream chat response from the backend using Server-Sent Events. @@ -50,20 +50,20 @@ export async function streamChat( sessionId: string, message: string, onChunk: (chunk: string) => void, - options: StreamChatOptions = {} + options: StreamChatOptions = {}, ): Promise { return streamWithRetry( - '/api/chat/stream', + "/api/chat/stream", { sessionId, latest: message }, { onChunk, onStatus: options.onStatus, onError: options.onError, onCitations: options.onCitations, - signal: options.signal + signal: options.signal, }, - 'chat.ts' - ) + "chat.ts", + ); } /** @@ -72,27 +72,27 @@ export async function streamChat( * @param sessionId - Session identifier to clear on the backend. */ export async function clearChatSession(sessionId: string): Promise { - const normalizedSessionId = sessionId.trim() + const normalizedSessionId = sessionId.trim(); if (!normalizedSessionId) { - throw new Error('Session ID is required') + throw new Error("Session ID is required"); } const clearSessionResponse = await fetchWithCsrfRetry( `/api/chat/clear?sessionId=${encodeURIComponent(normalizedSessionId)}`, { - method: 'POST', + method: "POST", headers: { - ...csrfHeader() - } + ...csrfHeader(), + }, }, - 'clearChatSession' - ) + "clearChatSession", + ); if (!clearSessionResponse.ok) { - const apiMessage = await extractApiErrorMessage(clearSessionResponse, 'clearChatSession') - const httpStatusLabel = `HTTP ${clearSessionResponse.status}` - const suffix = apiMessage ? `: ${apiMessage}` : `: ${httpStatusLabel}` - throw new Error(`Failed to clear chat session${suffix}`) + const apiMessage = await extractApiErrorMessage(clearSessionResponse, "clearChatSession"); + const httpStatusLabel = `HTTP ${clearSessionResponse.status}`; + const suffix = apiMessage ? `: ${apiMessage}` : `: ${httpStatusLabel}`; + throw new Error(`Failed to clear chat session${suffix}`); } } @@ -105,25 +105,26 @@ export async function clearChatSession(sessionId: string): Promise { */ export async function fetchCitationsByEndpoint( citationUrl: string, - logLabel: string + logLabel: string, ): Promise { try { - const citationsResponse = await fetch(citationUrl) + const citationsResponse = await fetch(citationUrl); const citationsValidation = await validateFetchJson( citationsResponse, CitationsArraySchema, - logLabel - ) + logLabel, + ); if (!citationsValidation.success) { - return { success: false, error: citationsValidation.error } + return { success: false, error: citationsValidation.error }; } - return { success: true, citations: citationsValidation.validated } + return { success: true, citations: citationsValidation.validated }; } catch (error) { - const errorMessage = error instanceof Error ? error.message : 'Network error fetching citations' - console.error(`[${logLabel}] Unexpected error:`, error) - return { success: false, error: errorMessage } + const errorMessage = + error instanceof Error ? error.message : "Network error fetching citations"; + console.error(`[${logLabel}] Unexpected error:`, error); + return { success: false, error: errorMessage }; } } @@ -135,6 +136,6 @@ export async function fetchCitationsByEndpoint( export async function fetchCitations(query: string): Promise { return fetchCitationsByEndpoint( `/api/chat/citations?q=${encodeURIComponent(query)}`, - `fetchCitations [query=${query}]` - ) + `fetchCitations [query=${query}]`, + ); } diff --git a/frontend/src/lib/services/csrf.test.ts b/frontend/src/lib/services/csrf.test.ts index 21206985..d9404c2c 100644 --- a/frontend/src/lib/services/csrf.test.ts +++ b/frontend/src/lib/services/csrf.test.ts @@ -87,9 +87,7 @@ describe("csrf helpers", () => { expect(response.status).toBe(200); expect(fetchMock).toHaveBeenCalledTimes(3); - const retriedHeaders = new Headers( - (fetchMock.mock.calls[2][1] as RequestInit).headers ?? undefined, - ); + const retriedHeaders = new Headers(fetchMock.mock.calls[2][1]?.headers ?? undefined); expect(retriedHeaders.get(CSRF_HEADER_NAME)).toBe("fresh-token"); }); diff --git a/frontend/src/lib/services/guided.test.ts b/frontend/src/lib/services/guided.test.ts index 2fb4ebee..8e8369d0 100644 --- a/frontend/src/lib/services/guided.test.ts +++ b/frontend/src/lib/services/guided.test.ts @@ -1,114 +1,114 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from "vitest"; const { streamSseMock } = vi.hoisted(() => { - return { streamSseMock: vi.fn() } -}) + return { streamSseMock: vi.fn() }; +}); -vi.mock('./sse', () => { - return { streamSse: streamSseMock } -}) +vi.mock("./sse", () => { + return { streamSse: streamSseMock }; +}); -import { streamGuidedChat } from './guided' +import { streamGuidedChat } from "./guided"; -describe('streamGuidedChat recovery', () => { +describe("streamGuidedChat recovery", () => { beforeEach(() => { - streamSseMock.mockReset() - }) + streamSseMock.mockReset(); + }); - it('retries once for recoverable invalid stream errors before any chunk', async () => { - streamSseMock.mockRejectedValueOnce(new Error('OverflowException: malformed response frame')) + it("retries once for recoverable invalid stream errors before any chunk", async () => { + streamSseMock.mockRejectedValueOnce(new Error("OverflowException: malformed response frame")); streamSseMock.mockImplementationOnce(async (_url, _body, callbacks) => { - callbacks.onText('Recovered guided response') - }) + callbacks.onText("Recovered guided response"); + }); - const onChunk = vi.fn() - const onStatus = vi.fn() - const onError = vi.fn() - const onCitations = vi.fn() + const onChunk = vi.fn(); + const onStatus = vi.fn(); + const onError = vi.fn(); + const onCitations = vi.fn(); await expect( - streamGuidedChat('guided-session-1', 'intro', 'Teach me streams', { + streamGuidedChat("guided-session-1", "intro", "Teach me streams", { onChunk, onStatus, onError, - onCitations - }) - ).resolves.toBeUndefined() + onCitations, + }), + ).resolves.toBeUndefined(); - expect(streamSseMock).toHaveBeenCalledTimes(2) + expect(streamSseMock).toHaveBeenCalledTimes(2); expect(onStatus).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Temporary stream issue detected' - }) - ) + message: "Temporary stream issue detected", + }), + ); expect(onStatus).toHaveBeenCalledWith( expect.objectContaining({ - message: 'Streaming recovered' - }) - ) - expect(onChunk).toHaveBeenCalledWith('Recovered guided response') - expect(onError).not.toHaveBeenCalled() - }) + message: "Streaming recovered", + }), + ); + expect(onChunk).toHaveBeenCalledWith("Recovered guided response"); + expect(onError).not.toHaveBeenCalled(); + }); - it('does not retry for non-recoverable rate-limit errors', async () => { - streamSseMock.mockRejectedValueOnce(new Error('429 rate limit exceeded')) + it("does not retry for non-recoverable rate-limit errors", async () => { + streamSseMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); - const onChunk = vi.fn() - const onStatus = vi.fn() - const onError = vi.fn() - const onCitations = vi.fn() + const onChunk = vi.fn(); + const onStatus = vi.fn(); + const onError = vi.fn(); + const onCitations = vi.fn(); await expect( - streamGuidedChat('guided-session-2', 'intro', 'Teach me streams', { + streamGuidedChat("guided-session-2", "intro", "Teach me streams", { onChunk, onStatus, onError, - onCitations - }) - ).rejects.toThrow('429 rate limit exceeded') + onCitations, + }), + ).rejects.toThrow("429 rate limit exceeded"); - expect(streamSseMock).toHaveBeenCalledTimes(1) + expect(streamSseMock).toHaveBeenCalledTimes(1); expect(onStatus).not.toHaveBeenCalledWith( expect.objectContaining({ - message: 'Temporary stream issue detected' - }) - ) - const [firstOnErrorCall] = onError.mock.calls - expect(firstOnErrorCall).toBeDefined() - expect(firstOnErrorCall[0]).toEqual({ message: '429 rate limit exceeded' }) - }) - - it('does not retry when backend marks stream failure as non-retryable', async () => { + message: "Temporary stream issue detected", + }), + ); + const [firstOnErrorCall] = onError.mock.calls; + expect(firstOnErrorCall).toBeDefined(); + expect(firstOnErrorCall[0]).toEqual({ message: "429 rate limit exceeded" }); + }); + + it("does not retry when backend marks stream failure as non-retryable", async () => { streamSseMock.mockImplementationOnce(async (_url, _body, callbacks) => { callbacks.onStatus?.({ - message: 'Primary and fallback streams both failed', - code: 'stream.provider.fatal-error', + message: "Primary and fallback streams both failed", + code: "stream.provider.fatal-error", retryable: false, - stage: 'stream' - }) - throw new Error('Provider stream unavailable') - }) + stage: "stream", + }); + throw new Error("Provider stream unavailable"); + }); - const onChunk = vi.fn() - const onStatus = vi.fn() - const onError = vi.fn() - const onCitations = vi.fn() + const onChunk = vi.fn(); + const onStatus = vi.fn(); + const onError = vi.fn(); + const onCitations = vi.fn(); await expect( - streamGuidedChat('guided-session-3', 'intro', 'Teach me streams', { + streamGuidedChat("guided-session-3", "intro", "Teach me streams", { onChunk, onStatus, onError, - onCitations - }) - ).rejects.toThrow('Provider stream unavailable') + onCitations, + }), + ).rejects.toThrow("Provider stream unavailable"); - expect(streamSseMock).toHaveBeenCalledTimes(1) + expect(streamSseMock).toHaveBeenCalledTimes(1); expect(onStatus).toHaveBeenCalledWith( expect.objectContaining({ - code: 'stream.provider.fatal-error', - retryable: false - }) - ) - }) -}) + code: "stream.provider.fatal-error", + retryable: false, + }), + ); + }); +}); diff --git a/frontend/src/lib/services/guided.ts b/frontend/src/lib/services/guided.ts index 97a702df..f4a58b03 100644 --- a/frontend/src/lib/services/guided.ts +++ b/frontend/src/lib/services/guided.ts @@ -13,21 +13,21 @@ import { type StreamError, type Citation, type GuidedLesson, - type LessonContentResponse -} from '../validation/schemas' -import { validateFetchJson } from '../validation/validate' -import { fetchCitationsByEndpoint, type CitationFetchResult } from './chat' -import { streamWithRetry } from './streamRecovery' + type LessonContentResponse, +} from "../validation/schemas"; +import { validateFetchJson } from "../validation/validate"; +import { fetchCitationsByEndpoint, type CitationFetchResult } from "./chat"; +import { streamWithRetry } from "./streamRecovery"; -export type { StreamStatus, GuidedLesson, LessonContentResponse } +export type { StreamStatus, GuidedLesson, LessonContentResponse }; /** Callbacks for guided chat streaming with explicit error handling. */ export interface GuidedStreamCallbacks { - onChunk: (chunk: string) => void - onStatus?: (status: StreamStatus) => void - onError?: (error: StreamError) => void - onCitations?: (citations: Citation[]) => void - signal?: AbortSignal + onChunk: (chunk: string) => void; + onStatus?: (status: StreamStatus) => void; + onError?: (error: StreamError) => void; + onCitations?: (citations: Citation[]) => void; + signal?: AbortSignal; } /** @@ -37,14 +37,18 @@ export interface GuidedStreamCallbacks { * @throws Error if fetch fails or validation fails */ export async function fetchTOC(): Promise { - const tocResponse = await fetch('/api/guided/toc') - const tocValidation = await validateFetchJson(tocResponse, GuidedTOCSchema, 'fetchTOC [/api/guided/toc]') + const tocResponse = await fetch("/api/guided/toc"); + const tocValidation = await validateFetchJson( + tocResponse, + GuidedTOCSchema, + "fetchTOC [/api/guided/toc]", + ); if (!tocValidation.success) { - throw new Error(`Failed to fetch TOC: ${tocValidation.error}`) + throw new Error(`Failed to fetch TOC: ${tocValidation.error}`); } - return tocValidation.validated + return tocValidation.validated; } /** @@ -54,18 +58,18 @@ export async function fetchTOC(): Promise { * @throws Error if fetch fails or validation fails */ export async function fetchLesson(slug: string): Promise { - const lessonResponse = await fetch(`/api/guided/lesson?slug=${encodeURIComponent(slug)}`) + const lessonResponse = await fetch(`/api/guided/lesson?slug=${encodeURIComponent(slug)}`); const lessonValidation = await validateFetchJson( lessonResponse, GuidedLessonSchema, - `fetchLesson [slug=${slug}]` - ) + `fetchLesson [slug=${slug}]`, + ); if (!lessonValidation.success) { - throw new Error(`Failed to fetch lesson: ${lessonValidation.error}`) + throw new Error(`Failed to fetch lesson: ${lessonValidation.error}`); } - return lessonValidation.validated + return lessonValidation.validated; } /** @@ -75,18 +79,18 @@ export async function fetchLesson(slug: string): Promise { * @throws Error if fetch fails or validation fails */ export async function fetchLessonContent(slug: string): Promise { - const lessonContentResponse = await fetch(`/api/guided/content?slug=${encodeURIComponent(slug)}`) + const lessonContentResponse = await fetch(`/api/guided/content?slug=${encodeURIComponent(slug)}`); const contentValidation = await validateFetchJson( lessonContentResponse, LessonContentResponseSchema, - `fetchLessonContent [slug=${slug}]` - ) + `fetchLessonContent [slug=${slug}]`, + ); if (!contentValidation.success) { - throw new Error(`Failed to fetch lesson content: ${contentValidation.error}`) + throw new Error(`Failed to fetch lesson content: ${contentValidation.error}`); } - return contentValidation.validated + return contentValidation.validated; } /** @@ -96,8 +100,8 @@ export async function fetchLessonContent(slug: string): Promise { return fetchCitationsByEndpoint( `/api/guided/citations?slug=${encodeURIComponent(slug)}`, - `fetchGuidedLessonCitations [slug=${slug}]` - ) + `fetchGuidedLessonCitations [slug=${slug}]`, + ); } /** @@ -109,18 +113,18 @@ export async function streamGuidedChat( sessionId: string, slug: string, message: string, - callbacks: GuidedStreamCallbacks + callbacks: GuidedStreamCallbacks, ): Promise { return streamWithRetry( - '/api/guided/stream', + "/api/guided/stream", { sessionId, slug, latest: message }, { onChunk: callbacks.onChunk, onStatus: callbacks.onStatus, onError: callbacks.onError, onCitations: callbacks.onCitations, - signal: callbacks.signal + signal: callbacks.signal, }, - 'guided.ts' - ) + "guided.ts", + ); } diff --git a/frontend/src/lib/services/markdown.test.ts b/frontend/src/lib/services/markdown.test.ts index 378d4aa6..ecca52dd 100644 --- a/frontend/src/lib/services/markdown.test.ts +++ b/frontend/src/lib/services/markdown.test.ts @@ -1,162 +1,171 @@ -import { describe, it, expect } from 'vitest' -import { parseMarkdown, applyJavaLanguageDetection, escapeHtml } from './markdown' - -describe('parseMarkdown', () => { - it('returns empty string for empty input', () => { - expect(parseMarkdown('')).toBe('') - expect(parseMarkdown(null as unknown as string)).toBe('') - expect(parseMarkdown(undefined as unknown as string)).toBe('') - }) - - it('parses basic markdown to HTML', () => { - const renderedHtml = parseMarkdown('**bold** and *italic*') - expect(renderedHtml).toContain('bold') - expect(renderedHtml).toContain('italic') - }) - - it('parses code blocks', () => { - const markdown = '```java\npublic class Test {}\n```' - const renderedHtml = parseMarkdown(markdown) - expect(renderedHtml).toContain('
')
-    expect(renderedHtml).toContain(' {
-    const markdown = ''
-    const renderedHtml = parseMarkdown(markdown)
-    expect(renderedHtml).not.toContain('';
+    const renderedHtml = parseMarkdown(markdown);
+    expect(renderedHtml).not.toContain("'
-    const escapedHtml = escapeHtml(input)
-    expect(escapedHtml).not.toContain('<')
-    expect(escapedHtml).not.toContain('>')
-    expect(escapedHtml).toContain('<')
-    expect(escapedHtml).toContain('>')
-  })
-
-  it('is SSR-safe - uses pure string operations', () => {
+  });
+});
+
+describe("escapeHtml", () => {
+  it("escapes HTML special characters", () => {
+    expect(escapeHtml("
")).toBe("<div>"); + expect(escapeHtml('"quoted"')).toBe(""quoted""); + expect(escapeHtml("it's")).toBe("it's"); + expect(escapeHtml("a & b")).toBe("a & b"); + }); + + it("returns empty string for empty input", () => { + expect(escapeHtml("")).toBe(""); + }); + + it("handles complex mixed content", () => { + const input = ''; + const escapedHtml = escapeHtml(input); + expect(escapedHtml).not.toContain("<"); + expect(escapedHtml).not.toContain(">"); + expect(escapedHtml).toContain("<"); + expect(escapedHtml).toContain(">"); + }); + + it("is SSR-safe - uses pure string operations", () => { // This works without document APIs - const escapedHtml = escapeHtml('
') - expect(escapedHtml).toBe('<div class="test">') - }) -}) + const escapedHtml = escapeHtml('
'); + expect(escapedHtml).toBe("<div class="test">"); + }); +}); diff --git a/frontend/src/lib/services/markdown.ts b/frontend/src/lib/services/markdown.ts index e2f2d6b1..2a10efcb 100644 --- a/frontend/src/lib/services/markdown.ts +++ b/frontend/src/lib/services/markdown.ts @@ -1,5 +1,5 @@ -import { marked, type TokenizerExtension, type RendererExtension, type Tokens } from 'marked' -import DOMPurify from 'dompurify' +import { marked, type TokenizerExtension, type RendererExtension, type Tokens } from "marked"; +import DOMPurify from "dompurify"; /** * Enrichment kinds with their display metadata. @@ -7,111 +7,111 @@ import DOMPurify from 'dompurify' */ const ENRICHMENT_KINDS: Record = { hint: { - title: 'Helpful Hints', - icon: '' + title: "Helpful Hints", + icon: '', }, background: { - title: 'Background Context', - icon: '' + title: "Background Context", + icon: '', }, reminder: { - title: 'Important Reminders', - icon: '' + title: "Important Reminders", + icon: '', }, warning: { - title: 'Warning', - icon: '' + title: "Warning", + icon: '', }, example: { - title: 'Example', - icon: '' - } -} + title: "Example", + icon: '', + }, +}; interface EnrichmentToken extends Tokens.Generic { - type: 'enrichment' - raw: string - kind: string - content: string + type: "enrichment"; + raw: string; + kind: string; + content: string; } /** Pattern matching code fence delimiters (3+ backticks or tildes at line start). */ -const FENCE_PATTERN = /^[ \t]*(`{3,}|~{3,})/ -const FENCE_MIN_LENGTH = 3 -const NEWLINE = '\n' +const FENCE_PATTERN = /^[ \t]*(`{3,}|~{3,})/; +const FENCE_MIN_LENGTH = 3; +const NEWLINE = "\n"; -type FenceMarker = { character: string; length: number } +type FenceMarker = { character: string; length: number }; function scanFenceMarker(src: string, index: number): FenceMarker | null { if (index < 0 || index >= src.length) { - return null + return null; } - const markerChar = src[index] - if (markerChar !== '`' && markerChar !== '~') { - return null + const markerChar = src[index]; + if (markerChar !== "`" && markerChar !== "~") { + return null; } - let markerLength = 0 + let markerLength = 0; while (index + markerLength < src.length && src[index + markerLength] === markerChar) { - markerLength++ + markerLength++; } if (markerLength < FENCE_MIN_LENGTH) { - return null + return null; } - return { character: markerChar, length: markerLength } + return { character: markerChar, length: markerLength }; } function isFenceLanguageCharacter(character: string): boolean { if (character.length !== 1) { - return false + return false; } - const charCode = character.charCodeAt(0) - const isLowerAlpha = charCode >= 97 && charCode <= 122 - const isUpperAlpha = charCode >= 65 && charCode <= 90 - const isDigit = charCode >= 48 && charCode <= 57 - return isLowerAlpha || isUpperAlpha || isDigit || character === '-' || character === '_' + const charCode = character.charCodeAt(0); + const isLowerAlpha = charCode >= 97 && charCode <= 122; + const isUpperAlpha = charCode >= 65 && charCode <= 90; + const isDigit = charCode >= 48 && charCode <= 57; + return isLowerAlpha || isUpperAlpha || isDigit || character === "-" || character === "_"; } function isAttachedFenceStart(src: string, index: number): boolean { if (index <= 0 || index >= src.length) { - return false + return false; } - return !/\s/.test(src[index - 1]) + return !/\s/.test(src[index - 1]); } function appendLineBreakIfNeeded(text: string): string { if (text.length === 0 || text.endsWith(NEWLINE)) { - return text + return text; } - return `${text}${NEWLINE}` + return `${text}${NEWLINE}`; } /** Result of consuming a fence marker and its trailing language tag or newline. */ -type ConsumedFence = { text: string; nextCursor: number } +type ConsumedFence = { text: string; nextCursor: number }; /** Consumes an opening fence marker plus any language tag, ensuring a trailing newline. */ function consumeOpeningFence(content: string, cursor: number, marker: FenceMarker): ConsumedFence { - let text = content.slice(cursor, cursor + marker.length) - let pos = cursor + marker.length + let text = content.slice(cursor, cursor + marker.length); + let pos = cursor + marker.length; while (pos < content.length && isFenceLanguageCharacter(content[pos])) { - text += content[pos] - pos++ + text += content[pos]; + pos++; } if (pos < content.length && content[pos] !== NEWLINE) { - text += NEWLINE + text += NEWLINE; } - return { text, nextCursor: pos } + return { text, nextCursor: pos }; } /** Consumes a closing fence marker, ensuring a trailing newline. */ function consumeClosingFence(content: string, cursor: number, marker: FenceMarker): ConsumedFence { - const text = content.slice(cursor, cursor + marker.length) - const pos = cursor + marker.length - const suffix = pos < content.length && content[pos] !== NEWLINE ? NEWLINE : '' - return { text: text + suffix, nextCursor: pos } + const text = content.slice(cursor, cursor + marker.length); + const pos = cursor + marker.length; + const suffix = pos < content.length && content[pos] !== NEWLINE ? NEWLINE : ""; + return { text: text + suffix, nextCursor: pos }; } /** @@ -122,49 +122,55 @@ function consumeClosingFence(content: string, cursor: number, marker: FenceMarke */ function normalizeMarkdownForStreaming(content: string): string { if (!content) { - return '' + return ""; } - let normalized = '' - let inFence = false - let fenceChar = '' - let fenceLength = 0 + let normalized = ""; + let inFence = false; + let fenceChar = ""; + let fenceLength = 0; for (let cursor = 0; cursor < content.length; ) { - const startOfLine = cursor === 0 || content[cursor - 1] === NEWLINE - const marker = scanFenceMarker(content, cursor) + const startOfLine = cursor === 0 || content[cursor - 1] === NEWLINE; + const marker = scanFenceMarker(content, cursor); if (marker && !inFence && (startOfLine || isAttachedFenceStart(content, cursor))) { - normalized = appendLineBreakIfNeeded(normalized) - const consumed = consumeOpeningFence(content, cursor, marker) - normalized += consumed.text - cursor = consumed.nextCursor - inFence = true - fenceChar = marker.character - fenceLength = marker.length - continue + normalized = appendLineBreakIfNeeded(normalized); + const consumed = consumeOpeningFence(content, cursor, marker); + normalized += consumed.text; + cursor = consumed.nextCursor; + inFence = true; + fenceChar = marker.character; + fenceLength = marker.length; + continue; } - if (marker && inFence && startOfLine && marker.character === fenceChar && marker.length >= fenceLength) { - normalized = appendLineBreakIfNeeded(normalized) - const consumed = consumeClosingFence(content, cursor, marker) - normalized += consumed.text - cursor = consumed.nextCursor - inFence = false - fenceChar = '' - fenceLength = 0 - continue + if ( + marker && + inFence && + startOfLine && + marker.character === fenceChar && + marker.length >= fenceLength + ) { + normalized = appendLineBreakIfNeeded(normalized); + const consumed = consumeClosingFence(content, cursor, marker); + normalized += consumed.text; + cursor = consumed.nextCursor; + inFence = false; + fenceChar = ""; + fenceLength = 0; + continue; } - normalized += content[cursor] - cursor++ + normalized += content[cursor]; + cursor++; } if (inFence && fenceChar) { - normalized += `${NEWLINE}${fenceChar.repeat(Math.max(fenceLength, FENCE_MIN_LENGTH))}` + normalized += `${NEWLINE}${fenceChar.repeat(Math.max(fenceLength, FENCE_MIN_LENGTH))}`; } - return normalized + return normalized; } /** @@ -172,47 +178,47 @@ function normalizeMarkdownForStreaming(content: string): string { * Uses simple line-by-line scan with toggle semantics. */ function hasBalancedCodeFences(content: string): boolean { - let depth = 0 - let openChar = '' - let openLen = 0 + let depth = 0; + let openChar = ""; + let openLen = 0; - for (const line of content.split('\n')) { - const match = line.match(FENCE_PATTERN) - if (!match) continue + for (const line of content.split("\n")) { + const match = line.match(FENCE_PATTERN); + if (!match) continue; - const fence = match[1] + const fence = match[1]; if (depth === 0) { // Opening fence - depth = 1 - openChar = fence[0] - openLen = fence.length + depth = 1; + openChar = fence[0]; + openLen = fence.length; } else if (fence[0] === openChar && fence.length >= openLen) { // Matching closing fence - depth = 0 - openChar = '' - openLen = 0 + depth = 0; + openChar = ""; + openLen = 0; } } - return depth === 0 + return depth === 0; } /** Enrichment close marker. */ -const ENRICHMENT_CLOSE = '}}' +const ENRICHMENT_CLOSE = "}}"; /** * Resolves the close marker position for a run of closing braces. * For runs like "}}}", this picks the final "}}" so a trailing content "}" is preserved. */ function resolveCloseIndexFromBraceRun(src: string, runStart: number): number { - let runLength = 0 - while (runStart + runLength < src.length && src[runStart + runLength] === '}') { - runLength++ + let runLength = 0; + while (runStart + runLength < src.length && src[runStart + runLength] === "}") { + runLength++; } if (runLength < ENRICHMENT_CLOSE.length) { - return -1 + return -1; } - return runStart + (runLength - ENRICHMENT_CLOSE.length) + return runStart + (runLength - ENRICHMENT_CLOSE.length); } /** @@ -220,40 +226,40 @@ function resolveCloseIndexFromBraceRun(src: string, runStart: number): number { * Scans character-by-character, tracking fence state at line boundaries. */ function findEnrichmentClose(src: string, startIndex: number): number { - let inFence = false - let fenceChar = '' - let fenceLen = 0 + let inFence = false; + let fenceChar = ""; + let fenceLen = 0; for (let cursor = startIndex; cursor < src.length - 1; cursor++) { // At line boundaries, check for fence delimiters - if (cursor === startIndex || src[cursor - 1] === '\n') { - const lineMatch = src.slice(cursor).match(FENCE_PATTERN) + if (cursor === startIndex || src[cursor - 1] === "\n") { + const lineMatch = src.slice(cursor).match(FENCE_PATTERN); if (lineMatch) { - const fence = lineMatch[1] + const fence = lineMatch[1]; if (!inFence) { - inFence = true - fenceChar = fence[0] - fenceLen = fence.length + inFence = true; + fenceChar = fence[0]; + fenceLen = fence.length; } else if (fence[0] === fenceChar && fence.length >= fenceLen) { - inFence = false - fenceChar = '' - fenceLen = 0 + inFence = false; + fenceChar = ""; + fenceLen = 0; } - cursor += fence.length - 1 // -1 because loop will increment - continue + cursor += fence.length - 1; // -1 because loop will increment + continue; } } // Check for closing marker only outside fences - if (!inFence && src[cursor] === '}') { - const closeIndex = resolveCloseIndexFromBraceRun(src, cursor) + if (!inFence && src[cursor] === "}") { + const closeIndex = resolveCloseIndexFromBraceRun(src, cursor); if (closeIndex >= 0) { - return closeIndex + return closeIndex; } } } - return -1 + return -1; } /** @@ -262,58 +268,62 @@ function findEnrichmentClose(src: string, startIndex: number): number { */ function createEnrichmentExtension(): TokenizerExtension & RendererExtension { return { - name: 'enrichment', - level: 'block', + name: "enrichment", + level: "block", start(src: string) { - return src.indexOf('{{') + return src.indexOf("{{"); }, tokenizer(src: string): EnrichmentToken | undefined { // Match opening {{kind: pattern - const openingRule = /^\{\{(hint|warning|background|example|reminder):/ - const openingMatch = openingRule.exec(src) + const openingRule = /^\{\{(hint|warning|background|example|reminder):/; + const openingMatch = openingRule.exec(src); if (!openingMatch) { - return undefined + return undefined; } - const kind = openingMatch[1].toLowerCase() - const contentStart = openingMatch[0].length + const kind = openingMatch[1].toLowerCase(); + const contentStart = openingMatch[0].length; // Find closing }} that's not inside a code fence - const closeIndex = findEnrichmentClose(src, contentStart) + const closeIndex = findEnrichmentClose(src, contentStart); if (closeIndex === -1) { - return undefined + return undefined; } - const content = src.slice(contentStart, closeIndex) - const raw = src.slice(0, closeIndex + 2) + const content = src.slice(contentStart, closeIndex); + const raw = src.slice(0, closeIndex + 2); return { - type: 'enrichment', + type: "enrichment", raw, kind, - content: content.trim() - } + content: content.trim(), + }; }, renderer(token: Tokens.Generic): string { - const enrichmentToken = token as EnrichmentToken - const meta = ENRICHMENT_KINDS[enrichmentToken.kind] + if (token.type !== "enrichment") { + return token.raw; + } + + const kind = typeof token.kind === "string" ? token.kind : ""; + const contentToRender = typeof token.content === "string" ? token.content : ""; + const meta = ENRICHMENT_KINDS[kind]; if (!meta) { - return token.raw + return token.raw; } - const contentToRender = enrichmentToken.content - const normalizedContent = normalizeMarkdownForStreaming(contentToRender) + const normalizedContent = normalizeMarkdownForStreaming(contentToRender); // DIAGNOSTIC: Log enrichment content to identify malformed markdown if (import.meta.env.DEV) { - const hasFences = normalizedContent.includes('```') || normalizedContent.includes('~~~') - const isBalanced = hasBalancedCodeFences(normalizedContent) + const hasFences = normalizedContent.includes("```") || normalizedContent.includes("~~~"); + const isBalanced = hasBalancedCodeFences(normalizedContent); if (hasFences && !isBalanced) { - console.warn('[markdown] Unbalanced code fences in enrichment:', { - kind: enrichmentToken.kind, + console.warn("[markdown] Unbalanced code fences in enrichment:", { + kind, content: normalizedContent, - raw: enrichmentToken.raw - }) + raw: token.raw, + }); } } @@ -322,32 +332,40 @@ function createEnrichmentExtension(): TokenizerExtension & RendererExtension { const innerHtml = marked.parse(normalizedContent, { async: false, gfm: true, - breaks: false // Preserve fence detection accuracy - }) as string + breaks: false, // Preserve fence detection accuracy + }); - return `
-
${meta.icon}${meta.title}
-
${innerHtml}
-
` - } - } + return `
+
${meta.icon}${meta.title}
+
${innerHtml}
+
`; + }, + }; } - // Configure marked once at module load marked.use({ gfm: true, breaks: true, - extensions: [createEnrichmentExtension()] -}) + extensions: [createEnrichmentExtension()], +}); /** Keywords indicating Java code for auto-detection. */ -const JAVA_KEYWORDS = ['public', 'private', 'class', 'import', 'void', 'String', 'int', 'boolean'] as const +const JAVA_KEYWORDS = [ + "public", + "private", + "class", + "import", + "void", + "String", + "int", + "boolean", +] as const; /** CSS class applied to detected Java code blocks for syntax highlighting. */ -const JAVA_LANGUAGE_CLASS = 'language-java' +const JAVA_LANGUAGE_CLASS = "language-java"; /** Selector for unmarked code blocks eligible for language detection. */ -const UNMARKED_CODE_SELECTOR = 'pre > code:not([class])' +const UNMARKED_CODE_SELECTOR = "pre > code:not([class])"; /** * Parse markdown to sanitized HTML. SSR-safe - no DOM APIs used. @@ -355,35 +373,35 @@ const UNMARKED_CODE_SELECTOR = 'pre > code:not([class])' * * @throws Never throws - returns empty string on parse failure and logs error in dev mode */ -export function parseMarkdown(content: string): string { +export function parseMarkdown(content: string | null | undefined): string { if (!content) { - return '' + return ""; } - const normalizedContent = normalizeMarkdownForStreaming(content) + const normalizedContent = normalizeMarkdownForStreaming(content); // DIAGNOSTIC: Log content with unbalanced fences before parsing if (import.meta.env.DEV) { - const hasFences = normalizedContent.includes('```') || normalizedContent.includes('~~~') + const hasFences = normalizedContent.includes("```") || normalizedContent.includes("~~~"); if (hasFences && !hasBalancedCodeFences(normalizedContent)) { - console.warn('[markdown] Unbalanced code fences in input:', { + console.warn("[markdown] Unbalanced code fences in input:", { contentLength: normalizedContent.length, contentPreview: normalizedContent.slice(0, 500), - contentEnd: normalizedContent.slice(-200) - }) + contentEnd: normalizedContent.slice(-200), + }); } } try { - const rawHtml = marked.parse(normalizedContent, { async: false }) as string + const rawHtml = marked.parse(normalizedContent, { async: false }); return DOMPurify.sanitize(rawHtml, { USE_PROFILES: { html: true }, - ADD_ATTR: ['class', 'data-enrichment-type'] - }) + ADD_ATTR: ["class", "data-enrichment-type"], + }); } catch (parseError) { - console.error('[markdown] Failed to parse markdown content:', parseError) - return '' + console.error("[markdown] Failed to parse markdown content:", parseError); + return ""; } } @@ -395,20 +413,23 @@ export function parseMarkdown(content: string): string { * @throws Never throws - logs warning if container is invalid and returns early */ export function applyJavaLanguageDetection(container: HTMLElement | null | undefined): void { - if (!container || typeof container.querySelectorAll !== 'function') { + if (!container || typeof container.querySelectorAll !== "function") { if (import.meta.env.DEV) { - console.warn('[markdown] applyJavaLanguageDetection called with invalid container:', container) + console.warn( + "[markdown] applyJavaLanguageDetection called with invalid container:", + container, + ); } - return + return; } - const codeBlocks = container.querySelectorAll(UNMARKED_CODE_SELECTOR) - codeBlocks.forEach(code => { - const text = code.textContent ?? '' - if (JAVA_KEYWORDS.some(kw => text.includes(kw))) { - code.className = JAVA_LANGUAGE_CLASS + const codeBlocks = container.querySelectorAll(UNMARKED_CODE_SELECTOR); + codeBlocks.forEach((code) => { + const text = code.textContent ?? ""; + if (JAVA_KEYWORDS.some((kw) => text.includes(kw))) { + code.className = JAVA_LANGUAGE_CLASS; } - }) + }); } /** @@ -416,9 +437,9 @@ export function applyJavaLanguageDetection(container: HTMLElement | null | undef */ export function escapeHtml(text: string): string { return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, ''') + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); } diff --git a/frontend/src/lib/services/sse.test.ts b/frontend/src/lib/services/sse.test.ts index da64a444..abaca75b 100644 --- a/frontend/src/lib/services/sse.test.ts +++ b/frontend/src/lib/services/sse.test.ts @@ -1,59 +1,59 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { streamSse } from './sse' +import { afterEach, describe, expect, it, vi } from "vitest"; +import { streamSse } from "./sse"; -describe('streamSse abort handling', () => { +describe("streamSse abort handling", () => { afterEach(() => { - vi.unstubAllGlobals() - vi.restoreAllMocks() - }) + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); - it('returns without invoking callbacks when fetch is aborted', async () => { - const abortController = new AbortController() - abortController.abort() + it("returns without invoking callbacks when fetch is aborted", async () => { + const abortController = new AbortController(); + abortController.abort(); - const fetchMock = vi.fn().mockRejectedValue(Object.assign(new Error('Aborted'), { name: 'AbortError' })) - vi.stubGlobal('fetch', fetchMock) + const fetchMock = vi + .fn() + .mockRejectedValue(Object.assign(new Error("Aborted"), { name: "AbortError" })); + vi.stubGlobal("fetch", fetchMock); - const onText = vi.fn() - const onError = vi.fn() + const onText = vi.fn(); + const onError = vi.fn(); - await streamSse( - '/api/test/stream', - { hello: 'world' }, - { onText, onError }, - 'sse.test.ts', - { signal: abortController.signal } - ) + await streamSse("/api/test/stream", { hello: "world" }, { onText, onError }, "sse.test.ts", { + signal: abortController.signal, + }); - expect(onText).not.toHaveBeenCalled() - expect(onError).not.toHaveBeenCalled() - }) + expect(onText).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); - it('treats AbortError during read as a cancellation (no onError)', async () => { - const encoder = new TextEncoder() - const abortError = Object.assign(new Error('Aborted'), { name: 'AbortError' }) - let didEnqueue = false + it("treats AbortError during read as a cancellation (no onError)", async () => { + const encoder = new TextEncoder(); + const abortError = Object.assign(new Error("Aborted"), { name: "AbortError" }); + let didEnqueue = false; const responseBody = new ReadableStream({ pull(controller) { if (!didEnqueue) { - didEnqueue = true - controller.enqueue(encoder.encode('data: {"text":"Hello"}\n\n')) - return + didEnqueue = true; + controller.enqueue(encoder.encode('data: {"text":"Hello"}\n\n')); + return; } - controller.error(abortError) - } - }) + controller.error(abortError); + }, + }); - const fetchMock = vi.fn().mockResolvedValue({ ok: true, body: responseBody, status: 200, statusText: 'OK' }) - vi.stubGlobal('fetch', fetchMock) + const fetchMock = vi + .fn() + .mockResolvedValue({ ok: true, body: responseBody, status: 200, statusText: "OK" }); + vi.stubGlobal("fetch", fetchMock); - const onText = vi.fn() - const onError = vi.fn() + const onText = vi.fn(); + const onError = vi.fn(); - await streamSse('/api/test/stream', { hello: 'world' }, { onText, onError }, 'sse.test.ts') + await streamSse("/api/test/stream", { hello: "world" }, { onText, onError }, "sse.test.ts"); - expect(onText).toHaveBeenCalledWith('Hello') - expect(onError).not.toHaveBeenCalled() - }) -}) + expect(onText).toHaveBeenCalledWith("Hello"); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/lib/services/sse.ts b/frontend/src/lib/services/sse.ts index b7ec082f..2c3672aa 100644 --- a/frontend/src/lib/services/sse.ts +++ b/frontend/src/lib/services/sse.ts @@ -15,33 +15,33 @@ import { type StreamStatus, type StreamError, type ProviderEvent, - type Citation -} from '../validation/schemas' -import { validateWithSchema } from '../validation/validate' -import { csrfHeader, extractApiErrorMessage, fetchWithCsrfRetry } from './csrf' + type Citation, +} from "../validation/schemas"; +import { validateWithSchema } from "../validation/validate"; +import { csrfHeader, extractApiErrorMessage, fetchWithCsrfRetry } from "./csrf"; /** SSE event types emitted by streaming endpoints. */ -const SSE_EVENT_STATUS = 'status' -const SSE_EVENT_ERROR = 'error' -const SSE_EVENT_CITATION = 'citation' -const SSE_EVENT_PROVIDER = 'provider' +const SSE_EVENT_STATUS = "status"; +const SSE_EVENT_ERROR = "error"; +const SSE_EVENT_CITATION = "citation"; +const SSE_EVENT_PROVIDER = "provider"; /** Optional request options for streaming fetch calls. */ export interface StreamSseRequestOptions { - signal?: AbortSignal + signal?: AbortSignal; } /** Callbacks for SSE stream processing. */ export interface SseCallbacks { - onText: (content: string) => void - onStatus?: (status: StreamStatus) => void - onError?: (error: StreamError) => void - onCitations?: (citations: Citation[]) => void - onProvider?: (provider: ProviderEvent) => void + onText: (content: string) => void; + onStatus?: (status: StreamStatus) => void; + onError?: (error: StreamError) => void; + onCitations?: (citations: Citation[]) => void; + onProvider?: (provider: ProviderEvent) => void; } function isAbortError(error: unknown): boolean { - return error instanceof Error && error.name === 'AbortError' + return error instanceof Error && error.name === "AbortError"; } /** @@ -50,19 +50,19 @@ function isAbortError(error: unknown): boolean { * Logs parse errors for debugging without interrupting stream processing. */ export function tryParseJson(content: string, source: string): unknown { - const trimmed = content.trim() - if (!trimmed.startsWith('{') && !trimmed.startsWith('[')) { - return null + const trimmed = content.trim(); + if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) { + return null; } try { - return JSON.parse(trimmed) + return JSON.parse(trimmed); } catch (parseError) { // Log for debugging but don't throw - allows graceful fallback to raw text console.warn(`[${source}] JSON parse failed for content that looked like JSON:`, { preview: trimmed.slice(0, 100), - error: parseError instanceof Error ? parseError.message : String(parseError) - }) - return null + error: parseError instanceof Error ? parseError.message : String(parseError), + }); + return null; } } @@ -78,51 +78,53 @@ function processEvent( eventType: string, eventData: string, callbacks: SseCallbacks, - source: string + source: string, ): void { - const normalizedType = eventType.trim().toLowerCase() + const normalizedType = eventType.trim().toLowerCase(); if (normalizedType === SSE_EVENT_STATUS) { - const parsed = tryParseJson(eventData, source) - const validated = validateWithSchema(StreamStatusSchema, parsed, `${source}:status`) - callbacks.onStatus?.(validated.success ? validated.validated : { message: eventData }) - return + const parsed = tryParseJson(eventData, source); + const validated = validateWithSchema(StreamStatusSchema, parsed, `${source}:status`); + callbacks.onStatus?.(validated.success ? validated.validated : { message: eventData }); + return; } if (normalizedType === SSE_EVENT_ERROR) { - const parsed = tryParseJson(eventData, source) - const validated = validateWithSchema(StreamErrorSchema, parsed, `${source}:error`) - const streamError: StreamError = validated.success ? validated.validated : { message: eventData } - callbacks.onError?.(streamError) - const errorWithDetails: Error & { details?: string } = new Error(streamError.message) - errorWithDetails.details = streamError.details ?? undefined - throw errorWithDetails + const parsed = tryParseJson(eventData, source); + const validated = validateWithSchema(StreamErrorSchema, parsed, `${source}:error`); + const streamError: StreamError = validated.success + ? validated.validated + : { message: eventData }; + callbacks.onError?.(streamError); + const errorWithDetails: Error & { details?: string } = new Error(streamError.message); + errorWithDetails.details = streamError.details ?? undefined; + throw errorWithDetails; } if (normalizedType === SSE_EVENT_CITATION) { - const parsed = tryParseJson(eventData, source) - const validated = validateWithSchema(CitationsArraySchema, parsed, `${source}:citations`) + const parsed = tryParseJson(eventData, source); + const validated = validateWithSchema(CitationsArraySchema, parsed, `${source}:citations`); if (validated.success) { - callbacks.onCitations?.(validated.validated) + callbacks.onCitations?.(validated.validated); } - return + return; } if (normalizedType === SSE_EVENT_PROVIDER) { - const parsed = tryParseJson(eventData, source) - const validated = validateWithSchema(ProviderEventSchema, parsed, `${source}:provider`) + const parsed = tryParseJson(eventData, source); + const validated = validateWithSchema(ProviderEventSchema, parsed, `${source}:provider`); if (validated.success) { - callbacks.onProvider?.(validated.validated) + callbacks.onProvider?.(validated.validated); } - return + return; } // Default and "text" events - extract text from JSON wrapper if present - const parsed = tryParseJson(eventData, source) - const validated = validateWithSchema(TextEventPayloadSchema, parsed, `${source}:text`) - const textContent = validated.success ? validated.validated.text : eventData - if (textContent !== '') { - callbacks.onText(textContent) + const parsed = tryParseJson(eventData, source); + const validated = validateWithSchema(TextEventPayloadSchema, parsed, `${source}:text`); + const textContent = validated.success ? validated.validated.text : eventData; + if (textContent !== "") { + callbacks.onText(textContent); } } @@ -146,150 +148,150 @@ export async function streamSse( body: object, callbacks: SseCallbacks, source: string, - options: StreamSseRequestOptions = {} + options: StreamSseRequestOptions = {}, ): Promise { - const abortSignal = options.signal - let response: Response + const abortSignal = options.signal; + let response: Response; try { response = await fetchWithCsrfRetry( url, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', - ...csrfHeader() + "Content-Type": "application/json", + ...csrfHeader(), }, body: JSON.stringify(body), - signal: abortSignal + signal: abortSignal, }, - `streamSse:${source}` - ) + `streamSse:${source}`, + ); } catch (fetchError) { if (abortSignal?.aborted || isAbortError(fetchError)) { - return + return; } - throw fetchError + throw fetchError; } if (!response.ok) { - const apiMessage = await extractApiErrorMessage(response, `streamSse:${source}`) + const apiMessage = await extractApiErrorMessage(response, `streamSse:${source}`); const errorMessage = - apiMessage ?? `HTTP ${response.status}: ${response.statusText || 'Request failed'}` - const httpError = new Error(errorMessage) - callbacks.onError?.({ message: httpError.message }) - throw httpError + apiMessage ?? `HTTP ${response.status}: ${response.statusText || "Request failed"}`; + const httpError = new Error(errorMessage); + callbacks.onError?.({ message: httpError.message }); + throw httpError; } - const reader = response.body?.getReader() + const reader = response.body?.getReader(); if (!reader) { - const bodyError = new Error('No response body') - callbacks.onError?.({ message: bodyError.message }) - throw bodyError + const bodyError = new Error("No response body"); + callbacks.onError?.({ message: bodyError.message }); + throw bodyError; } - const decoder = new TextDecoder() - let streamCompletedNormally = false - let buffer = '' - let eventBuffer = '' - let hasEventData = false - let currentEventType: string | null = null + const decoder = new TextDecoder(); + let streamCompletedNormally = false; + let buffer = ""; + let eventBuffer = ""; + let hasEventData = false; + let currentEventType: string | null = null; const flushEvent = () => { if (!hasEventData) { - currentEventType = null - return + currentEventType = null; + return; } - const eventType = currentEventType ?? '' - const rawEventData = eventBuffer - eventBuffer = '' - hasEventData = false - currentEventType = null + const eventType = currentEventType ?? ""; + const rawEventData = eventBuffer; + eventBuffer = ""; + hasEventData = false; + currentEventType = null; - processEvent(eventType, rawEventData, callbacks, source) - } + processEvent(eventType, rawEventData, callbacks, source); + }; try { while (true) { - const { done, value } = await reader.read() + const { done, value } = await reader.read(); if (done) { - streamCompletedNormally = true + streamCompletedNormally = true; // Flush any remaining bytes from the TextDecoder (handles multi-byte chars split across chunks) - const remaining = decoder.decode() + const remaining = decoder.decode(); if (remaining) { - buffer += remaining + buffer += remaining; } // Commit any remaining buffered line before flushing event data if (buffer.length > 0) { - eventBuffer = eventBuffer ? `${eventBuffer}\n${buffer}` : buffer - hasEventData = true - buffer = '' + eventBuffer = eventBuffer ? `${eventBuffer}\n${buffer}` : buffer; + hasEventData = true; + buffer = ""; } - flushEvent() - break + flushEvent(); + break; } - const chunk = decoder.decode(value, { stream: true }) - buffer += chunk - const lines = buffer.split('\n') - buffer = lines[lines.length - 1] + const chunk = decoder.decode(value, { stream: true }); + buffer += chunk; + const lines = buffer.split("\n"); + buffer = lines[lines.length - 1]; for (let lineIndex = 0; lineIndex < lines.length - 1; lineIndex++) { - let line = lines[lineIndex] - if (line.endsWith('\r')) { - line = line.slice(0, -1) + let line = lines[lineIndex]; + if (line.endsWith("\r")) { + line = line.slice(0, -1); } // Skip SSE comments (keepalive heartbeats) - if (line.startsWith(':')) { - continue + if (line.startsWith(":")) { + continue; } // Track event type - if (line.startsWith('event:')) { - currentEventType = line.startsWith('event: ') ? line.slice(7) : line.slice(6) - continue + if (line.startsWith("event:")) { + currentEventType = line.startsWith("event: ") ? line.slice(7) : line.slice(6); + continue; } // Accumulate data within current SSE event - if (line.startsWith('data:')) { + if (line.startsWith("data:")) { // Per SSE spec, strip optional space after "data:" prefix - const eventPayload = line.startsWith('data: ') ? line.slice(6) : line.slice(5) + const eventPayload = line.startsWith("data: ") ? line.slice(6) : line.slice(5); // Skip [DONE] token - if (eventPayload === '[DONE]') { - continue + if (eventPayload === "[DONE]") { + continue; } // Accumulate within current SSE event if (hasEventData) { - eventBuffer += '\n' + eventBuffer += "\n"; } - eventBuffer += eventPayload - hasEventData = true - } else if (line.trim() === '') { + eventBuffer += eventPayload; + hasEventData = true; + } else if (line.trim() === "") { // Blank line marks end of SSE event - commit accumulated data - flushEvent() + flushEvent(); } } } } catch (streamError) { if (abortSignal?.aborted || isAbortError(streamError)) { - return + return; } - throw streamError + throw streamError; } finally { // Cancel reader on abnormal exit to prevent dangling connections if (!streamCompletedNormally) { try { - await reader.cancel() + await reader.cancel(); } catch { // Expected: cancel() throws if stream already closed by abort signal or server. // Safe to ignore - we're in cleanup and the stream is already terminated. } } - reader.releaseLock() + reader.releaseLock(); } } diff --git a/frontend/src/lib/services/streamRecovery.test.ts b/frontend/src/lib/services/streamRecovery.test.ts index c08235a7..e4000470 100644 --- a/frontend/src/lib/services/streamRecovery.test.ts +++ b/frontend/src/lib/services/streamRecovery.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it } from "vitest"; import { buildStreamRecoverySucceededStatus, buildStreamRetryStatus, @@ -6,114 +6,121 @@ import { shouldRetryStreamRequest, StreamFailureError, toStreamError, - toStreamFailureException -} from './streamRecovery' + toStreamFailureException, +} from "./streamRecovery"; -describe('streamRecovery', () => { - it('allows one retry for overflow failures before any chunk is rendered', () => { +describe("streamRecovery", () => { + it("allows one retry for overflow failures before any chunk is rendered", () => { const retryDecision = shouldRetryStreamRequest( - new Error('OverflowException while decoding response'), + new Error("OverflowException while decoding response"), null, null, false, 0, - 1 - ) + 1, + ); - expect(retryDecision).toBe(true) - }) + expect(retryDecision).toBe(true); + }); - it('refuses retry after content has started streaming', () => { + it("refuses retry after content has started streaming", () => { const retryDecision = shouldRetryStreamRequest( - new Error('OverflowException while decoding response'), + new Error("OverflowException while decoding response"), null, null, true, 0, - 1 - ) + 1, + ); - expect(retryDecision).toBe(false) - }) + expect(retryDecision).toBe(false); + }); - it('refuses retry for non-recoverable quota errors', () => { + it("refuses retry for non-recoverable quota errors", () => { const retryDecision = shouldRetryStreamRequest( - new Error('HTTP 429 rate limit exceeded'), + new Error("HTTP 429 rate limit exceeded"), null, null, false, 0, - 1 - ) + 1, + ); - expect(retryDecision).toBe(false) - }) + expect(retryDecision).toBe(false); + }); - it('builds a user-visible retry status message', () => { - const retryStatus = buildStreamRetryStatus(1, 1) + it("builds a user-visible retry status message", () => { + const retryStatus = buildStreamRetryStatus(1, 1); - expect(retryStatus.message).toBe('Temporary stream issue detected') - expect(retryStatus.details).toContain('Retrying your request (1/1)') - }) + expect(retryStatus.message).toBe("Temporary stream issue detected"); + expect(retryStatus.details).toContain("Retrying your request (1/1)"); + }); - it('builds a user-visible recovery status message after retry succeeds', () => { - const recoveryStatus = buildStreamRecoverySucceededStatus(1) + it("builds a user-visible recovery status message after retry succeeds", () => { + const recoveryStatus = buildStreamRecoverySucceededStatus(1); - expect(recoveryStatus.message).toBe('Streaming recovered') - expect(recoveryStatus.details).toContain('Recovered after retry (1)') - }) + expect(recoveryStatus.message).toBe("Streaming recovered"); + expect(recoveryStatus.details).toContain("Recovered after retry (1)"); + }); - it('maps thrown errors into StreamError shape', () => { - const mappedStreamError = toStreamError(new Error('OverflowException'), null) + it("maps thrown errors into StreamError shape", () => { + const mappedStreamError = toStreamError(new Error("OverflowException"), null); - expect(mappedStreamError).toEqual({ message: 'OverflowException' }) - }) + expect(mappedStreamError).toEqual({ message: "OverflowException" }); + }); - it('retries once for recoverable network failures before any chunk is rendered', () => { - const retryDecision = shouldRetryStreamRequest(new Error('TypeError: Failed to fetch'), null, null, false, 0, 1) + it("retries once for recoverable network failures before any chunk is rendered", () => { + const retryDecision = shouldRetryStreamRequest( + new Error("TypeError: Failed to fetch"), + null, + null, + false, + 0, + 1, + ); - expect(retryDecision).toBe(true) - }) + expect(retryDecision).toBe(true); + }); - it('respects backend retry metadata when stage is stream', () => { + it("respects backend retry metadata when stage is stream", () => { const retryDecision = shouldRetryStreamRequest( - new Error('Some fatal backend error'), + new Error("Some fatal backend error"), null, { - message: 'Provider fallback succeeded', + message: "Provider fallback succeeded", retryable: true, - stage: 'stream' + stage: "stream", }, false, 0, - 1 - ) - - expect(retryDecision).toBe(true) - }) - - it('preserves stream error details in thrown stream failure exception', () => { - const streamFailureException = toStreamFailureException(new Error('Transport failed'), { - message: 'OverflowException', - details: 'Malformed response frame at byte 512' - }) - - expect(streamFailureException).toBeInstanceOf(StreamFailureError) - expect(streamFailureException.message).toBe('OverflowException') - expect(streamFailureException.details).toBe('Malformed response frame at byte 512') - }) - - it('uses default retry count for missing config', () => { - expect(resolveStreamRecoveryRetryCount(undefined)).toBe(1) - }) - - it('clamps configured retry count into safe range', () => { - expect(resolveStreamRecoveryRetryCount(-1)).toBe(0) - expect(resolveStreamRecoveryRetryCount(9)).toBe(3) - }) - - it('falls back to default retry count for non-numeric config', () => { - expect(resolveStreamRecoveryRetryCount('abc')).toBe(1) - expect(resolveStreamRecoveryRetryCount('')).toBe(1) - }) -}) + 1, + ); + + expect(retryDecision).toBe(true); + }); + + it("preserves stream error details in thrown stream failure exception", () => { + const streamFailureException = toStreamFailureException(new Error("Transport failed"), { + message: "OverflowException", + details: "Malformed response frame at byte 512", + }); + + expect(streamFailureException).toBeInstanceOf(StreamFailureError); + expect(streamFailureException.message).toBe("OverflowException"); + expect(streamFailureException.details).toBe("Malformed response frame at byte 512"); + }); + + it("uses default retry count for missing config", () => { + expect(resolveStreamRecoveryRetryCount(undefined)).toBe(1); + }); + + it("clamps configured retry count into safe range", () => { + expect(resolveStreamRecoveryRetryCount(-1)).toBe(0); + expect(resolveStreamRecoveryRetryCount(9)).toBe(3); + }); + + it("falls back to default retry count for non-numeric config", () => { + expect(resolveStreamRecoveryRetryCount("abc")).toBe(1); + expect(resolveStreamRecoveryRetryCount("")).toBe(1); + }); +}); diff --git a/frontend/src/lib/services/streamRecovery.ts b/frontend/src/lib/services/streamRecovery.ts index 1cb54e4e..62b9b75b 100644 --- a/frontend/src/lib/services/streamRecovery.ts +++ b/frontend/src/lib/services/streamRecovery.ts @@ -1,19 +1,19 @@ -import type { StreamError, StreamStatus, Citation } from '../validation/schemas' -import { streamSse } from './sse' +import type { StreamError, StreamStatus, Citation } from "../validation/schemas"; +import { streamSse } from "./sse"; -const GENERIC_STREAM_FAILURE_MESSAGE = 'Streaming request failed' +const GENERIC_STREAM_FAILURE_MESSAGE = "Streaming request failed"; /** * Error subclass carrying structured SSE stream failure details. * Replaces unsafe `as` casts that monkey-patched `details` onto plain Error objects. */ export class StreamFailureError extends Error { - readonly details?: string + readonly details?: string; constructor(message: string, details?: string) { - super(message) - this.name = 'StreamFailureError' - this.details = details + super(message); + this.name = "StreamFailureError"; + this.details = details; } } @@ -30,19 +30,25 @@ const RECOVERABLE_STREAM_ERROR_PATTERNS = [ /\btimeout\b/i, /\btimed out\b/i, /\bhttp\s*5\d{2}\b/i, - /\b5\d{2}\s+(internal|bad gateway|service unavailable|gateway timeout)\b/i -] + /\b5\d{2}\s+(internal|bad gateway|service unavailable|gateway timeout)\b/i, +]; -const NON_RECOVERABLE_STREAM_ERROR_PATTERNS = [/rate limit/i, /\b429\b/, /\b401\b/, /\b403\b/, /providers unavailable/i] -const ABORT_STREAM_ERROR_PATTERNS = [/aborterror/i, /\baborted\b/i, /\bcancelled\b/i] +const NON_RECOVERABLE_STREAM_ERROR_PATTERNS = [ + /rate limit/i, + /\b429\b/, + /\b401\b/, + /\b403\b/, + /providers unavailable/i, +]; +const ABORT_STREAM_ERROR_PATTERNS = [/aborterror/i, /\baborted\b/i, /\bcancelled\b/i]; -const DEFAULT_STREAM_RECOVERY_RETRY_COUNT = 1 -const MIN_STREAM_RECOVERY_RETRY_COUNT = 0 -const MAX_STREAM_RECOVERY_RETRY_COUNT = 3 +const DEFAULT_STREAM_RECOVERY_RETRY_COUNT = 1; +const MIN_STREAM_RECOVERY_RETRY_COUNT = 0; +const MAX_STREAM_RECOVERY_RETRY_COUNT = 3; export const MAX_STREAM_RECOVERY_RETRIES = resolveStreamRecoveryRetryCount( - import.meta.env.VITE_STREAM_RECOVERY_MAX_RETRIES -) + import.meta.env.VITE_STREAM_RECOVERY_MAX_RETRIES, +); /** * Decides whether a stream request should be retried for a likely recoverable provider response issue. @@ -58,39 +64,42 @@ export function shouldRetryStreamRequest( latestStreamStatus: StreamStatus | null, hasStreamedAnyChunk: boolean, attemptedRetries: number, - maxRecoveryRetries: number + maxRecoveryRetries: number, ): boolean { if (hasStreamedAnyChunk) { - return false + return false; } if (attemptedRetries >= maxRecoveryRetries) { - return false + return false; } - if (latestStreamStatus?.stage === 'stream' && latestStreamStatus.retryable === false) { - return false + if (latestStreamStatus?.stage === "stream" && latestStreamStatus.retryable === false) { + return false; } - if (latestStreamStatus?.stage === 'stream' && latestStreamStatus.retryable === true) { - return true + if (latestStreamStatus?.stage === "stream" && latestStreamStatus.retryable === true) { + return true; } - const failureDescription = describeStreamFailure(streamFailure, streamErrorEvent) + const failureDescription = describeStreamFailure(streamFailure, streamErrorEvent); if (ABORT_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription))) { - return false + return false; } if (NON_RECOVERABLE_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription))) { - return false + return false; } - return RECOVERABLE_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription)) + return RECOVERABLE_STREAM_ERROR_PATTERNS.some((pattern) => pattern.test(failureDescription)); } /** * Shows users that the client detected a transient stream fault and is retrying automatically. */ -export function buildStreamRetryStatus(nextAttemptNumber: number, maxRecoveryRetries: number): StreamStatus { +export function buildStreamRetryStatus( + nextAttemptNumber: number, + maxRecoveryRetries: number, +): StreamStatus { return { - message: 'Temporary stream issue detected', - details: `The API response or network stream was temporarily invalid. Retrying your request (${nextAttemptNumber}/${maxRecoveryRetries}).` - } + message: "Temporary stream issue detected", + details: `The API response or network stream was temporarily invalid. Retrying your request (${nextAttemptNumber}/${maxRecoveryRetries}).`, + }; } /** @@ -98,22 +107,25 @@ export function buildStreamRetryStatus(nextAttemptNumber: number, maxRecoveryRet */ export function buildStreamRecoverySucceededStatus(recoveryAttemptCount: number): StreamStatus { return { - message: 'Streaming recovered', - details: `Recovered after retry (${recoveryAttemptCount}). Continuing your response.` - } + message: "Streaming recovered", + details: `Recovered after retry (${recoveryAttemptCount}). Continuing your response.`, + }; } /** * Converts thrown stream failures into the canonical StreamError shape used by UI components. */ -export function toStreamError(streamFailure: unknown, streamErrorEvent: StreamError | null): StreamError { +export function toStreamError( + streamFailure: unknown, + streamErrorEvent: StreamError | null, +): StreamError { if (streamErrorEvent) { - return streamErrorEvent + return streamErrorEvent; } if (streamFailure instanceof Error) { - return { message: streamFailure.message } + return { message: streamFailure.message }; } - return { message: GENERIC_STREAM_FAILURE_MESSAGE } + return { message: GENERIC_STREAM_FAILURE_MESSAGE }; } /** @@ -121,37 +133,37 @@ export function toStreamError(streamFailure: unknown, streamErrorEvent: StreamEr */ export function toStreamFailureException( streamFailure: unknown, - streamErrorEvent: StreamError | null + streamErrorEvent: StreamError | null, ): StreamFailureError { - const mappedStreamError = toStreamError(streamFailure, streamErrorEvent) - return new StreamFailureError(mappedStreamError.message, mappedStreamError.details ?? undefined) + const mappedStreamError = toStreamError(streamFailure, streamErrorEvent); + return new StreamFailureError(mappedStreamError.message, mappedStreamError.details ?? undefined); } export function resolveStreamRecoveryRetryCount(rawRetrySetting: unknown): number { - if (rawRetrySetting === null || rawRetrySetting === undefined || rawRetrySetting === '') { - return DEFAULT_STREAM_RECOVERY_RETRY_COUNT + if (rawRetrySetting === null || rawRetrySetting === undefined || rawRetrySetting === "") { + return DEFAULT_STREAM_RECOVERY_RETRY_COUNT; } - const parsedRetryCount = Number(rawRetrySetting) + const parsedRetryCount = Number(rawRetrySetting); if (!Number.isInteger(parsedRetryCount)) { - return DEFAULT_STREAM_RECOVERY_RETRY_COUNT + return DEFAULT_STREAM_RECOVERY_RETRY_COUNT; } if (parsedRetryCount < MIN_STREAM_RECOVERY_RETRY_COUNT) { - return MIN_STREAM_RECOVERY_RETRY_COUNT + return MIN_STREAM_RECOVERY_RETRY_COUNT; } if (parsedRetryCount > MAX_STREAM_RECOVERY_RETRY_COUNT) { - return MAX_STREAM_RECOVERY_RETRY_COUNT + return MAX_STREAM_RECOVERY_RETRY_COUNT; } - return parsedRetryCount + return parsedRetryCount; } /** Callbacks for the stream-with-retry wrapper. */ export interface StreamWithRetryCallbacks { - onChunk: (chunk: string) => void - onStatus?: (status: StreamStatus) => void - onError?: (error: StreamError) => void - onCitations?: (citations: Citation[]) => void - signal?: AbortSignal + onChunk: (chunk: string) => void; + onStatus?: (status: StreamStatus) => void; + onError?: (error: StreamError) => void; + onCitations?: (citations: Citation[]) => void; + signal?: AbortSignal; } /** @@ -162,16 +174,16 @@ export async function streamWithRetry( endpoint: string, body: object, callbacks: StreamWithRetryCallbacks, - sourceLabel: string + sourceLabel: string, ): Promise { - const { onChunk, onStatus, onError, onCitations, signal } = callbacks - let attemptedRecoveryRetries = 0 - let hasPendingRecoverySuccessNotice = false + const { onChunk, onStatus, onError, onCitations, signal } = callbacks; + let attemptedRecoveryRetries = 0; + let hasPendingRecoverySuccessNotice = false; while (true) { - let hasStreamedAnyChunk = false - let streamErrorEvent: StreamError | null = null - let latestStreamStatus: StreamStatus | null = null + let hasStreamedAnyChunk = false; + let streamErrorEvent: StreamError | null = null; + let latestStreamStatus: StreamStatus | null = null; try { await streamSse( @@ -179,26 +191,26 @@ export async function streamWithRetry( body, { onText: (chunk) => { - hasStreamedAnyChunk = true + hasStreamedAnyChunk = true; if (hasPendingRecoverySuccessNotice) { - onStatus?.(buildStreamRecoverySucceededStatus(attemptedRecoveryRetries)) - hasPendingRecoverySuccessNotice = false + onStatus?.(buildStreamRecoverySucceededStatus(attemptedRecoveryRetries)); + hasPendingRecoverySuccessNotice = false; } - onChunk(chunk) + onChunk(chunk); }, onStatus: (status) => { - latestStreamStatus = status - onStatus?.(status) + latestStreamStatus = status; + onStatus?.(status); }, onError: (streamError) => { - streamErrorEvent = streamError + streamErrorEvent = streamError; }, - onCitations + onCitations, }, sourceLabel, - { signal } - ) - return + { signal }, + ); + return; } catch (streamFailure) { if ( shouldRetryStreamRequest( @@ -207,39 +219,67 @@ export async function streamWithRetry( latestStreamStatus, hasStreamedAnyChunk, attemptedRecoveryRetries, - MAX_STREAM_RECOVERY_RETRIES + MAX_STREAM_RECOVERY_RETRIES, ) ) { - attemptedRecoveryRetries++ - hasPendingRecoverySuccessNotice = true - onStatus?.(buildStreamRetryStatus(attemptedRecoveryRetries, MAX_STREAM_RECOVERY_RETRIES)) - continue + attemptedRecoveryRetries++; + hasPendingRecoverySuccessNotice = true; + onStatus?.(buildStreamRetryStatus(attemptedRecoveryRetries, MAX_STREAM_RECOVERY_RETRIES)); + continue; } - const mappedStreamError = toStreamError(streamFailure, streamErrorEvent) - onError?.(mappedStreamError) - throw toStreamFailureException(streamFailure, streamErrorEvent) + const mappedStreamError = toStreamError(streamFailure, streamErrorEvent); + onError?.(mappedStreamError); + throw toStreamFailureException(streamFailure, streamErrorEvent); } } } -function describeStreamFailure(streamFailure: unknown, streamErrorEvent: StreamError | null): string { - const diagnosticTokens: string[] = [] +function describeStreamFailure( + streamFailure: unknown, + streamErrorEvent: StreamError | null, +): string { + const diagnosticTokens: string[] = []; if (streamErrorEvent?.message) { - diagnosticTokens.push(streamErrorEvent.message) + diagnosticTokens.push(streamErrorEvent.message); } if (streamErrorEvent?.details) { - diagnosticTokens.push(streamErrorEvent.details) + diagnosticTokens.push(streamErrorEvent.details); } if (streamErrorEvent?.code) { - diagnosticTokens.push(streamErrorEvent.code) + diagnosticTokens.push(streamErrorEvent.code); } if (streamFailure instanceof Error) { - diagnosticTokens.push(streamFailure.message) - if ('details' in streamFailure && typeof streamFailure.details === 'string') { - diagnosticTokens.push(streamFailure.details) + diagnosticTokens.push(streamFailure.message); + if ("details" in streamFailure && typeof streamFailure.details === "string") { + diagnosticTokens.push(streamFailure.details); } } else if (streamFailure !== null && streamFailure !== undefined) { - diagnosticTokens.push(String(streamFailure)) + diagnosticTokens.push(formatUnknownDiagnostic(streamFailure)); + } + return diagnosticTokens.join(" ").trim(); +} + +function formatUnknownDiagnostic(value: unknown): string { + if (typeof value === "string") { + return value; + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value); + } + if (typeof value === "bigint") { + return value.toString(); + } + if (typeof value === "symbol") { + return value.description ?? "Symbol"; + } + if (typeof value === "function") { + return value.name ? `[function ${value.name}]` : "[function]"; + } + + try { + const json = JSON.stringify(value); + return json === undefined ? "" : json; + } catch { + return Object.prototype.toString.call(value); } - return diagnosticTokens.join(' ').trim() } diff --git a/frontend/src/lib/stores/toastStore.ts b/frontend/src/lib/stores/toastStore.ts index c1a7ea2a..6533c8b2 100644 --- a/frontend/src/lib/stores/toastStore.ts +++ b/frontend/src/lib/stores/toastStore.ts @@ -1,50 +1,50 @@ -import { derived, writable } from 'svelte/store' +import { derived, writable } from "svelte/store"; -export type ToastSeverity = 'error' | 'info' +export type ToastSeverity = "error" | "info"; export interface ToastAction { - label: string - href: string + label: string; + href: string; } export interface ToastNotice { - id: string - message: string - severity: ToastSeverity - detail?: string - action?: ToastAction + id: string; + message: string; + severity: ToastSeverity; + detail?: string; + action?: ToastAction; } -const TOAST_DURATION_MS = 6_000 +const TOAST_DURATION_MS = 6_000; -let nextToastId = 0 -const toastQueue = writable([]) +let nextToastId = 0; +const toastQueue = writable([]); export function pushToast( message: string, - options: { severity?: ToastSeverity; detail?: string; action?: ToastAction } = {} + options: { severity?: ToastSeverity; detail?: string; action?: ToastAction } = {}, ): string { - const id = `toast-${++nextToastId}` + const id = `toast-${++nextToastId}`; const notice: ToastNotice = { id, message, - severity: options.severity ?? 'error' - } + severity: options.severity ?? "error", + }; if (options.detail) { - notice.detail = options.detail + notice.detail = options.detail; } if (options.action) { - notice.action = options.action + notice.action = options.action; } - toastQueue.update((queue) => [...queue, notice]) - if (typeof window !== 'undefined') { - window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS) + toastQueue.update((queue) => [...queue, notice]); + if (typeof window !== "undefined") { + window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS); } - return id + return id; } export function dismissToast(id: string): void { - toastQueue.update((queue) => queue.filter((notice) => notice.id !== id)) + toastQueue.update((queue) => queue.filter((notice) => notice.id !== id)); } -export const toasts = derived(toastQueue, ($queue) => $queue) +export const toasts = derived(toastQueue, ($queue) => $queue); diff --git a/frontend/src/lib/utils/chatMessageId.ts b/frontend/src/lib/utils/chatMessageId.ts index c3b49416..9f0e0e9a 100644 --- a/frontend/src/lib/utils/chatMessageId.ts +++ b/frontend/src/lib/utils/chatMessageId.ts @@ -5,20 +5,20 @@ * chat and guided chat rendering paths. */ -type MessageContext = 'chat' | 'guided' +type MessageContext = "chat" | "guided"; -let sequenceNumber = 0 +let sequenceNumber = 0; function nextSequenceNumber(): number { - sequenceNumber = (sequenceNumber + 1) % 1_000_000 - return sequenceNumber + sequenceNumber = (sequenceNumber + 1) % 1_000_000; + return sequenceNumber; } function createRandomSuffix(): string { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); } - return Math.random().toString(36).slice(2, 12) + return Math.random().toString(36).slice(2, 12); } /** @@ -29,9 +29,8 @@ function createRandomSuffix(): string { * @returns A stable unique message identifier */ export function createChatMessageId(context: MessageContext, sessionId: string): string { - const timestampMs = Date.now() - const sequence = nextSequenceNumber() - const randomSuffix = createRandomSuffix() - return `msg-${context}-${sessionId}-${timestampMs}-${sequence}-${randomSuffix}` + const timestampMs = Date.now(); + const sequence = nextSequenceNumber(); + const randomSuffix = createRandomSuffix(); + return `msg-${context}-${sessionId}-${timestampMs}-${sequence}-${randomSuffix}`; } - diff --git a/frontend/src/lib/utils/highlight.ts b/frontend/src/lib/utils/highlight.ts index f9b2957c..562c2fa7 100644 --- a/frontend/src/lib/utils/highlight.ts +++ b/frontend/src/lib/utils/highlight.ts @@ -6,13 +6,13 @@ */ /** Selector for unhighlighted code blocks within a container. */ -const UNHIGHLIGHTED_CODE_SELECTOR = 'pre code:not(.hljs)' +const UNHIGHLIGHTED_CODE_SELECTOR = "pre code:not(.hljs)"; /** Track whether languages have been registered to avoid re-registration. */ -let languagesRegistered = false +let languagesRegistered = false; /** Cached highlight.js instance after first load. */ -let hljsInstance: typeof import('highlight.js/lib/core').default | null = null +let hljsInstance: typeof import("highlight.js/lib/core").default | null = null; /** * Dynamically imports highlight.js core and registers all supported languages. @@ -20,31 +20,31 @@ let hljsInstance: typeof import('highlight.js/lib/core').default | null = null * * @returns Promise resolving to the highlight.js instance */ -async function loadHighlightJs(): Promise { +async function loadHighlightJs(): Promise { if (hljsInstance && languagesRegistered) { - return hljsInstance + return hljsInstance; } const [hljs, java, xml, json, bash] = await Promise.all([ - import('highlight.js/lib/core'), - import('highlight.js/lib/languages/java'), - import('highlight.js/lib/languages/xml'), - import('highlight.js/lib/languages/json'), - import('highlight.js/lib/languages/bash') - ]) + import("highlight.js/lib/core"), + import("highlight.js/lib/languages/java"), + import("highlight.js/lib/languages/xml"), + import("highlight.js/lib/languages/json"), + import("highlight.js/lib/languages/bash"), + ]); - hljsInstance = hljs.default + hljsInstance = hljs.default; // Register languages only once if (!languagesRegistered) { - if (!hljsInstance.getLanguage('java')) hljsInstance.registerLanguage('java', java.default) - if (!hljsInstance.getLanguage('xml')) hljsInstance.registerLanguage('xml', xml.default) - if (!hljsInstance.getLanguage('json')) hljsInstance.registerLanguage('json', json.default) - if (!hljsInstance.getLanguage('bash')) hljsInstance.registerLanguage('bash', bash.default) - languagesRegistered = true + if (!hljsInstance.getLanguage("java")) hljsInstance.registerLanguage("java", java.default); + if (!hljsInstance.getLanguage("xml")) hljsInstance.registerLanguage("xml", xml.default); + if (!hljsInstance.getLanguage("json")) hljsInstance.registerLanguage("json", json.default); + if (!hljsInstance.getLanguage("bash")) hljsInstance.registerLanguage("bash", bash.default); + languagesRegistered = true; } - return hljsInstance + return hljsInstance; } /** @@ -54,15 +54,15 @@ async function loadHighlightJs(): Promise { - const codeBlocks = container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR) + const codeBlocks = container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR); if (codeBlocks.length === 0) { - return + return; } - const hljs = await loadHighlightJs() + const hljs = await loadHighlightJs(); codeBlocks.forEach((block) => { - hljs.highlightElement(block as HTMLElement) - }) + hljs.highlightElement(block); + }); } /** @@ -70,16 +70,16 @@ export async function highlightCodeBlocks(container: HTMLElement): Promise */ export interface HighlightConfig { /** Delay in ms during active streaming (longer to batch updates). */ - streamingDelay: number + streamingDelay: number; /** Delay in ms after streaming completes (shorter for quick finalization). */ - settledDelay: number + settledDelay: number; } /** Default highlighting delays matching MessageBubble behavior. */ export const DEFAULT_HIGHLIGHT_CONFIG: HighlightConfig = { streamingDelay: 300, - settledDelay: 50 -} as const + settledDelay: 50, +} as const; /** * Creates a debounced highlighting function with automatic cleanup. @@ -88,7 +88,7 @@ export const DEFAULT_HIGHLIGHT_CONFIG: HighlightConfig = { * @returns Object with highlight function and cleanup function */ export function createDebouncedHighlighter(config: HighlightConfig = DEFAULT_HIGHLIGHT_CONFIG) { - let timer: ReturnType | null = null + let timer: ReturnType | null = null; /** * Schedules highlighting for a container element. @@ -97,25 +97,25 @@ export function createDebouncedHighlighter(config: HighlightConfig = DEFAULT_HIG * @param isStreaming - Whether content is actively streaming */ function scheduleHighlight(container: HTMLElement | null, isStreaming: boolean): void { - if (!container) return + if (!container) return; // Clear pending highlight if (timer) { - clearTimeout(timer) + clearTimeout(timer); } - const delay = isStreaming ? config.streamingDelay : config.settledDelay + const delay = isStreaming ? config.streamingDelay : config.settledDelay; timer = setTimeout(() => { highlightCodeBlocks(container).catch((highlightError: unknown) => { // Log with context for debugging - highlighting failures are non-fatal // (content remains readable, just without syntax coloring) - console.warn('[highlight] Code highlighting failed:', { + console.warn("[highlight] Code highlighting failed:", { error: highlightError instanceof Error ? highlightError.message : String(highlightError), - codeBlockCount: container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR).length - }) - }) - }, delay) + codeBlockCount: container.querySelectorAll(UNHIGHLIGHTED_CODE_SELECTOR).length, + }); + }); + }, delay); } /** @@ -124,10 +124,10 @@ export function createDebouncedHighlighter(config: HighlightConfig = DEFAULT_HIG */ function cleanup(): void { if (timer) { - clearTimeout(timer) - timer = null + clearTimeout(timer); + timer = null; } } - return { scheduleHighlight, cleanup } + return { scheduleHighlight, cleanup }; } diff --git a/frontend/src/lib/utils/scroll.test.ts b/frontend/src/lib/utils/scroll.test.ts index 3bc6612f..c1e7e1e3 100644 --- a/frontend/src/lib/utils/scroll.test.ts +++ b/frontend/src/lib/utils/scroll.test.ts @@ -1,14 +1,14 @@ -import { describe, it, expect, vi, beforeEach } from 'vitest' -import { isNearBottom, scrollToBottom } from './scroll' +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { isNearBottom, scrollToBottom } from "./scroll"; /** * Mocks window.matchMedia for testing prefers-reduced-motion behavior. */ function mockMatchMedia(prefersReducedMotion: boolean): void { - Object.defineProperty(window, 'matchMedia', { + Object.defineProperty(window, "matchMedia", { writable: true, value: vi.fn().mockImplementation((query: string) => ({ - matches: query === '(prefers-reduced-motion: reduce)' ? prefersReducedMotion : false, + matches: query === "(prefers-reduced-motion: reduce)" ? prefersReducedMotion : false, media: query, onchange: null, addListener: vi.fn(), @@ -17,145 +17,145 @@ function mockMatchMedia(prefersReducedMotion: boolean): void { removeEventListener: vi.fn(), dispatchEvent: vi.fn(), })), - }) + }); } beforeEach(() => { // Default to no reduced motion preference - mockMatchMedia(false) -}) + mockMatchMedia(false); +}); -describe('isNearBottom', () => { - it('returns true when container is null', () => { - expect(isNearBottom(null)).toBe(true) - }) +describe("isNearBottom", () => { + it("returns true when container is null", () => { + expect(isNearBottom(null)).toBe(true); + }); - it('returns true when scrolled to bottom', () => { + it("returns true when scrolled to bottom", () => { const container = createMockContainer({ scrollTop: 900, scrollHeight: 1000, - clientHeight: 100 - }) - expect(isNearBottom(container)).toBe(true) - }) + clientHeight: 100, + }); + expect(isNearBottom(container)).toBe(true); + }); - it('returns true when within threshold of bottom', () => { + it("returns true when within threshold of bottom", () => { const container = createMockContainer({ scrollTop: 850, scrollHeight: 1000, - clientHeight: 100 - }) + clientHeight: 100, + }); // 1000 - 850 - 100 = 50, which is less than default threshold (100) - expect(isNearBottom(container)).toBe(true) - }) + expect(isNearBottom(container)).toBe(true); + }); - it('returns false when far from bottom', () => { + it("returns false when far from bottom", () => { const container = createMockContainer({ scrollTop: 0, scrollHeight: 1000, - clientHeight: 100 - }) + clientHeight: 100, + }); // 1000 - 0 - 100 = 900, which is greater than threshold - expect(isNearBottom(container)).toBe(false) - }) + expect(isNearBottom(container)).toBe(false); + }); - it('respects custom threshold', () => { + it("respects custom threshold", () => { const container = createMockContainer({ scrollTop: 700, scrollHeight: 1000, - clientHeight: 100 - }) + clientHeight: 100, + }); // 1000 - 700 - 100 = 200 - expect(isNearBottom(container, 150)).toBe(false) - expect(isNearBottom(container, 250)).toBe(true) - }) -}) + expect(isNearBottom(container, 150)).toBe(false); + expect(isNearBottom(container, 250)).toBe(true); + }); +}); -describe('scrollToBottom', () => { - it('does nothing when container is null', async () => { - await expect(scrollToBottom(null, true)).resolves.toBeUndefined() - }) +describe("scrollToBottom", () => { + it("does nothing when container is null", async () => { + await expect(scrollToBottom(null, true)).resolves.toBeUndefined(); + }); - it('does nothing when shouldScroll is false', async () => { + it("does nothing when shouldScroll is false", async () => { const container = createMockContainer({ scrollTop: 0, scrollHeight: 1000, - clientHeight: 100 - }) - const scrollToSpy = vi.spyOn(container, 'scrollTo') + clientHeight: 100, + }); + const scrollToSpy = vi.spyOn(container, "scrollTo"); - await scrollToBottom(container, false) + await scrollToBottom(container, false); - expect(scrollToSpy).not.toHaveBeenCalled() - }) + expect(scrollToSpy).not.toHaveBeenCalled(); + }); - it('scrolls smoothly when prefers-reduced-motion is not set', async () => { - mockMatchMedia(false) // User prefers motion + it("scrolls smoothly when prefers-reduced-motion is not set", async () => { + mockMatchMedia(false); // User prefers motion const container = createMockContainer({ scrollTop: 0, scrollHeight: 1000, - clientHeight: 100 - }) - const scrollToSpy = vi.spyOn(container, 'scrollTo') + clientHeight: 100, + }); + const scrollToSpy = vi.spyOn(container, "scrollTo"); - await scrollToBottom(container, true) + await scrollToBottom(container, true); expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, - behavior: 'smooth' - }) - }) + behavior: "smooth", + }); + }); - it('scrolls instantly when prefers-reduced-motion is set', async () => { - mockMatchMedia(true) // User prefers reduced motion + it("scrolls instantly when prefers-reduced-motion is set", async () => { + mockMatchMedia(true); // User prefers reduced motion const container = createMockContainer({ scrollTop: 0, scrollHeight: 1000, - clientHeight: 100 - }) - const scrollToSpy = vi.spyOn(container, 'scrollTo') + clientHeight: 100, + }); + const scrollToSpy = vi.spyOn(container, "scrollTo"); - await scrollToBottom(container, true) + await scrollToBottom(container, true); expect(scrollToSpy).toHaveBeenCalledWith({ top: 1000, - behavior: 'auto' - }) - }) -}) + behavior: "auto", + }); + }); +}); -describe('isNearBottom threshold', () => { - it('uses default threshold of 100 pixels', () => { +describe("isNearBottom threshold", () => { + it("uses default threshold of 100 pixels", () => { const container = createMockContainer({ scrollTop: 850, scrollHeight: 1000, - clientHeight: 100 - }) + clientHeight: 100, + }); // 1000 - 850 - 100 = 50, within default threshold of 100 - expect(isNearBottom(container)).toBe(true) + expect(isNearBottom(container)).toBe(true); const farContainer = createMockContainer({ scrollTop: 700, scrollHeight: 1000, - clientHeight: 100 - }) + clientHeight: 100, + }); // 1000 - 700 - 100 = 200, outside default threshold - expect(isNearBottom(farContainer)).toBe(false) - }) -}) + expect(isNearBottom(farContainer)).toBe(false); + }); +}); /** * Creates a mock HTMLElement with scroll properties. */ function createMockContainer(props: { - scrollTop: number - scrollHeight: number - clientHeight: number + scrollTop: number; + scrollHeight: number; + clientHeight: number; }): HTMLElement { - const element = document.createElement('div') - Object.defineProperty(element, 'scrollTop', { value: props.scrollTop, writable: true }) - Object.defineProperty(element, 'scrollHeight', { value: props.scrollHeight }) - Object.defineProperty(element, 'clientHeight', { value: props.clientHeight }) - element.scrollTo = vi.fn() - return element + const element = document.createElement("div"); + Object.defineProperty(element, "scrollTop", { value: props.scrollTop, writable: true }); + Object.defineProperty(element, "scrollHeight", { value: props.scrollHeight }); + Object.defineProperty(element, "clientHeight", { value: props.clientHeight }); + element.scrollTo = vi.fn(); + return element; } diff --git a/frontend/src/lib/utils/scroll.ts b/frontend/src/lib/utils/scroll.ts index 0e2dd392..bc7a3fd7 100644 --- a/frontend/src/lib/utils/scroll.ts +++ b/frontend/src/lib/utils/scroll.ts @@ -5,10 +5,10 @@ * enabling smooth streaming experiences without hijacking manual scrolling. */ -import { tick } from 'svelte' +import { tick } from "svelte"; /** Default threshold in pixels for determining if user is "at bottom". */ -const AUTO_SCROLL_THRESHOLD = 100 +const AUTO_SCROLL_THRESHOLD = 100; /** * Checks if the user is scrolled near the bottom of a container. @@ -18,10 +18,13 @@ const AUTO_SCROLL_THRESHOLD = 100 * @param threshold - Distance from bottom in pixels to consider "at bottom" * @returns true if within threshold of bottom, false otherwise */ -export function isNearBottom(container: HTMLElement | null, threshold = AUTO_SCROLL_THRESHOLD): boolean { - if (!container) return true // Default to auto-scroll if no container - const { scrollTop, scrollHeight, clientHeight } = container - return scrollHeight - scrollTop - clientHeight < threshold +export function isNearBottom( + container: HTMLElement | null, + threshold = AUTO_SCROLL_THRESHOLD, +): boolean { + if (!container) return true; // Default to auto-scroll if no container + const { scrollTop, scrollHeight, clientHeight } = container; + return scrollHeight - scrollTop - clientHeight < threshold; } /** @@ -34,14 +37,17 @@ export function isNearBottom(container: HTMLElement | null, threshold = AUTO_SCR * @param container - The scrollable container element * @param shouldScroll - Whether to actually perform the scroll */ -export async function scrollToBottom(container: HTMLElement | null, shouldScroll: boolean): Promise { - await tick() +export async function scrollToBottom( + container: HTMLElement | null, + shouldScroll: boolean, +): Promise { + await tick(); if (container && shouldScroll) { - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches + const prefersReducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; container.scrollTo({ top: container.scrollHeight, - behavior: prefersReducedMotion ? 'auto' : 'smooth' - }) + behavior: prefersReducedMotion ? "auto" : "smooth", + }); } } @@ -53,33 +59,33 @@ export async function scrollToBottom(container: HTMLElement | null, shouldScroll * @returns Object with scroll handlers and state accessor */ export function createScrollManager(threshold = AUTO_SCROLL_THRESHOLD) { - let shouldAutoScroll = true - let container: HTMLElement | null = null + let shouldAutoScroll = true; + let container: HTMLElement | null = null; return { /** Sets the container element to manage. */ setContainer(element: HTMLElement | null): void { - container = element + container = element; }, /** Checks scroll position and updates auto-scroll state. Bind to onscroll. */ checkAutoScroll(): void { - shouldAutoScroll = isNearBottom(container, threshold) + shouldAutoScroll = isNearBottom(container, threshold); }, /** Scrolls to bottom if auto-scroll is enabled. Call after content updates. */ async scrollToBottom(): Promise { - await scrollToBottom(container, shouldAutoScroll) + await scrollToBottom(container, shouldAutoScroll); }, /** Forces auto-scroll to be enabled (e.g., when user sends a message). */ enableAutoScroll(): void { - shouldAutoScroll = true + shouldAutoScroll = true; }, /** Returns current auto-scroll state. */ isAutoScrollEnabled(): boolean { - return shouldAutoScroll - } - } + return shouldAutoScroll; + }, + }; } diff --git a/frontend/src/lib/utils/session.test.ts b/frontend/src/lib/utils/session.test.ts index 1217e6fd..c2fbea3d 100644 --- a/frontend/src/lib/utils/session.test.ts +++ b/frontend/src/lib/utils/session.test.ts @@ -1,52 +1,51 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { generateSessionId } from './session' +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { generateSessionId } from "./session"; -describe('generateSessionId', () => { +describe("generateSessionId", () => { beforeEach(() => { - vi.useFakeTimers() - vi.setSystemTime(new Date('2026-02-09T12:00:00.000Z')) - }) + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-02-09T12:00:00.000Z")); + }); afterEach(() => { - vi.useRealTimers() - vi.restoreAllMocks() - vi.unstubAllGlobals() - }) - - it('uses crypto.randomUUID when available', () => { - vi.stubGlobal('crypto', { - randomUUID: () => 'uuid-test-value', - } as unknown as Crypto) - - const sessionId = generateSessionId('chat') - expect(sessionId).toBe('chat-1770638400000-uuid-test-value') - }) - - it('uses crypto.getRandomValues when randomUUID is unavailable', () => { - vi.stubGlobal('crypto', { + vi.useRealTimers(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it("uses crypto.randomUUID when available", () => { + vi.stubGlobal("crypto", { + randomUUID: () => "uuid-test-value", + }); + + const sessionId = generateSessionId("chat"); + expect(sessionId).toBe("chat-1770638400000-uuid-test-value"); + }); + + it("uses crypto.getRandomValues when randomUUID is unavailable", () => { + vi.stubGlobal("crypto", { getRandomValues: (randomBytes: Uint8Array) => { - randomBytes.fill(15) - return randomBytes + randomBytes.fill(15); + return randomBytes; }, - } as unknown as Crypto) - - const sessionId = generateSessionId('chat') - const sessionParts = sessionId.split('-') - const randomSuffix = sessionParts[sessionParts.length - 1] - expect(randomSuffix).toHaveLength(32) - expect(/^[0-9a-f]+$/.test(randomSuffix)).toBe(true) - }) - - it('falls back to padded Math.random output when crypto is unavailable', () => { - vi.stubGlobal('crypto', undefined) - vi.spyOn(Math, 'random').mockReturnValue(0) - - const sessionId = generateSessionId('chat') - const sessionParts = sessionId.split('-') - const randomSuffix = sessionParts[sessionParts.length - 1] - expect(sessionId.endsWith('-')).toBe(false) - expect(randomSuffix).toHaveLength(12) - expect(randomSuffix).toBe('000000000000') - }) -}) - + }); + + const sessionId = generateSessionId("chat"); + const sessionParts = sessionId.split("-"); + const randomSuffix = sessionParts[sessionParts.length - 1]; + expect(randomSuffix).toHaveLength(32); + expect(/^[0-9a-f]+$/.test(randomSuffix)).toBe(true); + }); + + it("falls back to padded Math.random output when crypto is unavailable", () => { + vi.stubGlobal("crypto", undefined); + vi.spyOn(Math, "random").mockReturnValue(0); + + const sessionId = generateSessionId("chat"); + const sessionParts = sessionId.split("-"); + const randomSuffix = sessionParts[sessionParts.length - 1]; + expect(sessionId.endsWith("-")).toBe(false); + expect(randomSuffix).toHaveLength(12); + expect(randomSuffix).toBe("000000000000"); + }); +}); diff --git a/frontend/src/lib/utils/session.ts b/frontend/src/lib/utils/session.ts index 534e54bf..23e03f73 100644 --- a/frontend/src/lib/utils/session.ts +++ b/frontend/src/lib/utils/session.ts @@ -3,17 +3,19 @@ */ function createSessionRandomPart(): string { - if (typeof crypto !== 'undefined') { - if (typeof crypto.randomUUID === 'function') { - return crypto.randomUUID() + if (typeof crypto !== "undefined") { + if (typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); } - if (typeof crypto.getRandomValues === 'function') { - const randomBytes = new Uint8Array(16) - crypto.getRandomValues(randomBytes) - return Array.from(randomBytes, (randomByte) => randomByte.toString(16).padStart(2, '0')).join('') + if (typeof crypto.getRandomValues === "function") { + const randomBytes = new Uint8Array(16); + crypto.getRandomValues(randomBytes); + return Array.from(randomBytes, (randomByte) => randomByte.toString(16).padStart(2, "0")).join( + "", + ); } } - return Math.random().toString(36).slice(2, 14).padEnd(12, '0') + return Math.random().toString(36).slice(2, 14).padEnd(12, "0"); } /** @@ -26,6 +28,6 @@ function createSessionRandomPart(): string { * @returns Unique session ID string in format "{prefix}-{timestamp}-{random}" */ export function generateSessionId(prefix: string): string { - const randomPart = createSessionRandomPart() - return `${prefix}-${Date.now()}-${randomPart}` + const randomPart = createSessionRandomPart(); + return `${prefix}-${Date.now()}-${randomPart}`; } diff --git a/frontend/src/lib/utils/url.test.ts b/frontend/src/lib/utils/url.test.ts index 8d8dede2..013bef02 100644 --- a/frontend/src/lib/utils/url.test.ts +++ b/frontend/src/lib/utils/url.test.ts @@ -1,120 +1,120 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect } from "vitest"; import { sanitizeUrl, buildFullUrl, deduplicateCitations, getCitationType, - getDisplaySource -} from './url' - -describe('sanitizeUrl', () => { - it('returns fallback for empty/null/undefined input', () => { - expect(sanitizeUrl('')).toBe('#') - expect(sanitizeUrl(null)).toBe('#') - expect(sanitizeUrl(undefined)).toBe('#') - }) - - it('allows https URLs', () => { - const url = 'https://example.com/path' - expect(sanitizeUrl(url)).toBe(url) - }) - - it('allows http URLs', () => { - const url = 'http://example.com/path' - expect(sanitizeUrl(url)).toBe(url) - }) - - it('blocks javascript URLs', () => { - expect(sanitizeUrl('javascript:alert(1)')).toBe('#') - }) - - it('blocks data URLs', () => { - expect(sanitizeUrl('data:text/html,')).toBe('#') - }) - - it('trims whitespace', () => { - expect(sanitizeUrl(' https://example.com ')).toBe('https://example.com') - }) -}) - -describe('buildFullUrl', () => { - it('returns sanitized URL without anchor', () => { - expect(buildFullUrl('https://example.com', undefined)).toBe('https://example.com') - expect(buildFullUrl('https://example.com', '')).toBe('https://example.com') - }) - - it('appends anchor with hash', () => { - expect(buildFullUrl('https://example.com', 'section')).toBe('https://example.com#section') - }) - - it('handles anchor - appends with hash separator', () => { + getDisplaySource, +} from "./url"; + +describe("sanitizeUrl", () => { + it("returns fallback for empty/null/undefined input", () => { + expect(sanitizeUrl("")).toBe("#"); + expect(sanitizeUrl(null)).toBe("#"); + expect(sanitizeUrl(undefined)).toBe("#"); + }); + + it("allows https URLs", () => { + const url = "https://example.com/path"; + expect(sanitizeUrl(url)).toBe(url); + }); + + it("allows http URLs", () => { + const url = "http://example.com/path"; + expect(sanitizeUrl(url)).toBe(url); + }); + + it("blocks javascript URLs", () => { + expect(sanitizeUrl("javascript:alert(1)")).toBe("#"); + }); + + it("blocks data URLs", () => { + expect(sanitizeUrl("data:text/html,")).toBe("#"); + }); + + it("trims whitespace", () => { + expect(sanitizeUrl(" https://example.com ")).toBe("https://example.com"); + }); +}); + +describe("buildFullUrl", () => { + it("returns sanitized URL without anchor", () => { + expect(buildFullUrl("https://example.com", undefined)).toBe("https://example.com"); + expect(buildFullUrl("https://example.com", "")).toBe("https://example.com"); + }); + + it("appends anchor with hash", () => { + expect(buildFullUrl("https://example.com", "section")).toBe("https://example.com#section"); + }); + + it("handles anchor - appends with hash separator", () => { // Note: buildFullUrl always prepends # to anchor, so #section becomes ##section // Callers should strip # from anchors before passing - expect(buildFullUrl('https://example.com', 'section')).toBe('https://example.com#section') - }) -}) + expect(buildFullUrl("https://example.com", "section")).toBe("https://example.com#section"); + }); +}); -describe('deduplicateCitations', () => { - it('returns empty array for empty input', () => { - expect(deduplicateCitations([])).toEqual([]) - }) +describe("deduplicateCitations", () => { + it("returns empty array for empty input", () => { + expect(deduplicateCitations([])).toEqual([]); + }); - it('returns empty array for null/undefined input', () => { - expect(deduplicateCitations(null)).toEqual([]) - expect(deduplicateCitations(undefined)).toEqual([]) - }) + it("returns empty array for null/undefined input", () => { + expect(deduplicateCitations(null)).toEqual([]); + expect(deduplicateCitations(undefined)).toEqual([]); + }); - it('removes duplicate URLs', () => { + it("removes duplicate URLs", () => { const citations = [ - { url: 'https://a.com', title: 'A' }, - { url: 'https://b.com', title: 'B' }, - { url: 'https://a.com', title: 'A duplicate' } - ] - const deduplicatedCitations = deduplicateCitations(citations) - expect(deduplicatedCitations).toHaveLength(2) - expect(deduplicatedCitations.map(c => c.url)).toEqual(['https://a.com', 'https://b.com']) - }) - - it('keeps first occurrence when deduplicating', () => { + { url: "https://a.com", title: "A" }, + { url: "https://b.com", title: "B" }, + { url: "https://a.com", title: "A duplicate" }, + ]; + const deduplicatedCitations = deduplicateCitations(citations); + expect(deduplicatedCitations).toHaveLength(2); + expect(deduplicatedCitations.map((c) => c.url)).toEqual(["https://a.com", "https://b.com"]); + }); + + it("keeps first occurrence when deduplicating", () => { const citations = [ - { url: 'https://a.com', title: 'First' }, - { url: 'https://a.com', title: 'Second' } - ] - const deduplicatedCitations = deduplicateCitations(citations) - expect(deduplicatedCitations[0].title).toBe('First') - }) -}) - -describe('getCitationType', () => { - it('detects PDF files', () => { - expect(getCitationType('https://example.com/doc.pdf')).toBe('pdf') - expect(getCitationType('https://example.com/DOC.PDF')).toBe('pdf') - }) - - it('detects API documentation', () => { - expect(getCitationType('https://docs.oracle.com/javase/8/docs/api/')).toBe('api-doc') - expect(getCitationType('https://developer.mozilla.org/api/something')).toBe('api-doc') - }) - - it('detects repositories', () => { - expect(getCitationType('https://github.com/user/repo')).toBe('repo') - expect(getCitationType('https://gitlab.com/user/repo')).toBe('repo') - }) - - it('returns external for generic URLs', () => { - expect(getCitationType('https://example.com')).toBe('external') - expect(getCitationType('https://blog.example.com/post')).toBe('external') - }) -}) - -describe('getDisplaySource', () => { - it('extracts hostname from URL', () => { - expect(getDisplaySource('https://docs.oracle.com/javase/8/')).toBe('docs.oracle.com') - }) - - it('returns fallback label for invalid URLs', () => { - expect(getDisplaySource('not-a-url')).toBe('Source') - expect(getDisplaySource('')).toBe('Source') - expect(getDisplaySource(null)).toBe('Source') - }) -}) + { url: "https://a.com", title: "First" }, + { url: "https://a.com", title: "Second" }, + ]; + const deduplicatedCitations = deduplicateCitations(citations); + expect(deduplicatedCitations[0].title).toBe("First"); + }); +}); + +describe("getCitationType", () => { + it("detects PDF files", () => { + expect(getCitationType("https://example.com/doc.pdf")).toBe("pdf"); + expect(getCitationType("https://example.com/DOC.PDF")).toBe("pdf"); + }); + + it("detects API documentation", () => { + expect(getCitationType("https://docs.oracle.com/javase/8/docs/api/")).toBe("api-doc"); + expect(getCitationType("https://developer.mozilla.org/api/something")).toBe("api-doc"); + }); + + it("detects repositories", () => { + expect(getCitationType("https://github.com/user/repo")).toBe("repo"); + expect(getCitationType("https://gitlab.com/user/repo")).toBe("repo"); + }); + + it("returns external for generic URLs", () => { + expect(getCitationType("https://example.com")).toBe("external"); + expect(getCitationType("https://blog.example.com/post")).toBe("external"); + }); +}); + +describe("getDisplaySource", () => { + it("extracts hostname from URL", () => { + expect(getDisplaySource("https://docs.oracle.com/javase/8/")).toBe("docs.oracle.com"); + }); + + it("returns fallback label for invalid URLs", () => { + expect(getDisplaySource("not-a-url")).toBe("Source"); + expect(getDisplaySource("")).toBe("Source"); + expect(getDisplaySource(null)).toBe("Source"); + }); +}); diff --git a/frontend/src/lib/utils/url.ts b/frontend/src/lib/utils/url.ts index e635feb3..f5ed965c 100644 --- a/frontend/src/lib/utils/url.ts +++ b/frontend/src/lib/utils/url.ts @@ -7,42 +7,42 @@ */ /** Citation type for styling and icon selection. */ -export type CitationType = 'pdf' | 'api-doc' | 'repo' | 'external' | 'local' | 'unknown' +export type CitationType = "pdf" | "api-doc" | "repo" | "external" | "local" | "unknown"; /** URL protocol constants. */ -const URL_SCHEME_HTTP = 'http://' -const URL_SCHEME_HTTPS = 'https://' -const LOCAL_PATH_PREFIX = '/' -const ANCHOR_SEPARATOR = '#' -const PDF_EXTENSION = '.pdf' +const URL_SCHEME_HTTP = "http://"; +const URL_SCHEME_HTTPS = "https://"; +const LOCAL_PATH_PREFIX = "/"; +const ANCHOR_SEPARATOR = "#"; +const PDF_EXTENSION = ".pdf"; /** Fallback value for invalid or dangerous URLs. */ -export const FALLBACK_LINK_TARGET = '#' +export const FALLBACK_LINK_TARGET = "#"; /** Fallback label when URL cannot be parsed for display. */ -export const FALLBACK_SOURCE_LABEL = 'Source' +export const FALLBACK_SOURCE_LABEL = "Source"; /** Safe URL schemes - only http and https are allowed for external links. */ -const SAFE_URL_SCHEMES = ['http:', 'https:'] as const +const SAFE_URL_SCHEMES = ["http:", "https:"] as const; /** Domain patterns that indicate API documentation sources. */ -const API_DOC_PATTERNS = ['docs.oracle.com', 'javadoc', '/api/', '/docs/api/'] as const +const API_DOC_PATTERNS = ["docs.oracle.com", "javadoc", "/api/", "/docs/api/"] as const; /** Domain patterns that indicate repository sources. */ -const REPO_PATTERNS = ['github.com', 'gitlab.com', 'bitbucket.org'] as const +const REPO_PATTERNS = ["github.com", "gitlab.com", "bitbucket.org"] as const; /** * Checks if URL starts with http:// or https://. */ export function isHttpUrl(url: string): boolean { - return url.startsWith(URL_SCHEME_HTTP) || url.startsWith(URL_SCHEME_HTTPS) + return url.startsWith(URL_SCHEME_HTTP) || url.startsWith(URL_SCHEME_HTTPS); } /** * Checks if URL contains any of the given patterns (case-sensitive). */ export function matchesAnyPattern(url: string, patterns: readonly string[]): boolean { - return patterns.some(pattern => url.includes(pattern)) + return patterns.some((pattern) => url.includes(pattern)); } /** @@ -53,36 +53,36 @@ export function matchesAnyPattern(url: string, patterns: readonly string[]): boo * @returns The original URL if safe, or FALLBACK_LINK_TARGET for dangerous schemes */ export function sanitizeUrl(url: string | undefined | null): string { - if (!url) return FALLBACK_LINK_TARGET - const trimmedUrl = url.trim() - if (!trimmedUrl) return FALLBACK_LINK_TARGET + if (!url) return FALLBACK_LINK_TARGET; + const trimmedUrl = url.trim(); + if (!trimmedUrl) return FALLBACK_LINK_TARGET; // Allow relative paths (start with / but not // which is protocol-relative) - if (trimmedUrl.startsWith(LOCAL_PATH_PREFIX) && !trimmedUrl.startsWith('//')) { - return trimmedUrl + if (trimmedUrl.startsWith(LOCAL_PATH_PREFIX) && !trimmedUrl.startsWith("//")) { + return trimmedUrl; } // Check for safe schemes via URL parsing try { - const parsedUrl = new URL(trimmedUrl) - const scheme = parsedUrl.protocol.toLowerCase() - if (SAFE_URL_SCHEMES.some(safe => scheme === safe)) { - return trimmedUrl + const parsedUrl = new URL(trimmedUrl); + const scheme = parsedUrl.protocol.toLowerCase(); + if (SAFE_URL_SCHEMES.some((safe) => scheme === safe)) { + return trimmedUrl; } // Parsed successfully but has dangerous scheme (javascript:, data:, etc.) - return FALLBACK_LINK_TARGET + return FALLBACK_LINK_TARGET; } catch { // URL parsing failed - might be a relative path or malformed // Block protocol-relative URLs (//example.com) that inherit page scheme - if (trimmedUrl.startsWith('//')) { - return FALLBACK_LINK_TARGET + if (trimmedUrl.startsWith("//")) { + return FALLBACK_LINK_TARGET; } // Only allow if it doesn't look like a dangerous scheme - const lowerUrl = trimmedUrl.toLowerCase() - if (lowerUrl.includes(':') && !isHttpUrl(lowerUrl)) { - return FALLBACK_LINK_TARGET + const lowerUrl = trimmedUrl.toLowerCase(); + if (lowerUrl.includes(":") && !isHttpUrl(lowerUrl)) { + return FALLBACK_LINK_TARGET; } - return trimmedUrl + return trimmedUrl; } } @@ -93,17 +93,17 @@ export function sanitizeUrl(url: string | undefined | null): string { * @returns CitationType for styling decisions */ export function getCitationType(url: string | undefined | null): CitationType { - if (!url) return 'unknown' - const lowerUrl = url.toLowerCase() + if (!url) return "unknown"; + const lowerUrl = url.toLowerCase(); - if (lowerUrl.endsWith(PDF_EXTENSION)) return 'pdf' + if (lowerUrl.endsWith(PDF_EXTENSION)) return "pdf"; if (isHttpUrl(lowerUrl)) { - if (matchesAnyPattern(lowerUrl, API_DOC_PATTERNS)) return 'api-doc' - if (matchesAnyPattern(lowerUrl, REPO_PATTERNS)) return 'repo' - return 'external' + if (matchesAnyPattern(lowerUrl, API_DOC_PATTERNS)) return "api-doc"; + if (matchesAnyPattern(lowerUrl, REPO_PATTERNS)) return "repo"; + return "external"; } - if (lowerUrl.startsWith(LOCAL_PATH_PREFIX)) return 'local' - return 'unknown' + if (lowerUrl.startsWith(LOCAL_PATH_PREFIX)) return "local"; + return "unknown"; } /** @@ -113,39 +113,39 @@ export function getCitationType(url: string | undefined | null): CitationType { * @returns Human-readable source label (domain, filename, or fallback) */ export function getDisplaySource(url: string | undefined | null): string { - if (!url) return FALLBACK_SOURCE_LABEL + if (!url) return FALLBACK_SOURCE_LABEL; - const lowerUrl = url.toLowerCase() + const lowerUrl = url.toLowerCase(); // PDF filenames - extract and clean the filename if (lowerUrl.endsWith(PDF_EXTENSION)) { - const segments = url.split(LOCAL_PATH_PREFIX) - const filename = segments[segments.length - 1] + const segments = url.split(LOCAL_PATH_PREFIX); + const filename = segments[segments.length - 1]; const cleanName = filename - .replace(/\.pdf$/i, '') - .replace(/[-_]/g, ' ') - .replace(/\s+/g, ' ') - .trim() - return cleanName || 'PDF Document' + .replace(/\.pdf$/i, "") + .replace(/[-_]/g, " ") + .replace(/\s+/g, " ") + .trim(); + return cleanName || "PDF Document"; } // HTTP URLs - extract hostname if (isHttpUrl(url)) { try { - const urlObj = new URL(url) - return urlObj.hostname.replace(/^www\./, '') + const urlObj = new URL(url); + return urlObj.hostname.replace(/^www\./, ""); } catch { - return FALLBACK_SOURCE_LABEL + return FALLBACK_SOURCE_LABEL; } } // Local paths - extract last segment if (url.startsWith(LOCAL_PATH_PREFIX)) { - const segments = url.split(LOCAL_PATH_PREFIX) - return segments[segments.length - 1] || 'Local Resource' + const segments = url.split(LOCAL_PATH_PREFIX); + return segments[segments.length - 1] || "Local Resource"; } - return FALLBACK_SOURCE_LABEL + return FALLBACK_SOURCE_LABEL; } /** @@ -157,20 +157,20 @@ export function getDisplaySource(url: string | undefined | null): string { * @returns Safe URL with anchor, or FALLBACK_LINK_TARGET if unsafe */ export function buildFullUrl(baseUrl: string | undefined | null, anchor?: string): string { - if (!baseUrl) return FALLBACK_LINK_TARGET + if (!baseUrl) return FALLBACK_LINK_TARGET; - const safeUrl = sanitizeUrl(baseUrl) - if (safeUrl === FALLBACK_LINK_TARGET) return FALLBACK_LINK_TARGET + const safeUrl = sanitizeUrl(baseUrl); + if (safeUrl === FALLBACK_LINK_TARGET) return FALLBACK_LINK_TARGET; if (anchor && !safeUrl.includes(ANCHOR_SEPARATOR)) { - return `${safeUrl}${ANCHOR_SEPARATOR}${anchor}` + return `${safeUrl}${ANCHOR_SEPARATOR}${anchor}`; } - return safeUrl + return safeUrl; } /** Constraint for objects with a URL property (used in deduplication). */ interface HasUrl { - url?: string | null + url?: string | null; } /** @@ -180,16 +180,18 @@ interface HasUrl { * @param citations - Array of objects with url property (null/undefined treated as empty) * @returns Deduplicated array preserving original order */ -export function deduplicateCitations(citations: readonly T[] | null | undefined): T[] { +export function deduplicateCitations( + citations: readonly T[] | null | undefined, +): T[] { if (!citations || citations.length === 0) { - return [] + return []; } - const seen = new Set() + const seen = new Set(); return citations.filter((citation) => { - if (!citation || typeof citation.url !== 'string') return false - const key = citation.url.toLowerCase() - if (seen.has(key)) return false - seen.add(key) - return true - }) + if (!citation || typeof citation.url !== "string") return false; + const key = citation.url.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); } diff --git a/frontend/src/lib/validation/schemas.ts b/frontend/src/lib/validation/schemas.ts index 76ce9826..46013f49 100644 --- a/frontend/src/lib/validation/schemas.ts +++ b/frontend/src/lib/validation/schemas.ts @@ -8,7 +8,7 @@ * @see {@link docs/type-safety-zod-validation.md} for validation patterns */ -import { z } from 'zod/v4' +import { z } from "zod/v4"; // ============================================================================= // SSE Stream Event Schemas @@ -23,24 +23,24 @@ const sseEventFieldShape = { provider: z.string().nullish(), stage: z.string().nullish(), attempt: z.int().positive().nullish(), - maxAttempts: z.int().positive().nullish() -} + maxAttempts: z.int().positive().nullish(), +}; /** Status message from SSE status events. */ -export const StreamStatusSchema = z.object(sseEventFieldShape) +export const StreamStatusSchema = z.object(sseEventFieldShape); /** Error response from SSE error events. */ -export const StreamErrorSchema = z.object(sseEventFieldShape) +export const StreamErrorSchema = z.object(sseEventFieldShape); /** Text event payload wrapper. */ export const TextEventPayloadSchema = z.object({ - text: z.string() -}) + text: z.string(), +}); /** Provider metadata from SSE provider events. */ export const ProviderEventSchema = z.object({ - provider: z.string() -}) + provider: z.string(), +}); // ============================================================================= // Citation Schemas @@ -51,11 +51,11 @@ export const CitationSchema = z.object({ url: z.string(), title: z.string(), anchor: z.string().optional(), - snippet: z.string().optional() -}) + snippet: z.string().optional(), +}); /** Array of citations from citation endpoints. */ -export const CitationsArraySchema = z.array(CitationSchema) +export const CitationsArraySchema = z.array(CitationSchema); // ============================================================================= // Guided Learning Schemas @@ -66,17 +66,17 @@ export const GuidedLessonSchema = z.object({ slug: z.string(), title: z.string(), summary: z.string(), - keywords: z.array(z.string()) -}) + keywords: z.array(z.string()), +}); /** Array of lessons for TOC endpoint. */ -export const GuidedTOCSchema = z.array(GuidedLessonSchema) +export const GuidedTOCSchema = z.array(GuidedLessonSchema); /** Response from the lesson content endpoint. */ export const LessonContentResponseSchema = z.object({ markdown: z.string(), - cached: z.boolean() -}) + cached: z.boolean(), +}); // ============================================================================= // Error Response Schemas @@ -86,18 +86,18 @@ export const LessonContentResponseSchema = z.object({ export const ApiErrorResponseSchema = z.object({ status: z.string(), message: z.string(), - details: z.string().nullable().optional() -}) + details: z.string().nullable().optional(), +}); // ============================================================================= // Inferred Types (export for service layer) // ============================================================================= -export type StreamStatus = z.infer -export type StreamError = z.infer -export type TextEventPayload = z.infer -export type ProviderEvent = z.infer -export type Citation = z.infer -export type GuidedLesson = z.infer -export type LessonContentResponse = z.infer -export type ApiErrorResponse = z.infer +export type StreamStatus = z.infer; +export type StreamError = z.infer; +export type TextEventPayload = z.infer; +export type ProviderEvent = z.infer; +export type Citation = z.infer; +export type GuidedLesson = z.infer; +export type LessonContentResponse = z.infer; +export type ApiErrorResponse = z.infer; diff --git a/frontend/src/lib/validation/validate.ts b/frontend/src/lib/validation/validate.ts index eff062e2..d1fbbc53 100644 --- a/frontend/src/lib/validation/validate.ts +++ b/frontend/src/lib/validation/validate.ts @@ -8,7 +8,7 @@ * @see {@link docs/type-safety-zod-validation.md} for validation patterns */ -import { z } from 'zod/v4' +import { z } from "zod/v4"; // ============================================================================= // Result Types (Discriminated Unions) @@ -16,18 +16,18 @@ import { z } from 'zod/v4' /** Success result with validated data. */ interface ValidationSuccess { - success: true - validated: T + success: true; + validated: T; } /** Failure result with Zod error. */ interface ValidationFailure { - success: false - error: z.ZodError + success: false; + error: z.ZodError; } /** Discriminated union - never null, always explicit success/failure. */ -export type ValidationResult = ValidationSuccess | ValidationFailure +export type ValidationResult = ValidationSuccess | ValidationFailure; // ============================================================================= // Error Logging @@ -45,38 +45,39 @@ export type ValidationResult = ValidationSuccess | ValidationFailure */ export function logZodFailure(context: string, error: unknown, rawInput?: unknown): void { const inputKeys = - typeof rawInput === 'object' && rawInput !== null ? Object.keys(rawInput).slice(0, 20) : [] + typeof rawInput === "object" && rawInput !== null ? Object.keys(rawInput).slice(0, 20) : []; if (error instanceof z.ZodError) { const issueSummaries = error.issues.slice(0, 10).map((issue) => { - const path = issue.path.length > 0 ? issue.path.join('.') : '(root)' + const path = issue.path.length > 0 ? issue.path.join(".") : "(root)"; // Zod v4: 'input' contains the failing value, 'received' for type errors - const inputValue = 'input' in issue ? issue.input : undefined - const receivedValue = 'received' in issue ? issue.received : undefined - const actualValue = receivedValue ?? inputValue + const inputValue = "input" in issue ? issue.input : undefined; + const receivedValue = "received" in issue ? issue.received : undefined; + const actualValue = receivedValue ?? inputValue; - const received = actualValue !== undefined ? ` (received: ${JSON.stringify(actualValue)})` : '' - const expected = 'expected' in issue ? ` (expected: ${issue.expected})` : '' + const received = + actualValue !== undefined ? ` (received: ${JSON.stringify(actualValue)})` : ""; + const expected = "expected" in issue ? ` (expected: ${issue.expected})` : ""; - return ` - ${path}: ${issue.message}${expected}${received}` - }) + return ` - ${path}: ${issue.message}${expected}${received}`; + }); // Log as readable string - NOT collapsed object console.error( `[Zod] ${context} validation failed\n` + - `Issues:\n${issueSummaries.join('\n')}\n` + - `Payload keys: ${inputKeys.join(', ')}` - ) + `Issues:\n${issueSummaries.join("\n")}\n` + + `Payload keys: ${inputKeys.join(", ")}`, + ); // Full details for deep debugging console.error(`[Zod] ${context} - full details:`, { prettifiedError: z.prettifyError(error), issues: error.issues, - rawInput - }) + rawInput, + }); } else { - console.error(`[Zod] ${context} validation failed (non-ZodError):`, error) + console.error(`[Zod] ${context} validation failed (non-ZodError):`, error); } } @@ -98,16 +99,16 @@ export function logZodFailure(context: string, error: unknown, rawInput?: unknow export function validateWithSchema( schema: z.ZodType, rawInput: unknown, - recordId: string + recordId: string, ): ValidationResult { - const result = schema.safeParse(rawInput) + const result = schema.safeParse(rawInput); if (!result.success) { - logZodFailure(`validateWithSchema [${recordId}]`, result.error, rawInput) - return { success: false, error: result.error } + logZodFailure(`validateWithSchema [${recordId}]`, result.error, rawInput); + return { success: false, error: result.error }; } - return { success: true, validated: result.data } + return { success: true, validated: result.data }; } /** @@ -124,30 +125,30 @@ export function validateWithSchema( export async function validateFetchJson( response: Response, schema: z.ZodType, - recordId: string + recordId: string, ): Promise<{ success: true; validated: T } | { success: false; error: string }> { if (!response.ok) { - const errorMessage = `HTTP ${response.status}: ${response.statusText}` - console.error(`[Fetch] ${recordId} failed: ${errorMessage}`) - return { success: false, error: errorMessage } + const errorMessage = `HTTP ${response.status}: ${response.statusText}`; + console.error(`[Fetch] ${recordId} failed: ${errorMessage}`); + return { success: false, error: errorMessage }; } - let fetchedJson: unknown + let fetchedJson: unknown; try { - fetchedJson = await response.json() + fetchedJson = await response.json(); } catch (parseError) { - const errorMessage = `JSON parse failed: ${parseError instanceof Error ? parseError.message : String(parseError)}` - console.error(`[Fetch] ${recordId} ${errorMessage}`) - return { success: false, error: errorMessage } + const errorMessage = `JSON parse failed: ${parseError instanceof Error ? parseError.message : String(parseError)}`; + console.error(`[Fetch] ${recordId} ${errorMessage}`); + return { success: false, error: errorMessage }; } - const validationResult = validateWithSchema(schema, fetchedJson, recordId) + const validationResult = validateWithSchema(schema, fetchedJson, recordId); if (!validationResult.success) { - return { success: false, error: `Validation failed for ${recordId}` } + return { success: false, error: `Validation failed for ${recordId}` }; } - return { success: true, validated: validationResult.validated } + return { success: true, validated: validationResult.validated }; } /** @@ -156,5 +157,5 @@ export async function validateFetchJson( * Use this instead of unsafe `as Record` casts. */ export function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value) + return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 4a086a1f..95cda7e2 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,9 +1,9 @@ -import { mount } from 'svelte' -import App from './App.svelte' -import './styles/global.css' +import { mount } from "svelte"; +import App from "./App.svelte"; +import "./styles/global.css"; const app = mount(App, { - target: document.getElementById('app')! -}) + target: document.getElementById("app")!, +}); -export default app +export default app; diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index e486509d..e02e4489 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -35,7 +35,7 @@ /* Borders */ --color-border-subtle: rgba(255, 252, 247, 0.06); - --color-border-default: rgba(255, 252, 247, 0.10); + --color-border-default: rgba(255, 252, 247, 0.1); --color-border-strong: rgba(255, 252, 247, 0.16); /* Semantic */ @@ -49,18 +49,21 @@ * ═══════════════════════════════════════════════════════════════════════════ */ @font-face { - font-family: 'Fraunces'; - src: url('/fonts/Fraunces-Variable.ttf') format('truetype'); + font-family: "Fraunces"; + src: url("/fonts/Fraunces-Variable.ttf") format("truetype"); font-weight: 100 900; font-display: swap; font-style: normal italic; /* Force "Clean" axes defaults: no wonky, no soft, text-optimized opsz base */ - font-variation-settings: 'SOFT' 0, 'WONK' 0, 'opsz' 9; + font-variation-settings: + "SOFT" 0, + "WONK" 0, + "opsz" 9; } - --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; - --font-serif: 'Fraunces', 'Georgia', 'Times New Roman', serif; - --font-mono: 'DM Mono', 'SF Mono', 'Fira Code', 'Consolas', monospace; + --font-sans: "DM Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif; + --font-serif: "Fraunces", "Georgia", "Times New Roman", serif; + --font-mono: "DM Mono", "SF Mono", "Fira Code", "Consolas", monospace; /* Type scale - Minor third (1.2) */ --text-xs: 0.694rem; @@ -89,18 +92,18 @@ * ═══════════════════════════════════════════════════════════════════════════ */ --space-0: 0; - --space-1: 0.25rem; /* 4px */ - --space-2: 0.5rem; /* 8px */ - --space-3: 0.75rem; /* 12px */ - --space-4: 1rem; /* 16px */ - --space-5: 1.25rem; /* 20px */ - --space-6: 1.5rem; /* 24px */ - --space-8: 2rem; /* 32px */ - --space-10: 2.5rem; /* 40px */ - --space-12: 3rem; /* 48px */ - --space-16: 4rem; /* 64px */ - --space-20: 5rem; /* 80px */ - --space-24: 6rem; /* 96px */ + --space-1: 0.25rem; /* 4px */ + --space-2: 0.5rem; /* 8px */ + --space-3: 0.75rem; /* 12px */ + --space-4: 1rem; /* 16px */ + --space-5: 1.25rem; /* 20px */ + --space-6: 1.5rem; /* 24px */ + --space-8: 2rem; /* 32px */ + --space-10: 2.5rem; /* 40px */ + --space-12: 3rem; /* 48px */ + --space-16: 4rem; /* 64px */ + --space-20: 5rem; /* 80px */ + --space-24: 6rem; /* 96px */ /* ═══════════════════════════════════════════════════════════════════════════ * RADIUS & SHADOWS @@ -165,7 +168,9 @@ * RESET & BASE * ═══════════════════════════════════════════════════════════════════════════ */ -*, *::before, *::after { +*, +*::before, +*::after { box-sizing: border-box; margin: 0; padding: 0; @@ -270,8 +275,12 @@ body { * ═══════════════════════════════════════════════════════════════════════════ */ @keyframes fade-in { - from { opacity: 0; } - to { opacity: 1; } + from { + opacity: 0; + } + to { + opacity: 1; + } } @keyframes fade-in-up { @@ -297,17 +306,29 @@ body { } @keyframes pulse-subtle { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.6; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.6; + } } @keyframes typing-cursor { - 0%, 100% { opacity: 1; } - 50% { opacity: 0; } + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0; + } } @keyframes bounce { - 0%, 80%, 100% { + 0%, + 80%, + 100% { transform: scale(0.8); opacity: 0.5; } @@ -329,7 +350,8 @@ body { * CODE HIGHLIGHTING - Tokyo Night inspired * ═══════════════════════════════════════════════════════════════════════════ */ -pre, code { +pre, +code { font-family: var(--font-mono); } @@ -362,19 +384,46 @@ pre code { } /* Syntax highlighting colors - warm palette */ -.hljs-keyword { color: #c45d3a; } -.hljs-string { color: #a8c28a; } -.hljs-number { color: #d4a04a; } -.hljs-comment { color: var(--color-text-muted); font-style: italic; } -.hljs-function { color: #7aade9; } -.hljs-class { color: #d4a04a; } -.hljs-variable { color: #e0d0b8; } -.hljs-operator { color: var(--color-text-tertiary); } -.hljs-punctuation { color: var(--color-text-tertiary); } -.hljs-type { color: #7aade9; } -.hljs-built_in { color: #c9a04a; } -.hljs-attr { color: #c45d3a; } -.hljs-meta { color: var(--color-text-muted); } +.hljs-keyword { + color: #c45d3a; +} +.hljs-string { + color: #a8c28a; +} +.hljs-number { + color: #d4a04a; +} +.hljs-comment { + color: var(--color-text-muted); + font-style: italic; +} +.hljs-function { + color: #7aade9; +} +.hljs-class { + color: #d4a04a; +} +.hljs-variable { + color: #e0d0b8; +} +.hljs-operator { + color: var(--color-text-tertiary); +} +.hljs-punctuation { + color: var(--color-text-tertiary); +} +.hljs-type { + color: #7aade9; +} +.hljs-built_in { + color: #c9a04a; +} +.hljs-attr { + color: #c45d3a; +} +.hljs-meta { + color: var(--color-text-muted); +} /* ═══════════════════════════════════════════════════════════════════════════ * ENRICHMENT CARDS - Hint, Warning, Background, Example, Reminder diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts index b545bbe0..4d9051ee 100644 --- a/frontend/src/test/setup.ts +++ b/frontend/src/test/setup.ts @@ -1,7 +1,7 @@ -import '@testing-library/jest-dom/vitest' +import "@testing-library/jest-dom/vitest"; // Mock window.matchMedia for components that use media queries -Object.defineProperty(window, 'matchMedia', { +Object.defineProperty(window, "matchMedia", { writable: true, value: (query: string) => ({ matches: false, @@ -11,18 +11,19 @@ Object.defineProperty(window, 'matchMedia', { removeListener: () => {}, addEventListener: () => {}, removeEventListener: () => {}, - dispatchEvent: () => false - }) -}) + dispatchEvent: () => false, + }), +}); // jsdom doesn't implement scrollTo on elements; components use it for chat auto-scroll. // oxlint-disable-next-line no-extend-native -- jsdom polyfill, not production code -Object.defineProperty(HTMLElement.prototype, 'scrollTo', { +Object.defineProperty(HTMLElement.prototype, "scrollTo", { writable: true, - value: () => {} -}) + value: () => {}, +}); // requestAnimationFrame is used for post-update DOM adjustments; provide a safe fallback. -if (typeof window.requestAnimationFrame !== 'function') { - window.requestAnimationFrame = (callback: FrameRequestCallback) => window.setTimeout(() => callback(performance.now()), 0) +if (typeof window.requestAnimationFrame !== "function") { + window.requestAnimationFrame = (callback: FrameRequestCallback) => + window.setTimeout(() => callback(performance.now()), 0); } diff --git a/frontend/svelte.config.js b/frontend/svelte.config.js index 09a1bf7a..d0e64483 100644 --- a/frontend/svelte.config.js +++ b/frontend/svelte.config.js @@ -1,5 +1,5 @@ -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte' +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; export default { - preprocess: vitePreprocess() -} + preprocess: vitePreprocess(), +}; diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 8f53b1e0..c3d22a91 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,9 +1,9 @@ -import { defineConfig, type HtmlTagDescriptor } from 'vite' -import { svelte } from '@sveltejs/vite-plugin-svelte' +import { defineConfig, type HtmlTagDescriptor } from "vite"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; -const SIMPLE_ANALYTICS_QUEUE_ORIGIN = 'https://queue.simpleanalyticscdn.com' -const SIMPLE_ANALYTICS_HOSTNAME = 'javachat.ai' -const SIMPLE_ANALYTICS_SCRIPT_URL = 'https://scripts.simpleanalyticscdn.com/latest.js' +const SIMPLE_ANALYTICS_QUEUE_ORIGIN = "https://queue.simpleanalyticscdn.com"; +const SIMPLE_ANALYTICS_HOSTNAME = "javachat.ai"; +const SIMPLE_ANALYTICS_SCRIPT_URL = "https://scripts.simpleanalyticscdn.com/latest.js"; const SIMPLE_ANALYTICS_RUNTIME_GUARD_SCRIPT = `;(function () { if (globalThis.location.hostname !== '${SIMPLE_ANALYTICS_HOSTNAME}') { return @@ -14,80 +14,79 @@ const SIMPLE_ANALYTICS_RUNTIME_GUARD_SCRIPT = `;(function () { analyticsScript.src = '${SIMPLE_ANALYTICS_SCRIPT_URL}' analyticsScript.setAttribute('data-hostname', '${SIMPLE_ANALYTICS_HOSTNAME}') document.head.appendChild(analyticsScript) -})()` +})()`; function buildSimpleAnalyticsTags(mode: string): HtmlTagDescriptor[] { - if (mode !== 'production') { - return [] + if (mode !== "production") { + return []; } - const noScriptImageUrl = - `${SIMPLE_ANALYTICS_QUEUE_ORIGIN}/noscript.gif?hostname=${encodeURIComponent(SIMPLE_ANALYTICS_HOSTNAME)}` + const noScriptImageUrl = `${SIMPLE_ANALYTICS_QUEUE_ORIGIN}/noscript.gif?hostname=${encodeURIComponent(SIMPLE_ANALYTICS_HOSTNAME)}`; return [ { - tag: 'script', + tag: "script", children: SIMPLE_ANALYTICS_RUNTIME_GUARD_SCRIPT, - injectTo: 'body', + injectTo: "body", }, { - tag: 'noscript', + tag: "noscript", children: [ { - tag: 'img', + tag: "img", attrs: { src: noScriptImageUrl, - alt: '', - referrerpolicy: 'no-referrer-when-downgrade', + alt: "", + referrerpolicy: "no-referrer-when-downgrade", }, }, ], - injectTo: 'body', + injectTo: "body", }, - ] + ]; } export default defineConfig(({ mode }) => ({ plugins: [ svelte(), { - name: 'simple-analytics', + name: "simple-analytics", transformIndexHtml() { - return buildSimpleAnalyticsTags(mode) + return buildSimpleAnalyticsTags(mode); }, }, ], - base: '/', + base: "/", server: { port: 5173, proxy: { - '/api': { - target: 'http://localhost:8085', - changeOrigin: true + "/api": { + target: "http://localhost:8085", + changeOrigin: true, }, - '/actuator': { - target: 'http://localhost:8085', - changeOrigin: true - } - } + "/actuator": { + target: "http://localhost:8085", + changeOrigin: true, + }, + }, }, build: { // Build directly to Spring Boot static resources - outDir: '../src/main/resources/static', + outDir: "../src/main/resources/static", emptyOutDir: false, // Don't delete favicons rollupOptions: { output: { manualChunks: { - 'highlight': [ - 'highlight.js/lib/core', - 'highlight.js/lib/languages/java', - 'highlight.js/lib/languages/xml', - 'highlight.js/lib/languages/json', - 'highlight.js/lib/languages/bash' + highlight: [ + "highlight.js/lib/core", + "highlight.js/lib/languages/java", + "highlight.js/lib/languages/xml", + "highlight.js/lib/languages/json", + "highlight.js/lib/languages/bash", ], - 'markdown': ['marked'] - } - } - } - } -})) + markdown: ["marked"], + }, + }, + }, + }, +})); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts index b1337b1c..5feb2d2c 100644 --- a/frontend/vitest.config.ts +++ b/frontend/vitest.config.ts @@ -1,5 +1,5 @@ -import { defineConfig } from 'vitest/config' -import { svelte } from '@sveltejs/vite-plugin-svelte' +import { defineConfig } from "vitest/config"; +import { svelte } from "@sveltejs/vite-plugin-svelte"; export default defineConfig({ plugins: [svelte({ hot: !process.env.VITEST })], @@ -7,18 +7,18 @@ export default defineConfig({ // conditional exports to resolve Svelte's server entry (where `mount()` is unavailable). // Force browser conditions so component tests can mount under jsdom. resolve: { - conditions: ['module', 'browser', 'development'] + conditions: ["module", "browser", "development"], }, test: { - environment: 'jsdom', + environment: "jsdom", globals: true, - include: ['src/**/*.{test,spec}.{js,ts}'], - setupFiles: ['./src/test/setup.ts'], + include: ["src/**/*.{test,spec}.{js,ts}"], + setupFiles: ["./src/test/setup.ts"], coverage: { - provider: 'v8', - reporter: ['text', 'html'], - include: ['src/lib/**/*.{ts,svelte}'], - exclude: ['src/lib/**/*.test.ts', 'src/test/**'] - } - } -}) + provider: "v8", + reporter: ["text", "html"], + include: ["src/lib/**/*.{ts,svelte}"], + exclude: ["src/lib/**/*.test.ts", "src/test/**"], + }, + }, +}); diff --git a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java index e0cdcf75..01681589 100644 --- a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java +++ b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java @@ -35,6 +35,7 @@ public class AppProperties { private static final String QDRANT_KEY = "app.qdrant"; private static final String CORS_KEY = "app.cors"; private static final String PUBLIC_BASE_URL_KEY = "app.public-base-url"; + private static final String CLICKY_KEY = "app.clicky"; private static final String EMBEDDINGS_KEY = "app.embeddings"; private static final String LLM_KEY = "app.llm"; private static final String GUIDED_LEARNING_KEY = "app.guided-learning"; @@ -57,6 +58,7 @@ public class AppProperties { private Embeddings embeddings = new Embeddings(); private Llm llm = new Llm(); private GuidedLearning guidedLearning = new GuidedLearning(); + private Clicky clicky = new Clicky(); private String publicBaseUrl = DEFAULT_PUBLIC_BASE_URL; /** @@ -81,6 +83,7 @@ void validateConfiguration() { requireConfiguredSection(embeddings, EMBEDDINGS_KEY).validateConfiguration(); requireConfiguredSection(llm, LLM_KEY).validateConfiguration(); requireConfiguredSection(guidedLearning, GUIDED_LEARNING_KEY).validateConfiguration(); + requireConfiguredSection(clicky, CLICKY_KEY).validateConfiguration(); this.publicBaseUrl = validatePublicBaseUrl(publicBaseUrl); } @@ -102,6 +105,14 @@ public void setPublicBaseUrl(final String publicBaseUrl) { this.publicBaseUrl = publicBaseUrl; } + public Clicky getClicky() { + return clicky; + } + + public void setClicky(Clicky clicky) { + this.clicky = requireConfiguredSection(clicky, CLICKY_KEY); + } + public GuidedLearning getGuidedLearning() { return guidedLearning; } @@ -249,6 +260,54 @@ private static T requireConfiguredSection(T section, String sectionKey) { return section; } + /** Clicky analytics configuration. */ + public static class Clicky { + private boolean enabled = false; + private String siteId = ""; + private long parsedSiteId = -1L; + + public boolean isEnabled() { + return enabled; + } + + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + public String getSiteId() { + return siteId; + } + + public void setSiteId(String siteId) { + this.siteId = siteId; + } + + public long getParsedSiteId() { + return parsedSiteId; + } + + Clicky validateConfiguration() { + if (!enabled) { + parsedSiteId = -1L; + return this; + } + + if (siteId == null || siteId.isBlank()) { + throw new IllegalArgumentException("app.clicky.site-id must not be blank when app.clicky.enabled=true"); + } + + String trimmedSiteId = siteId.trim(); + boolean allDigits = trimmedSiteId.chars().allMatch(Character::isDigit); + if (!allDigits) { + throw new IllegalArgumentException( + "app.clicky.site-id must contain digits only, got: " + trimmedSiteId); + } + + parsedSiteId = Long.parseLong(trimmedSiteId); + return this; + } + } + /** Qdrant vector store settings. */ public static class Qdrant { private boolean ensurePayloadIndexes = true; diff --git a/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java new file mode 100644 index 00000000..5b53dca6 --- /dev/null +++ b/src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java @@ -0,0 +1,72 @@ +package com.williamcallahan.javachat.web; + +import com.williamcallahan.javachat.config.AppProperties; +import java.util.Objects; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.springframework.stereotype.Component; + +/** + * Injects or removes Clicky analytics script tags from server-rendered HTML documents. + * + *

When Clicky analytics is enabled, this component appends the site-ID initializer + * and the async script loader to the document {@code }. When disabled, it strips + * any existing Clicky tags to prevent double-injection from cached templates. + * + *

Owns all Clicky-specific DOM mutations so that controllers remain free of + * analytics concerns. + */ +@Component +public class ClickyAnalyticsInjector { + + private static final String CLICKY_SCRIPT_URL = "https://static.getclicky.com/js"; + private static final String CLICKY_INITIALIZER_TEMPLATE = + "var clicky_site_ids = clicky_site_ids || []; clicky_site_ids.push(%d);"; + + private final boolean clickyEnabled; + private final long clickySiteId; + + /** + * Reads Clicky configuration from the validated application properties. + */ + public ClickyAnalyticsInjector(AppProperties appProperties) { + AppProperties.Clicky clicky = + Objects.requireNonNull(appProperties, "appProperties").getClicky(); + this.clickyEnabled = clicky.isEnabled(); + this.clickySiteId = clicky.getParsedSiteId(); + } + + /** + * Applies Clicky analytics to the document: injects tags when enabled, removes them when disabled. + * + * @param document the Jsoup document whose {@code } will be modified in place + */ + public void applyTo(Document document) { + Element existingClickyLoader = document.head().selectFirst("script[src=\"" + CLICKY_SCRIPT_URL + "\"]"); + + if (!clickyEnabled) { + removeClickyTags(document, existingClickyLoader); + return; + } + + if (existingClickyLoader != null) { + return; + } + + String initializer = String.format(CLICKY_INITIALIZER_TEMPLATE, clickySiteId); + document.head().appendElement("script").text(initializer); + document.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL); + } + + private void removeClickyTags(Document document, Element existingLoader) { + if (existingLoader != null) { + existingLoader.remove(); + } + document.head().select("script").forEach(scriptTag -> { + String scriptBody = scriptTag.html(); + if (scriptBody != null && scriptBody.contains("clicky_site_ids")) { + scriptTag.remove(); + } + }); + } +} diff --git a/src/main/java/com/williamcallahan/javachat/web/SeoController.java b/src/main/java/com/williamcallahan/javachat/web/SeoController.java index 5cc5cd80..e22fa15a 100644 --- a/src/main/java/com/williamcallahan/javachat/web/SeoController.java +++ b/src/main/java/com/williamcallahan/javachat/web/SeoController.java @@ -11,6 +11,8 @@ import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.nodes.Element; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.Resource; import org.springframework.http.MediaType; @@ -30,19 +32,27 @@ @PreAuthorize("permitAll()") public class SeoController { + private static final Logger log = LoggerFactory.getLogger(SeoController.class); + private final Resource indexHtml; private final SiteUrlResolver siteUrlResolver; + private final ClickyAnalyticsInjector clickyAnalyticsInjector; private final Map metadataMap = new ConcurrentHashMap<>(); // Cache the parsed document to avoid re-reading files, but clone it per request to modify private Document cachedIndexDocument; /** - * Creates the SEO controller using the built SPA index.html template and a base URL resolver. + * Creates the SEO controller using the built SPA index.html template, a base URL resolver, + * and an analytics injector for Clicky script management. */ - public SeoController(@Value("classpath:/static/index.html") Resource indexHtml, SiteUrlResolver siteUrlResolver) { + public SeoController( + @Value("classpath:/static/index.html") Resource indexHtml, + SiteUrlResolver siteUrlResolver, + ClickyAnalyticsInjector clickyAnalyticsInjector) { this.indexHtml = indexHtml; this.siteUrlResolver = siteUrlResolver; + this.clickyAnalyticsInjector = Objects.requireNonNull(clickyAnalyticsInjector, "clickyAnalyticsInjector"); initMetadata(); } @@ -91,6 +101,7 @@ public ResponseEntity serveIndexWithSeo(HttpServletRequest request) { return ResponseEntity.ok(doc.html()); } catch (IOException contentLoadException) { + log.error("Failed to load SPA index.html from classpath", contentLoadException); return ResponseEntity.internalServerError().body("Error loading content"); } } @@ -128,6 +139,9 @@ private void updateDocumentMetadata(Document doc, PageMetadata metadata, String // Structured Data (JSON-LD) updateJsonLd(doc, fullUrl, metadata.description); + + // Analytics + clickyAnalyticsInjector.applyTo(doc); } private void updateCanonicalLink(Document doc, String fullUrl) { diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties index b5e79e2a..832531fd 100644 --- a/src/main/resources/application-dev.properties +++ b/src/main/resources/application-dev.properties @@ -9,6 +9,9 @@ app.diagnostics.streamChunkSample=0 # Override with PUBLIC_BASE_URL=https://dev.javachat.ai for deployed dev. app.public-base-url=${PUBLIC_BASE_URL:http://localhost:8085} +# Disable Clicky by default for local development. +app.clicky.enabled=false + # OpenAI Java SDK streaming configuration (OpenAIStreamingService) # Base URLs are used by the SDK directly (not Spring AI). # Defaults are set via @Value annotations in OpenAIStreamingService.java; diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 2bf5297c..943089c4 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -10,6 +10,10 @@ server.forward-headers-strategy=framework # Canonical public base URL used for SEO responses (sitemap.xml, robots.txt, OpenGraph/canonical tags) app.public-base-url=${PUBLIC_BASE_URL:https://javachat.ai} +# Clicky analytics (enabled by default in prod; disable in dev profile) +app.clicky.enabled=true +app.clicky.site-id=101501246 + # Memory-sensitive defaults for 512MB container budgets # Note: lazy-initialization=true defers bean creation to first use, reducing startup memory but moving errors to runtime spring.main.lazy-initialization=${SPRING_MAIN_LAZY_INITIALIZATION:true} diff --git a/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java b/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java index 2cbc9211..af8a99e0 100644 --- a/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java +++ b/src/test/java/com/williamcallahan/javachat/web/SeoControllerTest.java @@ -23,7 +23,7 @@ * Verifies SEO HTML responses include expected metadata. */ @WebMvcTest(controllers = SeoController.class) -@Import({SiteUrlResolver.class, com.williamcallahan.javachat.config.AppProperties.class}) +@Import({SiteUrlResolver.class, ClickyAnalyticsInjector.class, com.williamcallahan.javachat.config.AppProperties.class}) @TestPropertySource(properties = "app.public-base-url=https://example.com") @WithMockUser class SeoControllerTest {