diff --git a/sdk/go/documents_test.go b/sdk/go/documents_test.go index b4ce364..6c18e7e 100644 --- a/sdk/go/documents_test.go +++ b/sdk/go/documents_test.go @@ -8,13 +8,21 @@ import ( "testing" ) +const ( + errUnexpectedPath = "unexpected path: %s" + headerContentType = "Content-Type" + mimeJSON = "application/json" + errUnexpected = "unexpected error: %v" + testGoDevDocURL = "https://go.dev/doc/" +) + func TestAddText(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { t.Errorf("expected POST, got %s", r.Method) } if r.URL.Path != "/api/v1/documents" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(errUnexpectedPath, r.URL.Path) } var body map[string]interface{} @@ -26,7 +34,7 @@ func TestAddText(t *testing.T) { t.Errorf("expected content 'hello world', got %v", body["content"]) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.WriteHeader(201) w.Write([]byte(`{"data":{"id":"doc-1","title":"Test Doc","source":"manual"}}`)) })) @@ -35,7 +43,7 @@ func TestAddText(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) doc, err := c.AddText(context.Background(), "Test Doc", "hello world") if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } if doc.ID != "doc-1" { t.Errorf("expected id doc-1, got %q", doc.ID) @@ -54,7 +62,7 @@ func TestAddTextWithOptions(t *testing.T) { t.Errorf("expected 2 tags, got %v", body["tags"]) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.WriteHeader(201) w.Write([]byte(`{"data":{"id":"doc-2","title":"Go Guide","topic":"golang","tags":["go","tutorial"]}}`)) })) @@ -66,7 +74,7 @@ func TestAddTextWithOptions(t *testing.T) { WithTextTags("go", "tutorial"), ) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } if doc.Topic != "golang" { t.Errorf("expected topic golang, got %q", doc.Topic) @@ -76,26 +84,26 @@ func TestAddTextWithOptions(t *testing.T) { func TestAddDocument(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/documents/url" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(errUnexpectedPath, r.URL.Path) } var body map[string]interface{} json.NewDecoder(r.Body).Decode(&body) - if body["url"] != "https://go.dev/doc/" { + if body["url"] != testGoDevDocURL { t.Errorf("expected url, got %v", body["url"]) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.WriteHeader(201) - w.Write([]byte(`{"data":{"id":"doc-3","title":"Go Documentation","url":"https://go.dev/doc/"}}`)) + w.Write([]byte(`{"data":{"id":"doc-3","title":"Go Documentation","url":"` + testGoDevDocURL + `"}}`)) })) defer srv.Close() c := NewClient(WithBaseURL(srv.URL)) - doc, err := c.AddDocument(context.Background(), "https://go.dev/doc/") + doc, err := c.AddDocument(context.Background(), testGoDevDocURL) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } - if doc.URL != "https://go.dev/doc/" { + if doc.URL != testGoDevDocURL { t.Errorf("expected url, got %q", doc.URL) } } @@ -103,9 +111,9 @@ func TestAddDocument(t *testing.T) { func TestGetDocument(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/documents/doc-1" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(errUnexpectedPath, r.URL.Path) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.Write([]byte(`{"data":{"id":"doc-1","title":"Test"}}`)) })) defer srv.Close() @@ -113,7 +121,7 @@ func TestGetDocument(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) doc, err := c.GetDocument(context.Background(), "doc-1") if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } if doc.ID != "doc-1" { t.Errorf("expected doc-1, got %q", doc.ID) @@ -122,7 +130,7 @@ func TestGetDocument(t *testing.T) { func TestGetDocumentNotFound(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.WriteHeader(404) w.Write([]byte(`{"error":{"code":"NOT_FOUND","message":"Document not found"}}`)) })) @@ -150,7 +158,7 @@ func TestListDocuments(t *testing.T) { if r.URL.Query().Get("limit") != "10" { t.Errorf("expected limit=10, got %q", r.URL.Query().Get("limit")) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.Write([]byte(`{"data":[{"id":"doc-1","title":"Doc 1"},{"id":"doc-2","title":"Doc 2"}]}`)) })) defer srv.Close() @@ -158,7 +166,7 @@ func TestListDocuments(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) docs, err := c.ListDocuments(context.Background(), WithListTopic("go"), WithListLimit(10)) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } if len(docs) != 2 { t.Errorf("expected 2 docs, got %d", len(docs)) @@ -171,9 +179,9 @@ func TestDeleteDocument(t *testing.T) { t.Errorf("expected DELETE, got %s", r.Method) } if r.URL.Path != "/api/v1/documents/doc-1" { - t.Errorf("unexpected path: %s", r.URL.Path) + t.Errorf(errUnexpectedPath, r.URL.Path) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.Write([]byte(`{"data":{"deleted":true}}`)) })) defer srv.Close() @@ -181,6 +189,6 @@ func TestDeleteDocument(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) err := c.DeleteDocument(context.Background(), "doc-1") if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } } diff --git a/sdk/go/search_test.go b/sdk/go/search_test.go index 16b19f6..d84e00a 100644 --- a/sdk/go/search_test.go +++ b/sdk/go/search_test.go @@ -15,7 +15,7 @@ func TestSearch(t *testing.T) { if r.URL.Query().Get("limit") != "5" { t.Errorf("expected limit=5, got %q", r.URL.Query().Get("limit")) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.Write([]byte(`{"data":{"results":[{"document":{"id":"doc-1","title":"Go Concurrency"},"score":0.95,"chunk_text":"goroutines are..."}],"total":1,"query":"goroutines"}}`)) })) defer srv.Close() @@ -23,7 +23,7 @@ func TestSearch(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) result, err := c.Search(context.Background(), "goroutines", WithLimit(5)) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } if result.Total != 1 { t.Errorf("expected 1 result, got %d", result.Total) @@ -38,7 +38,7 @@ func TestSearchWithTopic(t *testing.T) { if r.URL.Query().Get("topic") != "golang" { t.Errorf("expected topic=golang, got %q", r.URL.Query().Get("topic")) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.Write([]byte(`{"data":{"results":[],"total":0,"query":"test"}}`)) })) defer srv.Close() @@ -46,7 +46,7 @@ func TestSearchWithTopic(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) result, err := c.Search(context.Background(), "test", WithTopic("golang")) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } if result.Total != 0 { t.Errorf("expected 0 results, got %d", result.Total) @@ -58,7 +58,7 @@ func TestSearchWithTags(t *testing.T) { if r.URL.Query().Get("tag") != "tutorial" { t.Errorf("expected tag=tutorial, got %q", r.URL.Query().Get("tag")) } - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.Write([]byte(`{"data":{"results":[],"total":0,"query":"test"}}`)) })) defer srv.Close() @@ -66,13 +66,13 @@ func TestSearchWithTags(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) _, err := c.Search(context.Background(), "test", WithTags("tutorial")) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } } func TestSearchWithMinScore(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.Write([]byte(`{"data":{"results":[{"document":{"id":"d1","title":"High"},"score":0.9,"chunk_text":"high"},{"document":{"id":"d2","title":"Low"},"score":0.3,"chunk_text":"low"}],"total":2,"query":"test"}}`)) })) defer srv.Close() @@ -80,7 +80,7 @@ func TestSearchWithMinScore(t *testing.T) { c := NewClient(WithBaseURL(srv.URL)) result, err := c.Search(context.Background(), "test", WithMinScore(0.5)) if err != nil { - t.Fatalf("unexpected error: %v", err) + t.Fatalf(errUnexpected, err) } if len(result.Results) != 1 { t.Errorf("expected 1 result after min score filter, got %d", len(result.Results)) @@ -92,7 +92,7 @@ func TestSearchWithMinScore(t *testing.T) { func TestSearchError(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerContentType, mimeJSON) w.WriteHeader(400) w.Write([]byte(`{"error":{"code":"VALIDATION_ERROR","message":"Query parameter 'q' is required"}}`)) })) diff --git a/sdk/python/src/pylibscope/client.py b/sdk/python/src/pylibscope/client.py index 676cdab..cf44401 100644 --- a/sdk/python/src/pylibscope/client.py +++ b/sdk/python/src/pylibscope/client.py @@ -28,6 +28,8 @@ ) _API = "/api/v1" +_PATH_DOCUMENTS = "/documents" +_PATH_TOPICS = "/topics" def _raise_for_error(response: httpx.Response) -> None: @@ -215,7 +217,7 @@ def add_text( payload["topic"] = topic if tags: payload["tags"] = tags - resp = self._request("POST", "/documents", json=payload) + resp = self._request("POST", _PATH_DOCUMENTS, json=payload) return _parse_document(_extract_data(resp)) def get_document(self, doc_id: str) -> Document: @@ -234,7 +236,7 @@ def list_documents( params: Dict[str, Any] = {"limit": limit, "offset": offset} if topic is not None: params["topic"] = topic - resp = self._request("GET", "/documents", params=params) + resp = self._request("GET", _PATH_DOCUMENTS, params=params) data = _extract_data(resp) if isinstance(data, list): return [_parse_document(d) for d in data] @@ -248,7 +250,7 @@ def delete_document(self, doc_id: str) -> None: def list_topics(self) -> List[Topic]: """List all topics.""" - resp = self._request("GET", "/topics") + resp = self._request("GET", _PATH_TOPICS) data = _extract_data(resp) if isinstance(data, list): return [_parse_topic(t) for t in data] @@ -259,7 +261,7 @@ def create_topic(self, name: str, *, parent_id: Optional[str] = None) -> Topic: payload: Dict[str, Any] = {"name": name} if parent_id is not None: payload["parentId"] = parent_id - resp = self._request("POST", "/topics", json=payload) + resp = self._request("POST", _PATH_TOPICS, json=payload) return _parse_topic(_extract_data(resp)) # -- tag operations ------------------------------------------------------ @@ -442,7 +444,7 @@ async def add_text( payload["topic"] = topic if tags: payload["tags"] = tags - resp = await self._request("POST", "/documents", json=payload) + resp = await self._request("POST", _PATH_DOCUMENTS, json=payload) return _parse_document(_extract_data(resp)) async def get_document(self, doc_id: str) -> Document: @@ -459,7 +461,7 @@ async def list_documents( params: Dict[str, Any] = {"limit": limit, "offset": offset} if topic is not None: params["topic"] = topic - resp = await self._request("GET", "/documents", params=params) + resp = await self._request("GET", _PATH_DOCUMENTS, params=params) data = _extract_data(resp) if isinstance(data, list): return [_parse_document(d) for d in data] @@ -471,7 +473,7 @@ async def delete_document(self, doc_id: str) -> None: # -- topic operations ---------------------------------------------------- async def list_topics(self) -> List[Topic]: - resp = await self._request("GET", "/topics") + resp = await self._request("GET", _PATH_TOPICS) data = _extract_data(resp) if isinstance(data, list): return [_parse_topic(t) for t in data] @@ -481,7 +483,7 @@ async def create_topic(self, name: str, *, parent_id: Optional[str] = None) -> T payload: Dict[str, Any] = {"name": name} if parent_id is not None: payload["parentId"] = parent_id - resp = await self._request("POST", "/topics", json=payload) + resp = await self._request("POST", _PATH_TOPICS, json=payload) return _parse_topic(_extract_data(resp)) # -- tag operations ------------------------------------------------------ diff --git a/src/api/middleware.ts b/src/api/middleware.ts index c8629c8..7776fed 100644 --- a/src/api/middleware.ts +++ b/src/api/middleware.ts @@ -54,6 +54,24 @@ const rateLimitMap = new Map(); const RATE_LIMIT_WINDOW_MS = 60_000; const RATE_LIMIT_MAX_REQUESTS = 120; +/** Evict expired and oldest entries from the rate limit map when it reaches capacity. */ +function evictRateLimitEntries(now: number): void { + // First pass: delete expired entries + for (const [key, val] of rateLimitMap) { + if (now - val.windowStart >= RATE_LIMIT_WINDOW_MS) { + rateLimitMap.delete(key); + } + } + // If still over limit, evict oldest entries + if (rateLimitMap.size >= MAX_RATE_LIMIT_ENTRIES) { + const sorted = [...rateLimitMap.entries()].sort((a, b) => a[1].windowStart - b[1].windowStart); + const toDelete = sorted.slice(0, 1000); + for (const [key] of toDelete) { + rateLimitMap.delete(key); + } + } +} + /** Check rate limit for a given IP. Returns true if request is allowed. */ export function checkRateLimit(ip: string): boolean { const now = Date.now(); @@ -62,10 +80,7 @@ export function checkRateLimit(ip: string): boolean { if (entry) { if (now - entry.windowStart < RATE_LIMIT_WINDOW_MS) { entry.count++; - if (entry.count > RATE_LIMIT_MAX_REQUESTS) { - return false; - } - return true; + return entry.count <= RATE_LIMIT_MAX_REQUESTS; } // Window expired — reset entry.count = 1; @@ -75,22 +90,7 @@ export function checkRateLimit(ip: string): boolean { // New IP — evict expired/oldest entries if map is full if (rateLimitMap.size >= MAX_RATE_LIMIT_ENTRIES) { - // First pass: delete expired entries - for (const [key, val] of rateLimitMap) { - if (now - val.windowStart >= RATE_LIMIT_WINDOW_MS) { - rateLimitMap.delete(key); - } - } - // If still over limit, evict oldest entries - if (rateLimitMap.size >= MAX_RATE_LIMIT_ENTRIES) { - const sorted = [...rateLimitMap.entries()].sort( - (a, b) => a[1].windowStart - b[1].windowStart, - ); - const toDelete = sorted.slice(0, 1000); - for (const [key] of toDelete) { - rateLimitMap.delete(key); - } - } + evictRateLimitEntries(now); } rateLimitMap.set(ip, { count: 1, windowStart: now }); diff --git a/src/api/server.ts b/src/api/server.ts index 7b011ac..3b9649c 100644 --- a/src/api/server.ts +++ b/src/api/server.ts @@ -1,4 +1,4 @@ -import { createServer } from "node:http"; +import { createServer, type Server } from "node:http"; import type Database from "better-sqlite3"; import type { EmbeddingProvider } from "../providers/embedding.js"; import { getLogger } from "../logger.js"; @@ -6,6 +6,13 @@ import { corsMiddleware, checkRateLimit, checkApiKey } from "./middleware.js"; import { handleRequest } from "./routes.js"; import { ConnectorScheduler, loadScheduleEntries } from "../core/scheduler.js"; +/** Close an HTTP server, returning a promise that resolves when done. */ +function closeHttpServer(server: Server): Promise { + return new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); +} + export interface ApiServerOptions { port?: number | undefined; host?: string | undefined; @@ -71,9 +78,7 @@ export async function startApiServer( resolve({ close: async () => { await scheduler?.stop(); - await new Promise((resolveClose, rejectClose) => { - server.close((err) => (err ? rejectClose(err) : resolveClose())); - }); + await closeHttpServer(server); }, port, scheduler, diff --git a/src/cli/commands/registry.ts b/src/cli/commands/registry.ts index e3affac..e51f807 100644 --- a/src/cli/commands/registry.ts +++ b/src/cli/commands/registry.ts @@ -59,6 +59,46 @@ function padColumns(cols: string[]): string { return cols.map((col, i) => col.padEnd(widths[i] ?? 16)).join(" "); } +/** Sync a single named registry and print the result. */ +async function syncSingleRegistry(name: string): Promise { + const status = await syncRegistryByName(name); + if (status.status === "error") { + console.error(`Error: ${status.error}`); + process.exit(1); + return; + } + if (status.status === "offline") { + console.warn(`Warning: ${status.error}`); + console.warn( + `Registry "${name}" is unreachable. Using cached index from ${status.lastSyncedAt ?? "unknown"}.`, + ); + return; + } + const cacheDir = getRegistryCacheDir(name); + const index = readIndex(cacheDir); + console.log(`Registry "${name}" synced: ${index.length} pack(s) available.`); +} + +/** Sync all registries and print per-registry results. */ +async function syncAllRegistriesAction(): Promise { + const results = await syncAllRegistries(); + if (results.length === 0) { + console.log("No registries configured."); + return; + } + for (const status of results) { + if (status.status === "success") { + const cacheDir = getRegistryCacheDir(status.registryName); + const index = readIndex(cacheDir); + console.log(` ${status.registryName}: synced (${index.length} packs)`); + } else if (status.status === "offline") { + console.warn(` ${status.registryName}: offline (using cached data)`); + } else { + console.error(` ${status.registryName}: error — ${status.error}`); + } + } +} + /** Register all `registry` subcommands on the given Commander program. */ export function registerRegistryCommands(program: Command): void { const registryCmd = program @@ -241,39 +281,9 @@ export function registerRegistryCommands(program: Command): void { } if (name) { - const status = await syncRegistryByName(name); - if (status.status === "error") { - console.error(`Error: ${status.error}`); - process.exit(1); - return; - } - if (status.status === "offline") { - console.warn(`Warning: ${status.error}`); - console.warn( - `Registry "${name}" is unreachable. Using cached index from ${status.lastSyncedAt ?? "unknown"}.`, - ); - } else { - const cacheDir = getRegistryCacheDir(name); - const index = readIndex(cacheDir); - console.log(`Registry "${name}" synced: ${index.length} pack(s) available.`); - } + await syncSingleRegistry(name); } else { - const results = await syncAllRegistries(); - if (results.length === 0) { - console.log("No registries configured."); - return; - } - for (const status of results) { - if (status.status === "success") { - const cacheDir = getRegistryCacheDir(status.registryName); - const index = readIndex(cacheDir); - console.log(` ${status.registryName}: synced (${index.length} packs)`); - } else if (status.status === "offline") { - console.warn(` ${status.registryName}: offline (using cached data)`); - } else { - console.error(` ${status.registryName}: error — ${status.error}`); - } - } + await syncAllRegistriesAction(); } }); diff --git a/src/cli/index.ts b/src/cli/index.ts index a74f5e2..db4a16e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1222,7 +1222,7 @@ function findFiles(dir: string, extensions: Set): string[] { } walk(dir); - return results.sort(); + return results.sort((a, b) => a.localeCompare(b)); } // watch @@ -2015,6 +2015,55 @@ packCmd }, ); +/** Perform device-code auth for OneNote and persist credentials. */ +async function onenoteDeviceAuth( + onenoteConf: Record, + connConfig: Record, +): Promise<{ token: string; conf: Record }> { + const clientId = (onenoteConf.clientId as string | undefined) ?? process.env.ONENOTE_CLIENT_ID; + if (!clientId) { + console.error("Error: No client ID. Set ONENOTE_CLIENT_ID env var or provide --token."); + process.exit(1); + } + const tenantId = + (onenoteConf.tenantId as string | undefined) ?? process.env.ONENOTE_TENANT_ID ?? "common"; + + console.log("Starting device code authentication..."); + const auth = await authenticateDeviceCode(clientId, tenantId); + const updated = { + ...onenoteConf, + clientId, + tenantId, + accessToken: auth.accessToken, + refreshToken: auth.refreshToken, + tokenExpiry: auth.expiresAt, + }; + connConfig.onenote = updated; + saveConnectorConfig(connConfig); + console.log("\u2713 Authenticated successfully"); + return { token: auth.accessToken, conf: updated }; +} + +/** Refresh an existing OneNote access token and persist credentials. */ +async function onenoteRefreshAuth( + onenoteConf: Record, + connConfig: Record, +): Promise { + const clientId = onenoteConf.clientId as string | undefined; + const refreshTok = onenoteConf.refreshToken as string | undefined; + if (!clientId || !refreshTok) { + return onenoteConf.accessToken as string | undefined; + } + const tenantId = (onenoteConf.tenantId as string | undefined) ?? "common"; + const auth = await refreshAccessToken(clientId, refreshTok, tenantId); + onenoteConf.accessToken = auth.accessToken; + onenoteConf.refreshToken = auth.refreshToken; + onenoteConf.tokenExpiry = auth.expiresAt; + connConfig.onenote = onenoteConf; + saveConnectorConfig(connConfig); + return auth.accessToken; +} + // connect onenote const connectCmd = program.command("connect").description("Connect external services"); @@ -2047,47 +2096,13 @@ connectCmd let accessToken = opts.token; if (!accessToken && !opts.sync) { - const clientId = - (onenoteConf.clientId as string | undefined) ?? process.env.ONENOTE_CLIENT_ID; - if (!clientId) { - console.error("Error: No client ID. Set ONENOTE_CLIENT_ID env var or provide --token."); - process.exit(1); - } - const tenantId = - (onenoteConf.tenantId as string | undefined) ?? process.env.ONENOTE_TENANT_ID ?? "common"; - - console.log("Starting device code authentication..."); - const auth = await authenticateDeviceCode(clientId, tenantId); - accessToken = auth.accessToken; - onenoteConf = { - ...onenoteConf, - clientId, - tenantId, - accessToken: auth.accessToken, - refreshToken: auth.refreshToken, - tokenExpiry: auth.expiresAt, - }; - connConfig.onenote = onenoteConf; - saveConnectorConfig(connConfig); - console.log("✓ Authenticated successfully"); + const result = await onenoteDeviceAuth(onenoteConf, connConfig); + accessToken = result.token; + onenoteConf = result.conf; } if (opts.sync && !accessToken) { - // Try to refresh token - const clientId = onenoteConf.clientId as string | undefined; - const refreshTok = onenoteConf.refreshToken as string | undefined; - if (clientId && refreshTok) { - const tenantId = (onenoteConf.tenantId as string | undefined) ?? "common"; - const auth = await refreshAccessToken(clientId, refreshTok, tenantId); - accessToken = auth.accessToken; - onenoteConf.accessToken = auth.accessToken; - onenoteConf.refreshToken = auth.refreshToken; - onenoteConf.tokenExpiry = auth.expiresAt; - connConfig.onenote = onenoteConf; - saveConnectorConfig(connConfig); - } else { - accessToken = onenoteConf.accessToken as string | undefined; - } + accessToken = await onenoteRefreshAuth(onenoteConf, connConfig); } if (!accessToken) { diff --git a/src/cli/reporter.ts b/src/cli/reporter.ts index 22533cb..08d5e55 100644 --- a/src/cli/reporter.ts +++ b/src/cli/reporter.ts @@ -68,12 +68,24 @@ class PrettyReporter implements CliReporter { /** No-op reporter: used in verbose/JSON mode where pino logs handle output. */ class SilentReporter implements CliReporter { - log(_msg: string): void {} - success(_msg: string): void {} - warn(_msg: string): void {} - error(_msg: string): void {} - progress(_current: number, _total: number, _label: string): void {} - clearProgress(): void {} + log(_msg: string): void { + // intentionally empty — silent reporter + } + success(_msg: string): void { + // intentionally empty — silent reporter + } + warn(_msg: string): void { + // intentionally empty — silent reporter + } + error(_msg: string): void { + // intentionally empty — silent reporter + } + progress(_current: number, _total: number, _label: string): void { + // intentionally empty — silent reporter + } + clearProgress(): void { + // intentionally empty — silent reporter + } } /** Returns true if verbose mode is active (flag or env var). */ diff --git a/src/config.ts b/src/config.ts index ffab3da..e524987 100644 --- a/src/config.ts +++ b/src/config.ts @@ -205,11 +205,8 @@ export function loadConfig(): LibScopeConfig { return config; } -/** Validate config and log warnings for any issues found. */ -export function validateConfig(config: LibScopeConfig): string[] { - const warnings: string[] = []; - - // Check OpenAI API key for embedding provider +/** Check embedding and LLM provider configuration for missing keys/URLs. */ +function validateProviderConfig(config: LibScopeConfig, warnings: string[]): void { if (config.embedding.provider === "openai") { const hasKey = config.embedding.openaiApiKey ?? process.env["OPENAI_API_KEY"]; if (!hasKey) { @@ -218,15 +215,9 @@ export function validateConfig(config: LibScopeConfig): string[] { ); } } - - // Check Ollama base URL for embedding provider - if (config.embedding.provider === "ollama") { - if (!config.embedding.ollamaUrl) { - warnings.push('embedding.provider is "ollama" but embedding.ollamaUrl is not set.'); - } + if (config.embedding.provider === "ollama" && !config.embedding.ollamaUrl) { + warnings.push('embedding.provider is "ollama" but embedding.ollamaUrl is not set.'); } - - // Check OpenAI API key for LLM provider if (config.llm?.provider === "openai") { const hasKey = config.llm.openaiApiKey ?? config.embedding.openaiApiKey ?? process.env["OPENAI_API_KEY"]; @@ -236,26 +227,35 @@ export function validateConfig(config: LibScopeConfig): string[] { ); } } +} - // Validate database path is writable (or parent directory is writable/creatable) - const dbPath = config.database.path; - const dbDir = dirname(dbPath); +/** Check that the database directory is writable or can be created. */ +function validateDatabasePath(config: LibScopeConfig, warnings: string[]): void { + const dbDir = dirname(config.database.path); try { if (existsSync(dbDir)) { accessSync(dbDir, constants.W_OK); - } else { - // Walk up to find the first existing ancestor and check writability - let ancestor = dirname(dbDir); - while (!existsSync(ancestor) && ancestor !== dirname(ancestor)) { - ancestor = dirname(ancestor); - } - if (existsSync(ancestor)) { - accessSync(ancestor, constants.W_OK); - } + return; + } + // Walk up to find the first existing ancestor and check writability + let ancestor = dirname(dbDir); + while (!existsSync(ancestor) && ancestor !== dirname(ancestor)) { + ancestor = dirname(ancestor); + } + if (existsSync(ancestor)) { + accessSync(ancestor, constants.W_OK); } } catch { warnings.push(`database.path directory "${dbDir}" is not writable or cannot be created.`); } +} + +/** Validate config and log warnings for any issues found. */ +export function validateConfig(config: LibScopeConfig): string[] { + const warnings: string[] = []; + + validateProviderConfig(config, warnings); + validateDatabasePath(config, warnings); const logger = getLogger(); for (const warning of warnings) { diff --git a/src/core/packs.ts b/src/core/packs.ts index 45aeca1..176727a 100644 --- a/src/core/packs.ts +++ b/src/core/packs.ts @@ -710,15 +710,16 @@ function collectFiles( continue; // Skip unreadable entries } - if (stat.isDirectory()) { - if (recursive) { - results.push(...collectFiles(fullPath, rootDir, recursive, extensions, excludePatterns)); - } - } else if (stat.isFile()) { - const ext = extname(fullPath).toLowerCase(); - if (extensions.has(ext)) { - results.push(fullPath); - } + if (stat.isDirectory() && recursive) { + results.push(...collectFiles(fullPath, rootDir, recursive, extensions, excludePatterns)); + continue; + } + + if (!stat.isFile()) continue; + + const ext = extname(fullPath).toLowerCase(); + if (extensions.has(ext)) { + results.push(fullPath); } } diff --git a/src/core/parsers/index.ts b/src/core/parsers/index.ts index 3f47b99..97b5ca5 100644 --- a/src/core/parsers/index.ts +++ b/src/core/parsers/index.ts @@ -46,5 +46,5 @@ export function getParserForFile(filename: string): DocumentParser | null { /** Get all file extensions supported by the parsers. */ export function getSupportedExtensions(): string[] { - return [...extensionMap.keys()].sort(); + return [...extensionMap.keys()].sort((a, b) => a.localeCompare(b)); } diff --git a/src/core/scheduler.ts b/src/core/scheduler.ts index add4e37..5ff4358 100644 --- a/src/core/scheduler.ts +++ b/src/core/scheduler.ts @@ -124,7 +124,7 @@ export class ConnectorScheduler { const log = getLogger(); const inFlight: Promise[] = []; for (const [key, job] of this.jobs) { - void job.task.stop(); + void job.task.stop(); // eslint: no-floating-promises requires void for fire-and-forget if (job.running && job.runPromise) { inFlight.push(job.runPromise); } diff --git a/src/core/search.ts b/src/core/search.ts index 89c31b2..6a6ca26 100644 --- a/src/core/search.ts +++ b/src/core/search.ts @@ -150,44 +150,34 @@ interface RankedItem { ranks: number[]; } -/** - * Merge two ranked result lists via Reciprocal Rank Fusion. - * Returns results sorted by fused score in descending order. - */ -function reciprocalRankFusion(listA: SearchResult[], listB: SearchResult[]): SearchResult[] { - const map = new Map(); - - for (let i = 0; i < listA.length; i++) { - const r = listA[i]; +/** Add ranked results from a single list into the RRF accumulator map. */ +function addRankedList( + list: SearchResult[], + map: Map, + preferVector: boolean, +): void { + for (let i = 0; i < list.length; i++) { + const r = list[i]; if (r === undefined) continue; - const key = r.chunkId; - const existing = map.get(key); - if (existing) { - existing.ranks.push(i + 1); - } else { - map.set(key, { result: r, ranks: [i + 1] }); + const existing = map.get(r.chunkId); + if (!existing) { + map.set(r.chunkId, { result: r, ranks: [i + 1] }); + continue; } - } - - for (let i = 0; i < listB.length; i++) { - const r = listB[i]; - if (r === undefined) continue; - const key = r.chunkId; - const existing = map.get(key); - if (existing) { - existing.ranks.push(i + 1); - // Prefer result with richer explanation (vector > fts5 > keyword) - if ( - r.scoreExplanation.method === "vector" && - existing.result.scoreExplanation.method !== "vector" - ) { - existing.result = r; - } - } else { - map.set(key, { result: r, ranks: [i + 1] }); + existing.ranks.push(i + 1); + // Prefer result with richer explanation (vector > fts5 > keyword) + if ( + preferVector && + r.scoreExplanation.method === "vector" && + existing.result.scoreExplanation.method !== "vector" + ) { + existing.result = r; } } +} +/** Compute final RRF scores from the accumulated rank map. */ +function computeRrfScores(map: Map): SearchResult[] { const fused: Array<{ result: SearchResult; score: number }> = []; for (const item of map.values()) { let rrfScore = 0; @@ -209,11 +199,21 @@ function reciprocalRankFusion(listA: SearchResult[], listB: SearchResult[]): Sea score: rrfScore, }); } - fused.sort((a, b) => b.score - a.score); return fused.map((f) => f.result); } +/** + * Merge two ranked result lists via Reciprocal Rank Fusion. + * Returns results sorted by fused score in descending order. + */ +function reciprocalRankFusion(listA: SearchResult[], listB: SearchResult[]): SearchResult[] { + const map = new Map(); + addRankedList(listA, map, false); + addRankedList(listB, map, true); + return computeRrfScores(map); +} + /** Fetch neighboring chunks for a given chunk within its document. */ function fetchContextChunks( db: Database.Database, diff --git a/src/core/spider.ts b/src/core/spider.ts index 44dd6a1..8b74457 100644 --- a/src/core/spider.ts +++ b/src/core/spider.ts @@ -91,28 +91,39 @@ async function fetchRobotsTxt( * only those groups apply (ignoring wildcard). Otherwise wildcard groups apply. * This matches the robots.txt spec — a specific UA rule overrides the wildcard. */ +type RobotsGroup = { agents: string[]; disallows: string[] }; + +/** Parse a single robots.txt line into groups, updating the current group state. */ +function processRobotsLine( + line: string, + groups: RobotsGroup[], + current: RobotsGroup | null, +): RobotsGroup | null { + const lower = line.toLowerCase(); + if (lower.startsWith("user-agent:")) { + const agent = line.slice("user-agent:".length).trim(); + if (current === null || current.disallows.length > 0) { + current = { agents: [], disallows: [] }; + groups.push(current); + } + current.agents.push(agent.toLowerCase()); + return current; + } + if (lower.startsWith("disallow:") && current !== null) { + const path = line.slice("disallow:".length).trim(); + if (path.length > 0) current.disallows.push(path); + } + return current; +} + function parseRobotsTxt(text: string): Set { - type RobotsGroup = { agents: string[]; disallows: string[] }; const groups: RobotsGroup[] = []; let current: RobotsGroup | null = null; for (const raw of text.split(/\r?\n/)) { const line = raw.trim(); if (line.startsWith("#") || line.length === 0) continue; - - const lower = line.toLowerCase(); - if (lower.startsWith("user-agent:")) { - const agent = line.slice("user-agent:".length).trim(); - // Start a new group only if current has already collected Disallow lines - if (current === null || current.disallows.length > 0) { - current = { agents: [], disallows: [] }; - groups.push(current); - } - current.agents.push(agent.toLowerCase()); - } else if (lower.startsWith("disallow:") && current !== null) { - const path = line.slice("disallow:".length).trim(); - if (path.length > 0) current.disallows.push(path); - } + current = processRobotsLine(line, groups, current); } // Prefer explicit "libscope" group over the wildcard group @@ -211,6 +222,26 @@ function htmlToMarkdown(html: string): string { return NodeHtmlMarkdown.translate(html); } +/** + * Scan past a single HTML tag starting after the opening '<'. + * Returns the index immediately after the closing '>'. + * Respects quoted attribute values so '>' inside them doesn't end the tag early. + */ +function scanPastTag(input: string, start: number): number { + let i = start; + while (i < input.length) { + const ch = input[i]; + if (ch === ">") return i + 1; + if (ch === '"' || ch === "'") { + const close = input.indexOf(ch, i + 1); + i = close === -1 ? input.length : close + 1; + } else { + i++; + } + } + return i; +} + /** * Remove all HTML tags from a string using indexOf-based scanning. * Handles tags that span multiple lines and tags with > inside attribute values. @@ -226,23 +257,7 @@ function stripTags(input: string): string { break; } result += input.slice(pos, open); - // Scan for the closing > of this tag, respecting quoted attribute values - let i = open + 1; - while (i < input.length) { - const ch = input[i]; - if (ch === ">") { - i++; - break; - } - // Skip quoted attribute values so > inside them doesn't end the tag early - if (ch === '"' || ch === "'") { - const close = input.indexOf(ch, i + 1); - i = close === -1 ? input.length : close + 1; - } else { - i++; - } - } - pos = i; + pos = scanPastTag(input, open + 1); } // Collapse whitespace left behind by removed tags return result.replace(/\s+/g, " "); diff --git a/src/registry/checksum.ts b/src/registry/checksum.ts index f62557b..e327ff8 100644 --- a/src/registry/checksum.ts +++ b/src/registry/checksum.ts @@ -30,7 +30,11 @@ export async function computeChecksum(filePath: string): Promise { * Sorts keys to ensure deterministic output regardless of property order. */ export function computePackChecksum(packData: unknown): string { - const json = JSON.stringify(packData, Object.keys(packData as object).sort(), 0); + const json = JSON.stringify( + packData, + Object.keys(packData as object).sort((a, b) => a.localeCompare(b)), + 0, + ); return createHash("sha256").update(json, "utf-8").digest("hex"); } diff --git a/src/registry/git.ts b/src/registry/git.ts index 6bc01f2..c1193e8 100644 --- a/src/registry/git.ts +++ b/src/registry/git.ts @@ -130,6 +130,14 @@ function isValidPackSummary(entry: unknown): entry is PackSummary { ); } +/** Extract a display name from a potentially malformed index entry. */ +function extractEntryName(entry: unknown): string { + if (typeof entry === "object" && entry !== null && "name" in entry) { + return String((entry as Record)["name"]); + } + return JSON.stringify(entry); +} + /** Read and parse the index.json from a local registry cache. */ export function readIndex(cachedPath: string): PackSummary[] { const cached = indexCache.get(cachedPath); @@ -152,16 +160,13 @@ export function readIndex(cachedPath: string): PackSummary[] { for (const entry of data) { if (isValidPackSummary(entry)) { valid.push(entry); - } else { - const name = - typeof entry === "object" && entry !== null && "name" in entry - ? String((entry as Record)["name"]) - : JSON.stringify(entry); - log.warn( - { entry: name }, - "Skipping invalid index.json entry: missing or wrong-typed required fields", - ); + continue; } + const name = extractEntryName(entry); + log.warn( + { entry: name }, + "Skipping invalid index.json entry: missing or wrong-typed required fields", + ); } indexCache.set(cachedPath, valid); diff --git a/src/registry/publish.ts b/src/registry/publish.ts index 5a33362..5064442 100644 --- a/src/registry/publish.ts +++ b/src/registry/publish.ts @@ -27,6 +27,35 @@ import { commitAndPush, fetchRegistry, git, clearIndexCache } from "./git.js"; import { computeChecksum, writeChecksumFile } from "./checksum.js"; import type { KnowledgePack } from "../core/packs.js"; +/** Remove an entire pack directory and its entry from the index. */ +function removeEntirePack(packDir: string, cacheDir: string, packName: string): void { + rmSync(packDir, { recursive: true, force: true }); + const indexPath = join(cacheDir, INDEX_FILE); + if (!existsSync(indexPath)) return; + const index = JSON.parse(readFileSync(indexPath, "utf-8")) as PackSummary[]; + const filtered = index.filter((p) => p.name !== packName); + writeFileSync(indexPath, JSON.stringify(filtered, null, 2), "utf-8"); +} + +/** Write updated manifest and update the index entry to reflect the new latest version. */ +function updateManifestAndIndex( + manifestPath: string, + manifest: PackManifest, + cacheDir: string, + packName: string, +): void { + writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); + const indexPath = join(cacheDir, INDEX_FILE); + if (!existsSync(indexPath)) return; + const index = JSON.parse(readFileSync(indexPath, "utf-8")) as PackSummary[]; + const indexEntry = index.find((p) => p.name === packName); + if (indexEntry && manifest.versions[0]) { + indexEntry.latestVersion = manifest.versions[0].version; + indexEntry.updatedAt = new Date().toISOString(); + } + writeFileSync(indexPath, JSON.stringify(index, null, 2), "utf-8"); +} + /** Maximum pack data size (50 MB). */ const MAX_PACK_SIZE_BYTES = 50 * 1024 * 1024; @@ -397,31 +426,9 @@ export async function unpublishPack(options: UnpublishOptions): Promise { manifest.versions.splice(versionIdx, 1); if (manifest.versions.length === 0) { - // Remove entire pack - rmSync(packDir, { recursive: true, force: true }); - - // Remove from index - const indexPath = join(cacheDir, INDEX_FILE); - if (existsSync(indexPath)) { - const index = JSON.parse(readFileSync(indexPath, "utf-8")) as PackSummary[]; - const filtered = index.filter((p) => p.name !== packName); - writeFileSync(indexPath, JSON.stringify(filtered, null, 2), "utf-8"); - } + removeEntirePack(packDir, cacheDir, packName); } else { - // Update manifest with remaining versions - writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), "utf-8"); - - // Update index with new latest version - const indexPath = join(cacheDir, INDEX_FILE); - if (existsSync(indexPath)) { - const index = JSON.parse(readFileSync(indexPath, "utf-8")) as PackSummary[]; - const indexEntry = index.find((p) => p.name === packName); - if (indexEntry && manifest.versions[0]) { - indexEntry.latestVersion = manifest.versions[0].version; - indexEntry.updatedAt = new Date().toISOString(); - } - writeFileSync(indexPath, JSON.stringify(index, null, 2), "utf-8"); - } + updateManifestAndIndex(manifestPath, manifest, cacheDir, packName); } // Commit and push diff --git a/src/registry/search.ts b/src/registry/search.ts index ebfb3c3..80531b8 100644 --- a/src/registry/search.ts +++ b/src/registry/search.ts @@ -50,6 +50,43 @@ function scoreMatch(pack: PackSummary, query: string): number { return score; } +/** Resolve which registries to search, returning them or adding a warning if not found. */ +function resolveRegistries( + registryName: string | undefined, + warnings: string[], +): RegistryEntry[] | null { + if (!registryName) return loadRegistries(); + const all = loadRegistries(); + const entry = all.find((r) => r.name === registryName); + if (!entry) { + warnings.push(`Registry "${registryName}" not found.`); + return null; + } + return [entry]; +} + +/** Read packs from a single registry, appending warnings on failure. */ +function readRegistryPacks(entry: RegistryEntry, warnings: string[]): PackSummary[] | null { + const cacheDir = getRegistryCacheDir(entry.name); + if (!existsSync(cacheDir)) { + warnings.push( + `Registry "${entry.name}" has never been synced. Run: libscope registry sync ${entry.name}`, + ); + return null; + } + try { + return readIndex(cacheDir); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + warnings.push(`Failed to read index for "${entry.name}": ${msg}`); + getLogger().warn( + { registry: entry.name, err: msg }, + "Failed to read registry index during search", + ); + return null; + } +} + /** * Search for packs across all (or a specific) registry. * Returns results sorted by relevance score (highest first). @@ -58,50 +95,20 @@ export function searchRegistries( query: string, options?: { registryName?: string | undefined }, ): { results: RegistrySearchResult[]; warnings: string[] } { - const log = getLogger(); const warnings: string[] = []; const results: RegistrySearchResult[] = []; - let registries: RegistryEntry[]; - if (options?.registryName) { - const all = loadRegistries(); - const entry = all.find((r) => r.name === options.registryName); - if (!entry) { - warnings.push(`Registry "${options.registryName}" not found.`); - return { results, warnings }; - } - registries = [entry]; - } else { - registries = loadRegistries(); - } + const registries = resolveRegistries(options?.registryName, warnings); + if (!registries) return { results, warnings }; for (const entry of registries) { - const cacheDir = getRegistryCacheDir(entry.name); - if (!existsSync(cacheDir)) { - warnings.push( - `Registry "${entry.name}" has never been synced. Run: libscope registry sync ${entry.name}`, - ); - continue; - } - - let packs: PackSummary[]; - try { - packs = readIndex(cacheDir); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - warnings.push(`Failed to read index for "${entry.name}": ${msg}`); - log.warn({ registry: entry.name, err: msg }, "Failed to read registry index during search"); - continue; - } + const packs = readRegistryPacks(entry, warnings); + if (!packs) continue; for (const pack of packs) { const score = scoreMatch(pack, query); if (score > 0) { - results.push({ - registryName: entry.name, - pack, - score, - }); + results.push({ registryName: entry.name, pack, score }); } } } diff --git a/tests/integration/registry/registry-conflict.test.ts b/tests/integration/registry/registry-conflict.test.ts index c50fec4..f682cb1 100644 --- a/tests/integration/registry/registry-conflict.test.ts +++ b/tests/integration/registry/registry-conflict.test.ts @@ -139,7 +139,9 @@ describe("integration: registry conflict resolution", () => { expect(resolved).toBeNull(); expect(conflict).toBeDefined(); expect(conflict!.sources).toHaveLength(2); - expect(conflict!.sources.map((s) => s.registryName).sort()).toEqual(["reg1", "reg2"]); + expect(conflict!.sources.map((s) => s.registryName).sort((a, b) => a.localeCompare(b))).toEqual( + ["reg1", "reg2"], + ); }); it("should resolve conflict with explicit --registry flag", async () => { diff --git a/tests/integration/registry/registry-lifecycle.test.ts b/tests/integration/registry/registry-lifecycle.test.ts index 6af278e..f510723 100644 --- a/tests/integration/registry/registry-lifecycle.test.ts +++ b/tests/integration/registry/registry-lifecycle.test.ts @@ -285,6 +285,9 @@ describe("integration: registry lifecycle", () => { // Search across both const { results } = searchRegistries("pack"); expect(results.length).toBe(2); - expect(results.map((r) => r.pack.name).sort()).toEqual(["pack-from-reg1", "pack-from-reg2"]); + expect(results.map((r) => r.pack.name).sort((a, b) => a.localeCompare(b))).toEqual([ + "pack-from-reg1", + "pack-from-reg2", + ]); }); }); diff --git a/tests/integration/retrieval-quality.test.ts b/tests/integration/retrieval-quality.test.ts index 6466676..e894288 100644 --- a/tests/integration/retrieval-quality.test.ts +++ b/tests/integration/retrieval-quality.test.ts @@ -50,7 +50,7 @@ class TfIdfEmbeddingProvider implements EmbeddingProvider { for (const text of corpusTexts) { for (const w of this.tokenize(text)) wordSet.add(w); } - const sorted = [...wordSet].sort(); + const sorted = [...wordSet].sort((a, b) => a.localeCompare(b)); this.vocab = new Map(sorted.map((w, i) => [w, i])); this.dimensions = sorted.length; } diff --git a/tests/integration/workflow.test.ts b/tests/integration/workflow.test.ts index 2753025..648872a 100644 --- a/tests/integration/workflow.test.ts +++ b/tests/integration/workflow.test.ts @@ -124,7 +124,10 @@ describe("integration: full workflow", () => { // Children of Infrastructure const children = listTopics(db, infra.id); expect(children.length).toBe(2); - expect(children.map((c) => c.name).sort()).toEqual(["Docker", "Kubernetes"]); + expect(children.map((c) => c.name).sort((a, b) => a.localeCompare(b))).toEqual([ + "Docker", + "Kubernetes", + ]); }); it("should allow model to rate and suggest corrections", async () => { diff --git a/tests/unit/packs.test.ts b/tests/unit/packs.test.ts index 45bf0f5..9ca8dd4 100644 --- a/tests/unit/packs.test.ts +++ b/tests/unit/packs.test.ts @@ -464,7 +464,10 @@ describe("knowledge packs", () => { expect(pack.name).toBe("test-from-folder"); expect(pack.documents).toHaveLength(2); - expect(pack.documents.map((d) => d.title).sort()).toEqual(["api", "guide"]); + expect(pack.documents.map((d) => d.title).sort((a, b) => a.localeCompare(b))).toEqual([ + "api", + "guide", + ]); expect(pack.documents[0]!.content).toBeTruthy(); expect(pack.documents[0]!.source).toMatch(/^file:\/\//); expect(pack.version).toBe("1.0.0"); @@ -542,7 +545,10 @@ describe("knowledge packs", () => { }); expect(pack.documents).toHaveLength(2); - expect(pack.documents.map((d) => d.title).sort()).toEqual(["nested", "root"]); + expect(pack.documents.map((d) => d.title).sort((a, b) => a.localeCompare(b))).toEqual([ + "nested", + "root", + ]); }); it("should not recurse when recursive is false", async () => { diff --git a/tests/unit/parsers.test.ts b/tests/unit/parsers.test.ts index 5fc5ab3..a49f29a 100644 --- a/tests/unit/parsers.test.ts +++ b/tests/unit/parsers.test.ts @@ -78,7 +78,7 @@ describe("getSupportedExtensions", () => { expect(exts).toContain(".html"); expect(exts).toContain(".htm"); // Should be sorted - const sorted = [...exts].sort(); + const sorted = [...exts].sort((a, b) => a.localeCompare(b)); expect(exts).toEqual(sorted); }); }); diff --git a/tests/unit/ratings.test.ts b/tests/unit/ratings.test.ts index f4caa4f..063cc01 100644 --- a/tests/unit/ratings.test.ts +++ b/tests/unit/ratings.test.ts @@ -117,7 +117,7 @@ describe("ratings", () => { const ratings = listRatings(db, testDocId); expect(ratings.length).toBe(2); - const feedbacks = ratings.map((r) => r.feedback).sort(); + const feedbacks = ratings.map((r) => r.feedback).sort((a, b) => a.localeCompare(b)); expect(feedbacks).toEqual(["first", "second"]); }); diff --git a/tests/unit/registry/search.test.ts b/tests/unit/registry/search.test.ts index 0f9522e..d89da2a 100644 --- a/tests/unit/registry/search.test.ts +++ b/tests/unit/registry/search.test.ts @@ -144,7 +144,10 @@ describe("registry search", () => { const { results } = searchRegistries("docs"); expect(results).toHaveLength(2); - expect(results.map((r) => r.pack.name).sort()).toEqual(["react-docs", "vue-docs"]); + expect(results.map((r) => r.pack.name).sort((a, b) => a.localeCompare(b))).toEqual([ + "react-docs", + "vue-docs", + ]); }); it("should sort results by score descending", () => {