From 53bc987fb1c83a84344ddb7b90de4aafcc576d33 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 13 Feb 2026 23:23:37 -0800 Subject: [PATCH 01/10] docs: codify secrets and config source policy This repo deploys via Coolify containers (BuildKit secrets) and runs locally with `.env`. Codify the rule that secrets never belong in tracked Spring property/YAML files, while keeping local/dev ergonomics intact. - Add [EV1] policy for secrets + env var handling (OpenAI/GitHub Models, Qdrant) - Clarify [TL1e] secrets storage expectations - Clarify [LM1c] that only provider connectivity comes from `.env`/env vars --- AGENTS.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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`. From cdae404c6fc1a3205820f1c6f49341e3f4c261dc Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 13 Feb 2026 23:24:38 -0800 Subject: [PATCH 02/10] feat(seo): inject Clicky analytics into SPA shell Add optional Clicky analytics injection to the SEO-rendered SPA shell so SPA routes are tracked without a frontend rebuild. This follows the standard Clicky pattern (clicky_site_ids + async loader) and is disabled for local dev. - Inject Clicky initializer + async script loader when enabled - Remove any Clicky tags when disabled to avoid double-injection - Validate app.clicky.site-id digits-only when enabled - Enable Clicky by default in prod; disable in dev profile --- .../javachat/web/SeoController.java | 55 ++++++++++++++++++- src/main/resources/application-dev.properties | 3 + src/main/resources/application.properties | 4 ++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/SeoController.java b/src/main/java/com/williamcallahan/javachat/web/SeoController.java index 5cc5cd80..fc62bd91 100644 --- a/src/main/java/com/williamcallahan/javachat/web/SeoController.java +++ b/src/main/java/com/williamcallahan/javachat/web/SeoController.java @@ -30,9 +30,13 @@ @PreAuthorize("permitAll()") public class SeoController { + private static final String CLICKY_SCRIPT_URL = "https://static.getclicky.com/js"; + private final Resource indexHtml; private final SiteUrlResolver siteUrlResolver; private final Map metadataMap = new ConcurrentHashMap<>(); + private final boolean clickyEnabled; + private final long clickySiteId; // Cache the parsed document to avoid re-reading files, but clone it per request to modify private Document cachedIndexDocument; @@ -40,9 +44,15 @@ public class SeoController { /** * Creates the SEO controller using the built SPA index.html template and a base URL resolver. */ - public SeoController(@Value("classpath:/static/index.html") Resource indexHtml, SiteUrlResolver siteUrlResolver) { + public SeoController( + @Value("classpath:/static/index.html") Resource indexHtml, + SiteUrlResolver siteUrlResolver, + @Value("${app.clicky.enabled:false}") boolean clickyEnabled, + @Value("${app.clicky.site-id:}") String clickySiteId) { this.indexHtml = indexHtml; this.siteUrlResolver = siteUrlResolver; + this.clickyEnabled = clickyEnabled; + this.clickySiteId = clickyEnabled ? parseClickySiteId(clickySiteId) : -1L; initMetadata(); } @@ -128,6 +138,49 @@ private void updateDocumentMetadata(Document doc, PageMetadata metadata, String // Structured Data (JSON-LD) updateJsonLd(doc, fullUrl, metadata.description); + + // Analytics + updateClickyAnalytics(doc); + } + + private void updateClickyAnalytics(Document doc) { + Element existingClickyLoader = doc.head().selectFirst("script[src=\"" + CLICKY_SCRIPT_URL + "\"]"); + if (!clickyEnabled) { + if (existingClickyLoader != null) { + existingClickyLoader.remove(); + } + doc.head().select("script").forEach(scriptTag -> { + String scriptBody = scriptTag.html(); + if (scriptBody != null && scriptBody.contains("clicky_site_ids")) { + scriptTag.remove(); + } + }); + return; + } + + if (existingClickyLoader != null) { + return; + } + + String initializer = "var clicky_site_ids = clicky_site_ids || []; clicky_site_ids.push(" + clickySiteId + ");"; + doc.head().appendElement("script").text(initializer); + doc.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL); + } + + private static long parseClickySiteId(String rawSiteId) { + if (rawSiteId == null || rawSiteId.isBlank()) { + throw new IllegalStateException("Clicky is enabled but app.clicky.site-id is blank."); + } + + String trimmedSiteId = rawSiteId.trim(); + for (int characterIndex = 0; characterIndex < trimmedSiteId.length(); characterIndex++) { + char character = trimmedSiteId.charAt(characterIndex); + if (character < '0' || character > '9') { + throw new IllegalStateException("app.clicky.site-id must contain digits only, got: " + trimmedSiteId); + } + } + + return Long.parseLong(trimmedSiteId); } 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} From 9712df1499728e0660922e5fcbb0c283ed4b16f9 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Fri, 13 Feb 2026 23:40:19 -0800 Subject: [PATCH 03/10] fix(frontend): satisfy type-aware lint diagnostics Type-aware oxlint flagged two issues: unsafe stringification of unknown stream failures (Object default "[object Object]") and unnecessary type assertions on marked.parse() results. This keeps lint green without changing app behavior. - Format unknown stream failure diagnostics safely (JSON/string-aware) - Remove unnecessary `as string` casts on marked.parse() return values --- frontend/src/lib/services/markdown.ts | 4 +-- frontend/src/lib/services/streamRecovery.ts | 27 ++++++++++++++++++++- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/frontend/src/lib/services/markdown.ts b/frontend/src/lib/services/markdown.ts index e2f2d6b1..104fb6c6 100644 --- a/frontend/src/lib/services/markdown.ts +++ b/frontend/src/lib/services/markdown.ts @@ -323,7 +323,7 @@ function createEnrichmentExtension(): TokenizerExtension & RendererExtension { async: false, gfm: true, breaks: false // Preserve fence detection accuracy - }) as string + }) return `
${meta.icon}${meta.title}
@@ -375,7 +375,7 @@ export function parseMarkdown(content: string): string { } 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 }, diff --git a/frontend/src/lib/services/streamRecovery.ts b/frontend/src/lib/services/streamRecovery.ts index 1cb54e4e..6096fbc3 100644 --- a/frontend/src/lib/services/streamRecovery.ts +++ b/frontend/src/lib/services/streamRecovery.ts @@ -239,7 +239,32 @@ function describeStreamFailure(streamFailure: unknown, streamErrorEvent: StreamE 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) + } +} From 99ab4e244c3a614a652712142ad8c31e4132bbe4 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sat, 14 Feb 2026 00:35:13 -0800 Subject: [PATCH 04/10] refactor(seo): bind Clicky analytics via @ConfigurationProperties SeoController was injecting Clicky settings through ad-hoc @Value annotations and duplicating the site-ID parsing/validation logic locally. This moves the configuration into AppProperties.Clicky, validated at startup alongside all other app.* sections, so parsing runs once and SeoController receives an already-validated value object through constructor injection. - Add AppProperties.Clicky inner class with enabled/siteId/parsedSiteId fields - Wire Clicky into requireConfiguredSection startup validation - Replace @Value injections in SeoController with AppProperties dependency - Remove SeoController.parseClickySiteId (logic now lives in Clicky.validateConfiguration) --- .../javachat/config/AppProperties.java | 60 +++++++++++++++++++ .../javachat/web/SeoController.java | 25 ++------ 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java index e0cdcf75..0f0fa9e8 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,55 @@ 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(); + for (int characterIndex = 0; characterIndex < trimmedSiteId.length(); characterIndex++) { + char character = trimmedSiteId.charAt(characterIndex); + if (character < '0' || character > '9') { + 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/SeoController.java b/src/main/java/com/williamcallahan/javachat/web/SeoController.java index fc62bd91..8da50873 100644 --- a/src/main/java/com/williamcallahan/javachat/web/SeoController.java +++ b/src/main/java/com/williamcallahan/javachat/web/SeoController.java @@ -1,5 +1,6 @@ package com.williamcallahan.javachat.web; +import com.williamcallahan.javachat.config.AppProperties; import com.fasterxml.jackson.core.io.JsonStringEncoder; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; @@ -47,12 +48,12 @@ public class SeoController { public SeoController( @Value("classpath:/static/index.html") Resource indexHtml, SiteUrlResolver siteUrlResolver, - @Value("${app.clicky.enabled:false}") boolean clickyEnabled, - @Value("${app.clicky.site-id:}") String clickySiteId) { + AppProperties appProperties) { this.indexHtml = indexHtml; this.siteUrlResolver = siteUrlResolver; - this.clickyEnabled = clickyEnabled; - this.clickySiteId = clickyEnabled ? parseClickySiteId(clickySiteId) : -1L; + AppProperties.Clicky clicky = Objects.requireNonNull(appProperties, "appProperties").getClicky(); + this.clickyEnabled = clicky.isEnabled(); + this.clickySiteId = clicky.getParsedSiteId(); initMetadata(); } @@ -167,22 +168,6 @@ private void updateClickyAnalytics(Document doc) { doc.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL); } - private static long parseClickySiteId(String rawSiteId) { - if (rawSiteId == null || rawSiteId.isBlank()) { - throw new IllegalStateException("Clicky is enabled but app.clicky.site-id is blank."); - } - - String trimmedSiteId = rawSiteId.trim(); - for (int characterIndex = 0; characterIndex < trimmedSiteId.length(); characterIndex++) { - char character = trimmedSiteId.charAt(characterIndex); - if (character < '0' || character > '9') { - throw new IllegalStateException("app.clicky.site-id must contain digits only, got: " + trimmedSiteId); - } - } - - return Long.parseLong(trimmedSiteId); - } - private void updateCanonicalLink(Document doc, String fullUrl) { Element canonical = doc.head().selectFirst("link[rel=canonical]"); if (canonical != null) { From 95b4a175c7ed823cdf3ba63897bb39a213701e3b Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sat, 14 Feb 2026 00:35:45 -0800 Subject: [PATCH 05/10] style: apply oxfmt and Spotless formatting across codebase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run oxfmt to enforce the frontend's canonical formatting: double quotes, trailing commas, statement semicolons, and consistent line wrapping across TypeScript sources, tests, HTML, CSS, JSON config, and build configs. Spotless also reformatted a few long lines in the Java files touched by the previous commit. No behavioral changes — only whitespace and punctuation normalization. --- frontend/config/oxlintrc.json | 9 +- frontend/index.html | 141 ++++--- frontend/package.json | 28 +- frontend/src/lib/components/ChatView.test.ts | 90 ++-- frontend/src/lib/components/LearnView.test.ts | 238 ++++++----- .../composables/createScrollAnchor.svelte.ts | 106 ++--- .../createStreamingState.svelte.ts | 90 ++-- frontend/src/lib/services/chat.test.ts | 132 +++--- frontend/src/lib/services/chat.ts | 87 ++-- frontend/src/lib/services/csrf.test.ts | 4 +- frontend/src/lib/services/guided.test.ts | 146 +++---- frontend/src/lib/services/guided.ts | 68 +-- frontend/src/lib/services/markdown.test.ts | 307 +++++++------- frontend/src/lib/services/markdown.ts | 387 +++++++++--------- frontend/src/lib/services/sse.test.ts | 84 ++-- frontend/src/lib/services/sse.ts | 236 +++++------ .../src/lib/services/streamRecovery.test.ts | 157 +++---- frontend/src/lib/services/streamRecovery.ts | 219 +++++----- frontend/src/lib/stores/toastStore.ts | 48 +-- frontend/src/lib/utils/chatMessageId.ts | 23 +- frontend/src/lib/utils/highlight.ts | 78 ++-- frontend/src/lib/utils/scroll.test.ts | 164 ++++---- frontend/src/lib/utils/scroll.ts | 46 ++- frontend/src/lib/utils/session.test.ts | 91 ++-- frontend/src/lib/utils/session.ts | 22 +- frontend/src/lib/utils/url.test.ts | 216 +++++----- frontend/src/lib/utils/url.ts | 136 +++--- frontend/src/lib/validation/schemas.ts | 54 +-- frontend/src/lib/validation/validate.ts | 79 ++-- frontend/src/main.ts | 12 +- frontend/src/styles/global.css | 131 ++++-- frontend/src/test/setup.ts | 21 +- frontend/svelte.config.js | 6 +- frontend/vite.config.ts | 83 ++-- frontend/vitest.config.ts | 26 +- .../javachat/config/AppProperties.java | 3 +- .../javachat/web/SeoController.java | 5 +- 37 files changed, 1971 insertions(+), 1802 deletions(-) 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 104fb6c6..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 - }) + 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 }) + 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 6096fbc3..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,64 +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(formatUnknownDiagnostic(streamFailure)) + diagnosticTokens.push(formatUnknownDiagnostic(streamFailure)); } - return diagnosticTokens.join(' ').trim() + return diagnosticTokens.join(" ").trim(); } function formatUnknownDiagnostic(value: unknown): string { - if (typeof value === 'string') { - return value + if (typeof value === "string") { + return value; } - if (typeof value === 'number' || typeof value === 'boolean') { - return String(value) + if (typeof value === "number" || typeof value === "boolean") { + return String(value); } - if (typeof value === 'bigint') { - return value.toString() + if (typeof value === "bigint") { + return value.toString(); } - if (typeof value === 'symbol') { - return value.description ?? 'Symbol' + if (typeof value === "symbol") { + return value.description ?? "Symbol"; } - if (typeof value === 'function') { - return value.name ? `[function ${value.name}]` : '[function]' + if (typeof value === "function") { + return value.name ? `[function ${value.name}]` : "[function]"; } try { - const json = JSON.stringify(value) - return json === undefined ? '' : json + const json = JSON.stringify(value); + return json === undefined ? "" : json; } catch { - return Object.prototype.toString.call(value) + return Object.prototype.toString.call(value); } } 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 0f0fa9e8..a40c17a8 100644 --- a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java +++ b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java @@ -300,7 +300,8 @@ Clicky validateConfiguration() { for (int characterIndex = 0; characterIndex < trimmedSiteId.length(); characterIndex++) { char character = trimmedSiteId.charAt(characterIndex); if (character < '0' || character > '9') { - throw new IllegalArgumentException("app.clicky.site-id must contain digits only, got: " + trimmedSiteId); + throw new IllegalArgumentException( + "app.clicky.site-id must contain digits only, got: " + trimmedSiteId); } } diff --git a/src/main/java/com/williamcallahan/javachat/web/SeoController.java b/src/main/java/com/williamcallahan/javachat/web/SeoController.java index 8da50873..774043ca 100644 --- a/src/main/java/com/williamcallahan/javachat/web/SeoController.java +++ b/src/main/java/com/williamcallahan/javachat/web/SeoController.java @@ -1,7 +1,7 @@ package com.williamcallahan.javachat.web; -import com.williamcallahan.javachat.config.AppProperties; import com.fasterxml.jackson.core.io.JsonStringEncoder; +import com.williamcallahan.javachat.config.AppProperties; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; @@ -51,7 +51,8 @@ public SeoController( AppProperties appProperties) { this.indexHtml = indexHtml; this.siteUrlResolver = siteUrlResolver; - AppProperties.Clicky clicky = Objects.requireNonNull(appProperties, "appProperties").getClicky(); + AppProperties.Clicky clicky = + Objects.requireNonNull(appProperties, "appProperties").getClicky(); this.clickyEnabled = clicky.isEnabled(); this.clickySiteId = clicky.getParsedSiteId(); initMetadata(); From a98116fc8a47fbc024b49e8103417facbd402638 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sat, 14 Feb 2026 01:18:43 -0800 Subject: [PATCH 06/10] fix(seo): add error logging and extract Clicky initializer constant SeoController's IOException handler silently discarded the exception with no log statement, making production failures invisible to operators. Additionally, the Clicky JavaScript initializer was an inline magic string rather than a named constant, reducing readability and violating naming discipline. - Add SLF4J Logger to SeoController for operational visibility - Log IOException with full stack trace before returning 500 response - Extract CLICKY_INITIALIZER_TEMPLATE constant for the site ID push script - Use String.format() instead of string concatenation for the initializer --- .../com/williamcallahan/javachat/web/SeoController.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/SeoController.java b/src/main/java/com/williamcallahan/javachat/web/SeoController.java index 774043ca..d8aa22ec 100644 --- a/src/main/java/com/williamcallahan/javachat/web/SeoController.java +++ b/src/main/java/com/williamcallahan/javachat/web/SeoController.java @@ -12,6 +12,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; @@ -31,7 +33,11 @@ @PreAuthorize("permitAll()") public class SeoController { + private static final Logger log = LoggerFactory.getLogger(SeoController.class); + 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 Resource indexHtml; private final SiteUrlResolver siteUrlResolver; @@ -103,6 +109,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"); } } @@ -164,7 +171,7 @@ private void updateClickyAnalytics(Document doc) { return; } - String initializer = "var clicky_site_ids = clicky_site_ids || []; clicky_site_ids.push(" + clickySiteId + ");"; + String initializer = String.format(CLICKY_INITIALIZER_TEMPLATE, clickySiteId); doc.head().appendElement("script").text(initializer); doc.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL); } From 4607510ae96c0c6fe38ccf0a69e30f8a13975054 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sat, 14 Feb 2026 01:19:08 -0800 Subject: [PATCH 07/10] refactor(config): replace manual ASCII loop with Character.isDigit() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicky site ID validation used a manual char-by-char loop with raw ASCII ordinal comparisons (character < '0' || character > '9'), which is harder to read than the idiomatic Java alternative. Replace with a single-expression stream using Character.isDigit() for clarity while preserving identical validation behavior — Long.parseLong() on the next line still rejects any non-ASCII digits that isDigit() might accept. - Replace 6-line manual loop with trimmedSiteId.chars().allMatch(Character::isDigit) - Extract boolean allDigits for self-documenting conditional --- .../williamcallahan/javachat/config/AppProperties.java | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java index a40c17a8..01681589 100644 --- a/src/main/java/com/williamcallahan/javachat/config/AppProperties.java +++ b/src/main/java/com/williamcallahan/javachat/config/AppProperties.java @@ -297,12 +297,10 @@ Clicky validateConfiguration() { } String trimmedSiteId = siteId.trim(); - for (int characterIndex = 0; characterIndex < trimmedSiteId.length(); characterIndex++) { - char character = trimmedSiteId.charAt(characterIndex); - if (character < '0' || character > '9') { - throw new IllegalArgumentException( - "app.clicky.site-id must contain digits only, got: " + trimmedSiteId); - } + 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); From f8f816fa67bbbb7a717778059f3d2e259fe5d435 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sat, 14 Feb 2026 01:40:55 -0800 Subject: [PATCH 08/10] refactor(seo): extract Clicky DOM injection into ClickyAnalyticsInjector SeoController was performing Clicky script detection, removal, and insertion directly in its updateClickyAnalytics method, mixing analytics DOM mutation with HTTP-to-HTML translation. This violates controller single responsibility (adapters/in/web should only delegate, not own business logic). - Create ClickyAnalyticsInjector @Component with applyTo(Document) method - Move CLICKY_SCRIPT_URL and CLICKY_INITIALIZER_TEMPLATE constants - Move clickyEnabled/clickySiteId fields and AppProperties.Clicky wiring - Extract removeClickyTags private helper for disabled-state cleanup --- .../javachat/web/ClickyAnalyticsInjector.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 src/main/java/com/williamcallahan/javachat/web/ClickyAnalyticsInjector.java 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(); + } + }); + } +} From fce1142842171133180786eefa487e2671ddca66 Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sat, 14 Feb 2026 01:41:03 -0800 Subject: [PATCH 09/10] refactor(seo): delegate Clicky analytics from SeoController to injector SeoController now delegates all Clicky DOM mutations to the new ClickyAnalyticsInjector component via a single applyTo(doc) call, removing the updateClickyAnalytics method and its analytics-specific constants/fields from the controller. - Replace updateClickyAnalytics with clickyAnalyticsInjector.applyTo(doc) - Remove CLICKY_SCRIPT_URL, CLICKY_INITIALIZER_TEMPLATE constants - Remove clickyEnabled, clickySiteId fields - Replace AppProperties constructor dependency with ClickyAnalyticsInjector --- .../javachat/web/SeoController.java | 44 +++---------------- 1 file changed, 6 insertions(+), 38 deletions(-) diff --git a/src/main/java/com/williamcallahan/javachat/web/SeoController.java b/src/main/java/com/williamcallahan/javachat/web/SeoController.java index d8aa22ec..e22fa15a 100644 --- a/src/main/java/com/williamcallahan/javachat/web/SeoController.java +++ b/src/main/java/com/williamcallahan/javachat/web/SeoController.java @@ -1,7 +1,6 @@ package com.williamcallahan.javachat.web; import com.fasterxml.jackson.core.io.JsonStringEncoder; -import com.williamcallahan.javachat.config.AppProperties; import jakarta.annotation.security.PermitAll; import jakarta.servlet.http.HttpServletRequest; import java.io.IOException; @@ -35,32 +34,25 @@ public class SeoController { private static final Logger log = LoggerFactory.getLogger(SeoController.class); - 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 Resource indexHtml; private final SiteUrlResolver siteUrlResolver; + private final ClickyAnalyticsInjector clickyAnalyticsInjector; private final Map metadataMap = new ConcurrentHashMap<>(); - private final boolean clickyEnabled; - private final long clickySiteId; // 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, - AppProperties appProperties) { + ClickyAnalyticsInjector clickyAnalyticsInjector) { this.indexHtml = indexHtml; this.siteUrlResolver = siteUrlResolver; - AppProperties.Clicky clicky = - Objects.requireNonNull(appProperties, "appProperties").getClicky(); - this.clickyEnabled = clicky.isEnabled(); - this.clickySiteId = clicky.getParsedSiteId(); + this.clickyAnalyticsInjector = Objects.requireNonNull(clickyAnalyticsInjector, "clickyAnalyticsInjector"); initMetadata(); } @@ -149,31 +141,7 @@ private void updateDocumentMetadata(Document doc, PageMetadata metadata, String updateJsonLd(doc, fullUrl, metadata.description); // Analytics - updateClickyAnalytics(doc); - } - - private void updateClickyAnalytics(Document doc) { - Element existingClickyLoader = doc.head().selectFirst("script[src=\"" + CLICKY_SCRIPT_URL + "\"]"); - if (!clickyEnabled) { - if (existingClickyLoader != null) { - existingClickyLoader.remove(); - } - doc.head().select("script").forEach(scriptTag -> { - String scriptBody = scriptTag.html(); - if (scriptBody != null && scriptBody.contains("clicky_site_ids")) { - scriptTag.remove(); - } - }); - return; - } - - if (existingClickyLoader != null) { - return; - } - - String initializer = String.format(CLICKY_INITIALIZER_TEMPLATE, clickySiteId); - doc.head().appendElement("script").text(initializer); - doc.head().appendElement("script").attr("async", "").attr("src", CLICKY_SCRIPT_URL); + clickyAnalyticsInjector.applyTo(doc); } private void updateCanonicalLink(Document doc, String fullUrl) { From c1fe8ab547720f4b93b5f95f04b14f932faabffa Mon Sep 17 00:00:00 2001 From: William Callahan Date: Sat, 14 Feb 2026 01:42:59 -0800 Subject: [PATCH 10/10] test(seo): add ClickyAnalyticsInjector to SeoControllerTest context SeoControllerTest uses @WebMvcTest which only loads the controller slice. After extracting ClickyAnalyticsInjector from SeoController, the test context needs the new component imported alongside AppProperties. - Add ClickyAnalyticsInjector.class to @Import annotation --- .../com/williamcallahan/javachat/web/SeoControllerTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 {