From 564bb808a323cf319142c883b183afd72fa26262 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 11:19:28 -0700 Subject: [PATCH 01/13] organize src into domain folders and mirror test tree --- .github/workflows/checks.yml | 2 +- package.json | 8 +- src/agent/docs-only-backend.ts | 2 +- src/agent/index.ts | 14 +- src/agent/openai-chatgpt-oauth.ts | 2 +- src/agent/skills.ts | 5 +- src/agent/translation-middleware.ts | 2 +- src/agent/utils.ts | 8 +- src/agent/vertex-surface.ts | 2 +- src/auth/configure.ts | 4 +- src/{ => auth}/external-cli-auth.ts | 2 +- src/auth/ngrok.ts | 2 +- src/auth/oauth.ts | 2 +- src/auth/providers.ts | 2 +- src/auth/tokens.ts | 2 +- src/{ => cli}/cli.tsx | 43 ++-- src/{ => cli}/commands.ts | 15 +- src/{ => cli}/startup.ts | 8 +- src/{ => config}/constants.ts | 0 src/{ => config}/env.ts | 4 +- src/{ => config}/openwiki-home.ts | 2 +- src/connectors/io.ts | 2 +- src/connectors/mcp-client.ts | 2 +- src/connectors/mcp-runtime.ts | 2 +- src/connectors/sources/gmail.ts | 2 +- src/connectors/sources/langsmith/index.ts | 2 +- .../sources/langsmith/repo-config.ts | 4 +- src/connectors/sources/slack.ts | 2 +- src/connectors/sources/web-search.ts | 2 +- src/connectors/sources/x.ts | 2 +- src/connectors/tools.ts | 2 +- src/{ => ingestion}/code-mode.ts | 10 +- src/{ => ingestion}/ingestion.ts | 16 +- src/mermaid/validate.ts | 2 +- src/{ => platform}/diagnostics.ts | 2 +- src/{ => platform}/fs-errors.ts | 0 src/{ => platform}/language.ts | 0 src/{ => platform}/utils.ts | 0 src/{ => platform}/windows-acl.ts | 0 src/{ => scheduling}/schedules.ts | 9 +- src/{ => setup}/credentials.tsx | 26 +-- src/{ => setup}/onboarding.ts | 9 +- src/telemetry/config.ts | 4 +- src/telemetry/install-id.ts | 2 +- src/telemetry/record-run-safe.ts | 2 +- test/{ => agent}/agent-runtime-root.test.ts | 2 +- .../bedrock-credentials-integration.test.ts | 2 +- test/{ => agent}/bedrock-model.test.ts | 2 +- test/{ => agent}/checkpoint-policy.test.ts | 2 +- test/{ => agent}/checkpoint-pruning.test.ts | 2 +- test/{ => agent}/create-model.test.ts | 2 +- test/{ => agent}/docs-only-backend.test.ts | 2 +- .../{ => agent}/frontmatter-validator.test.ts | 6 +- .../gemini-enterprise-claude.e2e.test.ts | 4 +- .../gemini-enterprise-claude.test.ts | 2 +- test/{ => agent}/gemini-retry.test.ts | 2 +- test/{ => agent}/index-middleware.test.ts | 8 +- test/{ => agent}/model-resolution.test.ts | 4 +- test/{ => agent}/openai-chatgpt-oauth.test.ts | 2 +- test/{ => agent}/prompt-okf.test.ts | 2 +- test/agent/prompt.test.ts | 212 ++++++++++++++++++ test/{ => agent}/redaction.test.ts | 4 +- test/{ => agent}/run-context.test.ts | 2 +- test/{ => agent}/run-metadata.test.ts | 4 +- test/{ => agent}/skills.test.ts | 2 +- test/{ => agent}/stream-redaction.test.ts | 2 +- .../translation-middleware.test.ts | 4 +- test/{ => agent}/update-noop.test.ts | 2 +- test/{ => agent}/vertex-surface.test.ts | 2 +- test/{ => auth}/external-cli-auth.test.ts | 2 +- test/{ => auth}/oauth-callback-server.test.ts | 4 +- test/{ => auth}/oauth-url-validation.test.ts | 2 +- test/{ => cli}/commands.test.ts | 2 +- test/{ => cli}/startup.test.ts | 6 +- test/{ => cli}/telemetry-cli.test.ts | 2 +- test/{ => config}/constants.test.ts | 2 +- test/{ => config}/copilot-provider.test.ts | 2 +- test/{ => config}/env-behavior.test.ts | 8 +- test/{ => config}/env.test.ts | 2 +- .../openai-chatgpt-provider.test.ts | 2 +- .../connector-config-overrides.test.ts | 7 +- .../{ => connectors}/connector-config.test.ts | 2 +- .../fetch-with-resilience.test.ts | 2 +- test/{ => connectors}/hackernews.test.ts | 2 +- test/{ => connectors}/langsmith-api.test.ts | 2 +- test/{ => connectors}/langsmith-index.test.ts | 20 +- .../langsmith-repo-config.test.ts | 2 +- test/{ => connectors}/langsmith-runs.test.ts | 4 +- test/{ => connectors}/langsmith-setup.test.ts | 6 +- test/{ => connectors}/mcp-client.test.ts | 2 +- .../raw-connector-tools.test.ts | 4 +- test/{ => ingestion}/code-mode.test.ts | 2 +- test/{ => ingestion}/langsmith-modes.test.ts | 4 +- test/{ => mermaid}/mermaid-fences.test.ts | 2 +- test/{ => mermaid}/mermaid-validate.test.ts | 2 +- test/{ => mermaid}/mermaid-wiki.test.ts | 4 +- test/{ => okf}/index-labels.test.ts | 2 +- test/{ => platform}/diagnostics.test.ts | 2 +- test/{ => platform}/fs-errors.test.ts | 2 +- test/{ => platform}/language.test.ts | 2 +- test/{ => platform}/utils.test.ts | 2 +- test/{ => platform}/windows-acl.test.ts | 2 +- .../launchd-calendar-interval.test.ts | 2 +- test/{ => setup}/credentials.test.ts | 4 +- test/{ => setup}/onboarding.test.ts | 2 +- .../openai-chatgpt-credentials.test.ts | 2 +- .../telemetry-install-id.test.ts | 2 +- test/{ => telemetry}/telemetry.test.ts | 20 +- 108 files changed, 443 insertions(+), 215 deletions(-) rename src/{ => auth}/external-cli-auth.ts (99%) rename src/{ => cli}/cli.tsx (99%) rename src/{ => cli}/commands.ts (98%) rename src/{ => cli}/startup.ts (92%) rename src/{ => config}/constants.ts (100%) rename src/{ => config}/env.ts (99%) rename src/{ => config}/openwiki-home.ts (97%) rename src/{ => ingestion}/code-mode.ts (97%) rename src/{ => ingestion}/ingestion.ts (98%) rename src/{ => platform}/diagnostics.ts (99%) rename src/{ => platform}/fs-errors.ts (100%) rename src/{ => platform}/language.ts (100%) rename src/{ => platform}/utils.ts (100%) rename src/{ => platform}/windows-acl.ts (100%) rename src/{ => scheduling}/schedules.ts (99%) rename src/{ => setup}/credentials.tsx (99%) rename src/{ => setup}/onboarding.ts (98%) rename test/{ => agent}/agent-runtime-root.test.ts (91%) rename test/{ => agent}/bedrock-credentials-integration.test.ts (97%) rename test/{ => agent}/bedrock-model.test.ts (97%) rename test/{ => agent}/checkpoint-policy.test.ts (90%) rename test/{ => agent}/checkpoint-pruning.test.ts (97%) rename test/{ => agent}/create-model.test.ts (98%) rename test/{ => agent}/docs-only-backend.test.ts (98%) rename test/{ => agent}/frontmatter-validator.test.ts (96%) rename test/{ => agent}/gemini-enterprise-claude.e2e.test.ts (97%) rename test/{ => agent}/gemini-enterprise-claude.test.ts (98%) rename test/{ => agent}/gemini-retry.test.ts (98%) rename test/{ => agent}/index-middleware.test.ts (98%) rename test/{ => agent}/model-resolution.test.ts (92%) rename test/{ => agent}/openai-chatgpt-oauth.test.ts (99%) rename test/{ => agent}/prompt-okf.test.ts (93%) create mode 100644 test/agent/prompt.test.ts rename test/{ => agent}/redaction.test.ts (99%) rename test/{ => agent}/run-context.test.ts (98%) rename test/{ => agent}/run-metadata.test.ts (98%) rename test/{ => agent}/skills.test.ts (98%) rename test/{ => agent}/stream-redaction.test.ts (98%) rename test/{ => agent}/translation-middleware.test.ts (99%) rename test/{ => agent}/update-noop.test.ts (99%) rename test/{ => agent}/vertex-surface.test.ts (99%) rename test/{ => auth}/external-cli-auth.test.ts (98%) rename test/{ => auth}/oauth-callback-server.test.ts (97%) rename test/{ => auth}/oauth-url-validation.test.ts (97%) rename test/{ => cli}/commands.test.ts (99%) rename test/{ => cli}/startup.test.ts (98%) rename test/{ => cli}/telemetry-cli.test.ts (93%) rename test/{ => config}/constants.test.ts (99%) rename test/{ => config}/copilot-provider.test.ts (96%) rename test/{ => config}/env-behavior.test.ts (98%) rename test/{ => config}/env.test.ts (98%) rename test/{ => config}/openai-chatgpt-provider.test.ts (98%) rename test/{ => connectors}/connector-config-overrides.test.ts (98%) rename test/{ => connectors}/connector-config.test.ts (92%) rename test/{ => connectors}/fetch-with-resilience.test.ts (99%) rename test/{ => connectors}/hackernews.test.ts (99%) rename test/{ => connectors}/langsmith-api.test.ts (98%) rename test/{ => connectors}/langsmith-index.test.ts (94%) rename test/{ => connectors}/langsmith-repo-config.test.ts (99%) rename test/{ => connectors}/langsmith-runs.test.ts (98%) rename test/{ => connectors}/langsmith-setup.test.ts (96%) rename test/{ => connectors}/mcp-client.test.ts (96%) rename test/{ => connectors}/raw-connector-tools.test.ts (98%) rename test/{ => ingestion}/code-mode.test.ts (99%) rename test/{ => ingestion}/langsmith-modes.test.ts (95%) rename test/{ => mermaid}/mermaid-fences.test.ts (96%) rename test/{ => mermaid}/mermaid-validate.test.ts (99%) rename test/{ => mermaid}/mermaid-wiki.test.ts (96%) rename test/{ => okf}/index-labels.test.ts (98%) rename test/{ => platform}/diagnostics.test.ts (96%) rename test/{ => platform}/fs-errors.test.ts (97%) rename test/{ => platform}/language.test.ts (94%) rename test/{ => platform}/utils.test.ts (95%) rename test/{ => platform}/windows-acl.test.ts (96%) rename test/{ => scheduling}/launchd-calendar-interval.test.ts (95%) rename test/{ => setup}/credentials.test.ts (98%) rename test/{ => setup}/onboarding.test.ts (98%) rename test/{ => setup}/openai-chatgpt-credentials.test.ts (99%) rename test/{ => telemetry}/telemetry-install-id.test.ts (95%) rename test/{ => telemetry}/telemetry.test.ts (98%) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 29ab45ad..609026f5 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -93,7 +93,7 @@ jobs: run: pnpm run build - name: Smoke test CLI - run: node dist/cli.js code --dry-run --init + run: node dist/cli/cli.js code --dry-run --init env: OPENWIKI_DEV: "1" diff --git a/package.json b/package.json index d1b3713c..1897f75d 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "node": ">=22" }, "bin": { - "openwiki": "./dist/cli.js" + "openwiki": "./dist/cli/cli.js" }, "repository": { "type": "git", @@ -34,12 +34,12 @@ "cli" ], "scripts": { - "openwiki": "node dist/cli.js", + "openwiki": "node dist/cli/cli.js", "build": "tsc -p tsconfig.json && tsc -p tsconfig.client.json", "changeset:version": "changeset version", "clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"", "coverage": "vitest run --coverage", - "dev": "tsx src/cli.tsx", + "dev": "tsx src/cli/cli.tsx", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint . --fix", @@ -47,7 +47,7 @@ "prebuild": "pnpm run clean", "prepack": "pnpm run build", "release": "pnpm run build && changeset publish", - "start": "node dist/cli.js", + "start": "node dist/cli/cli.js", "test": "vitest run", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json" }, diff --git a/src/agent/docs-only-backend.ts b/src/agent/docs-only-backend.ts index 68b1a2c8..7e4fcf89 100644 --- a/src/agent/docs-only-backend.ts +++ b/src/agent/docs-only-backend.ts @@ -13,7 +13,7 @@ import { type ReadResult, type WriteResult, } from "deepagents"; -import { OPEN_WIKI_DIR } from "../constants.js"; +import { OPEN_WIKI_DIR } from "../config/constants.js"; import { OPENWIKI_IGNORE_FILE, OpenWikiIgnore } from "./openwiki-ignore.js"; import type { OpenWikiOutputMode } from "./types.js"; diff --git a/src/agent/index.ts b/src/agent/index.ts index dd028088..dfa101c4 100644 --- a/src/agent/index.ts +++ b/src/agent/index.ts @@ -24,18 +24,18 @@ import { loadOpenWikiEnv, openWikiEnvDir, saveOpenWikiEnv, -} from "../env.js"; -import { isFileNotFoundError } from "../fs-errors.js"; +} from "../config/env.js"; +import { isFileNotFoundError } from "../platform/fs-errors.js"; import { sanitizeDiagnosticText, SECRET_KEY_PATTERN_SOURCE, -} from "../diagnostics.js"; +} from "../platform/diagnostics.js"; import { openWikiConversationHistoryDir, openWikiLocalWikiDir, openWikiSkillsDir, -} from "../openwiki-home.js"; -import { resolveLanguage } from "../language.js"; +} from "../config/openwiki-home.js"; +import { resolveLanguage } from "../platform/language.js"; import { resolveConceptTypeLabel, resolveIndexLabels, @@ -121,11 +121,11 @@ import { resolveProviderRegion, resolveProviderRetryAttempts, type OpenWikiProvider, -} from "../constants.js"; +} from "../config/constants.js"; import { resolveExternalCliCredential, validateExternalCliCredential, -} from "../external-cli-auth.js"; +} from "../auth/external-cli-auth.js"; import { createOpenWikiContentSnapshot, getUpdateNoopStatus, diff --git a/src/agent/openai-chatgpt-oauth.ts b/src/agent/openai-chatgpt-oauth.ts index cdfeabec..e9e9ffcd 100644 --- a/src/agent/openai-chatgpt-oauth.ts +++ b/src/agent/openai-chatgpt-oauth.ts @@ -7,7 +7,7 @@ import { OPENAI_CHATGPT_EXPIRES_AT_ENV_KEY, OPENAI_CHATGPT_PLAN_ENV_KEY, OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, -} from "../constants.js"; +} from "../config/constants.js"; /** * ChatGPT/Codex OAuth client. diff --git a/src/agent/skills.ts b/src/agent/skills.ts index d6430598..0fa09d9b 100644 --- a/src/agent/skills.ts +++ b/src/agent/skills.ts @@ -9,7 +9,10 @@ import { } from "node:fs/promises"; import { fileURLToPath } from "node:url"; import path from "node:path"; -import { ensureOpenWikiHome, openWikiSkillsDir } from "../openwiki-home.js"; +import { + ensureOpenWikiHome, + openWikiSkillsDir, +} from "../config/openwiki-home.js"; const bundledSkillsDir = fileURLToPath( new URL("../../skills", import.meta.url), diff --git a/src/agent/translation-middleware.ts b/src/agent/translation-middleware.ts index 574c2801..d7152b9b 100644 --- a/src/agent/translation-middleware.ts +++ b/src/agent/translation-middleware.ts @@ -4,7 +4,7 @@ import { HumanMessage, SystemMessage } from "@langchain/core/messages"; import type { BackendProtocolV2, FileInfo } from "deepagents"; import { createMiddleware } from "langchain"; import path from "node:path"; -import { getErrorMessage } from "../diagnostics.js"; +import { getErrorMessage } from "../platform/diagnostics.js"; import { OPENWIKI_TRANSLATION_PENDING_FIELD, readFrontmatterField, diff --git a/src/agent/utils.ts b/src/agent/utils.ts index 6962366e..a8acd5a2 100644 --- a/src/agent/utils.ts +++ b/src/agent/utils.ts @@ -3,16 +3,16 @@ import { createHash } from "node:crypto"; import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { promisify } from "node:util"; -import { OPEN_WIKI_DIR, UPDATE_METADATA_PATH } from "../constants.js"; +import { OPEN_WIKI_DIR, UPDATE_METADATA_PATH } from "../config/constants.js"; import { isExpectedSnapshotRaceError, isFileNotFoundError, -} from "../fs-errors.js"; -import { resolveLanguage } from "../language.js"; +} from "../platform/fs-errors.js"; +import { resolveLanguage } from "../platform/language.js"; import { readOpenWikiOnboardingConfig, readRepositoryWikiInstructions, -} from "../onboarding.js"; +} from "../setup/onboarding.js"; import { OpenWikiIgnore } from "./openwiki-ignore.js"; import type { OpenWikiCommand, diff --git a/src/agent/vertex-surface.ts b/src/agent/vertex-surface.ts index f8034920..ca678b0f 100644 --- a/src/agent/vertex-surface.ts +++ b/src/agent/vertex-surface.ts @@ -1,5 +1,5 @@ import { GoogleAuth } from "google-auth-library"; -import { ANTHROPIC_API_KEY_ENV_KEY } from "../constants.js"; +import { ANTHROPIC_API_KEY_ENV_KEY } from "../config/constants.js"; /** * A Vertex AI Model Garden model can be served over one of several distinct API diff --git a/src/auth/configure.ts b/src/auth/configure.ts index a7ad3cb3..b04426da 100644 --- a/src/auth/configure.ts +++ b/src/auth/configure.ts @@ -1,9 +1,9 @@ import { chmod, readFile, writeFile } from "node:fs/promises"; -import { OPENWIKI_NOTION_MCP_ACCESS_TOKEN_ENV_KEY } from "../constants.js"; +import { OPENWIKI_NOTION_MCP_ACCESS_TOKEN_ENV_KEY } from "../config/constants.js"; import { ensureConnectorHome, getConnectorConfigPath, -} from "../openwiki-home.js"; +} from "../config/openwiki-home.js"; import { discoverMcpConnectorTools, isMcpConnectorId, diff --git a/src/external-cli-auth.ts b/src/auth/external-cli-auth.ts similarity index 99% rename from src/external-cli-auth.ts rename to src/auth/external-cli-auth.ts index 46fe0ab4..9733bfd3 100644 --- a/src/external-cli-auth.ts +++ b/src/auth/external-cli-auth.ts @@ -7,7 +7,7 @@ import { providerUsesExternalCliAuth, type ExternalCliAuthAdapter, type OpenWikiProvider, -} from "./constants.js"; +} from "../config/constants.js"; const execFileAsync = promisify(execFile); const EXTERNAL_CLI_TIMEOUT_MS = 5_000; diff --git a/src/auth/ngrok.ts b/src/auth/ngrok.ts index 99a712d2..adc818c3 100644 --- a/src/auth/ngrok.ts +++ b/src/auth/ngrok.ts @@ -1,6 +1,6 @@ import { spawn } from "node:child_process"; import type { ChildProcess } from "node:child_process"; -import { saveOpenWikiEnv } from "../env.js"; +import { saveOpenWikiEnv } from "../config/env.js"; const DEFAULT_CALLBACK_PORT = 53682; const OAUTH_CALLBACK_PORT_ENV_KEY = "OPENWIKI_OAUTH_CALLBACK_PORT"; diff --git a/src/auth/oauth.ts b/src/auth/oauth.ts index 1f069652..c98d7162 100644 --- a/src/auth/oauth.ts +++ b/src/auth/oauth.ts @@ -1,7 +1,7 @@ import { createHash, randomBytes } from "node:crypto"; import { execFile } from "node:child_process"; import http from "node:http"; -import { loadOpenWikiEnv, saveOpenWikiEnv } from "../env.js"; +import { loadOpenWikiEnv, saveOpenWikiEnv } from "../config/env.js"; import { discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, diff --git a/src/auth/providers.ts b/src/auth/providers.ts index d923760c..83e75da8 100644 --- a/src/auth/providers.ts +++ b/src/auth/providers.ts @@ -13,7 +13,7 @@ import { OPENWIKI_X_CLIENT_ID_ENV_KEY, OPENWIKI_X_CLIENT_SECRET_ENV_KEY, OPENWIKI_X_REFRESH_TOKEN_ENV_KEY, -} from "../constants.js"; +} from "../config/constants.js"; import type { AuthProviderId, OAuthProviderConfig } from "./types.js"; export const AUTH_PROVIDERS: Record = { diff --git a/src/auth/tokens.ts b/src/auth/tokens.ts index 57ecf4bc..89e47712 100644 --- a/src/auth/tokens.ts +++ b/src/auth/tokens.ts @@ -1,4 +1,4 @@ -import { loadOpenWikiEnv, saveOpenWikiEnv } from "../env.js"; +import { loadOpenWikiEnv, saveOpenWikiEnv } from "../config/env.js"; import { discoverAuthorizationServerMetadata, discoverProtectedResourceMetadata, diff --git a/src/cli.tsx b/src/cli/cli.tsx similarity index 99% rename from src/cli.tsx rename to src/cli/cli.tsx index 5d93634e..12739e12 100644 --- a/src/cli.tsx +++ b/src/cli/cli.tsx @@ -8,11 +8,14 @@ import { configureAuthProvider, listAuthProviderTools, shouldDiscoverToolsAfterAuth, -} from "./auth/configure.js"; -import { startNgrokTunnel } from "./auth/ngrok.js"; -import { runVisualizeServer } from "./visualize/server.js"; -import { formatAuthProviderList, runOAuthAuth } from "./auth/oauth.js"; -import { ensureCodeModeRepoSetup, runCodeModeConnectors } from "./code-mode.js"; +} from "../auth/configure.js"; +import { startNgrokTunnel } from "../auth/ngrok.js"; +import { runVisualizeServer } from "../visualize/server.js"; +import { formatAuthProviderList, runOAuthAuth } from "../auth/oauth.js"; +import { + ensureCodeModeRepoSetup, + runCodeModeConnectors, +} from "../ingestion/code-mode.js"; import { commandEmitsTelemetry, helpContent, @@ -28,37 +31,37 @@ import { InitSetup, needsCredentialSetup, type InitSetupResult, -} from "./credentials.js"; +} from "../setup/credentials.js"; import { getCredentialDiagnostics, getShellEnvValue, loadOpenWikiEnv, saveOpenWikiEnv, type CredentialDiagnostic, -} from "./env.js"; -import { createOpenWikiThreadId, runOpenWikiAgent } from "./agent/index.js"; -import { installCrashGuard } from "./agent/crash-guard.js"; -import { formatChatGptAccountFromEnv } from "./agent/openai-chatgpt-oauth.js"; +} from "../config/env.js"; +import { createOpenWikiThreadId, runOpenWikiAgent } from "../agent/index.js"; +import { installCrashGuard } from "../agent/crash-guard.js"; +import { formatChatGptAccountFromEnv } from "../agent/openai-chatgpt-oauth.js"; import { getErrorMessage, isAuthError, isSecretLikeKey, sanitizeDiagnosticText, -} from "./diagnostics.js"; -import { stripHtmlTags } from "./utils.js"; +} from "../platform/diagnostics.js"; +import { stripHtmlTags } from "../platform/utils.js"; import { type OpenWikiRunEvent, type OpenWikiRunResult, -} from "./agent/types.js"; +} from "../agent/types.js"; import { runOpenWikiIngestion, type OpenWikiIngestionResult, -} from "./ingestion.js"; +} from "../ingestion/ingestion.js"; import { readOpenWikiOnboardingConfig, saveOpenWikiOnboardingConfig, -} from "./onboarding.js"; -import { openWikiLocalWikiDir } from "./openwiki-home.js"; +} from "../setup/onboarding.js"; +import { openWikiLocalWikiDir } from "../config/openwiki-home.js"; import { deleteConnectorSchedules, getSavedPowerScheduleStatus, @@ -68,7 +71,7 @@ import { type ConnectorScheduleStatus, type PowerScheduleStatus, type ScheduleMutationResult, -} from "./schedules.js"; +} from "../scheduling/schedules.js"; import { getDefaultModelId, getMissingProviderEnvKey, @@ -87,12 +90,12 @@ import { SELECTABLE_OPENWIKI_PROVIDERS, OPENWIKI_VERSION, type OpenWikiProvider, -} from "./constants.js"; +} from "../config/constants.js"; import type { OpenWikiCommand, OpenWikiOutputMode, OpenWikiRunOptions, -} from "./agent/types.js"; +} from "../agent/types.js"; import { firstRunNoticePending, FIRST_RUN_NOTICE_BODY, @@ -100,7 +103,7 @@ import { FIRST_RUN_NOTICE_VERIFY, withRunTelemetry, type RunTelemetryContext, -} from "./telemetry/index.js"; +} from "../telemetry/index.js"; // Register the last-resort handlers before any run starts, so a rejection that // escapes every catch (e.g. a subagent error surfacing on the microtask queue) is diff --git a/src/commands.ts b/src/cli/commands.ts similarity index 98% rename from src/commands.ts rename to src/cli/commands.ts index 3ddb03ee..8ed7a0c7 100644 --- a/src/commands.ts +++ b/src/cli/commands.ts @@ -1,9 +1,12 @@ -import { isValidModelId, normalizeModelId } from "./constants.js"; -import type { OpenWikiCommand } from "./agent/types.js"; -import { resolveLanguage } from "./language.js"; -import { isAuthProviderId } from "./auth/providers.js"; -import type { AuthProviderId } from "./auth/types.js"; -import { parseIngestionTarget, type IngestionTarget } from "./ingestion.js"; +import { isValidModelId, normalizeModelId } from "../config/constants.js"; +import type { OpenWikiCommand } from "../agent/types.js"; +import { resolveLanguage } from "../platform/language.js"; +import { isAuthProviderId } from "../auth/providers.js"; +import type { AuthProviderId } from "../auth/types.js"; +import { + parseIngestionTarget, + type IngestionTarget, +} from "../ingestion/ingestion.js"; export type HelpRow = { label: string; diff --git a/src/startup.ts b/src/cli/startup.ts similarity index 92% rename from src/startup.ts rename to src/cli/startup.ts index 909650db..083a0dbc 100644 --- a/src/startup.ts +++ b/src/cli/startup.ts @@ -1,5 +1,5 @@ -import { shouldCheckUpdateNoop, getUpdateNoopStatus } from "./agent/utils.js"; -import { readCodexTokensFromEnv } from "./agent/openai-chatgpt-oauth.js"; +import { shouldCheckUpdateNoop, getUpdateNoopStatus } from "../agent/utils.js"; +import { readCodexTokensFromEnv } from "../agent/openai-chatgpt-oauth.js"; import type { CliCommand } from "./commands.js"; import { OPENAI_CHATGPT_ACCOUNT_ID_ENV_KEY, @@ -12,8 +12,8 @@ import { providerUsesOAuth, resolveConfiguredProvider, type OpenWikiProvider, -} from "./constants.js"; -import { resolveExternalCliCredential } from "./external-cli-auth.js"; +} from "../config/constants.js"; +import { resolveExternalCliCredential } from "../auth/external-cli-auth.js"; type ResolveStartupCommandOptions = { cwd?: string; diff --git a/src/constants.ts b/src/config/constants.ts similarity index 100% rename from src/constants.ts rename to src/config/constants.ts diff --git a/src/env.ts b/src/config/env.ts similarity index 99% rename from src/env.ts rename to src/config/env.ts index 3da57f5c..00a56f43 100644 --- a/src/env.ts +++ b/src/config/env.ts @@ -60,8 +60,8 @@ import { OPENWIKI_PROVIDER_RETRY_ATTEMPTS_ENV_KEY, resolveProviderRetryAttempts, } from "./constants.js"; -import { isFileNotFoundError } from "./fs-errors.js"; -import { restrictDirToCurrentUser } from "./windows-acl.js"; +import { isFileNotFoundError } from "../platform/fs-errors.js"; +import { restrictDirToCurrentUser } from "../platform/windows-acl.js"; export const openWikiEnvDir = path.join(os.homedir(), ".openwiki"); export const openWikiEnvPath = path.join(openWikiEnvDir, ".env"); diff --git a/src/openwiki-home.ts b/src/config/openwiki-home.ts similarity index 97% rename from src/openwiki-home.ts rename to src/config/openwiki-home.ts index 4ae04ff2..7d3a320f 100644 --- a/src/openwiki-home.ts +++ b/src/config/openwiki-home.ts @@ -1,7 +1,7 @@ import { chmod, mkdir } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { restrictDirToCurrentUser } from "./windows-acl.js"; +import { restrictDirToCurrentUser } from "../platform/windows-acl.js"; export const openWikiHomeDir = path.join(os.homedir(), ".openwiki"); export const openWikiConnectorsDir = path.join(openWikiHomeDir, "connectors"); diff --git a/src/connectors/io.ts b/src/connectors/io.ts index e02d13cc..4b074727 100644 --- a/src/connectors/io.ts +++ b/src/connectors/io.ts @@ -5,7 +5,7 @@ import { getConnectorConfigPath, getConnectorRawDir, getConnectorStatePath, -} from "../openwiki-home.js"; +} from "../config/openwiki-home.js"; import type { ConnectorId, ConnectorState } from "./types.js"; export async function readConnectorConfig( diff --git a/src/connectors/mcp-client.ts b/src/connectors/mcp-client.ts index ef5b9c62..15317c24 100644 --- a/src/connectors/mcp-client.ts +++ b/src/connectors/mcp-client.ts @@ -1,5 +1,5 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; -import { OPENWIKI_VERSION } from "../constants.js"; +import { OPENWIKI_VERSION } from "../config/constants.js"; import { getOAuthAccessToken, getOAuthProviderIdForAccessTokenEnvKey, diff --git a/src/connectors/mcp-runtime.ts b/src/connectors/mcp-runtime.ts index 1140baaa..840134bd 100644 --- a/src/connectors/mcp-runtime.ts +++ b/src/connectors/mcp-runtime.ts @@ -16,7 +16,7 @@ import type { ConnectorIngestResult, McpConnectorConfig, } from "./types.js"; -import { isSecretLikeKey } from "../diagnostics.js"; +import { isSecretLikeKey } from "../platform/diagnostics.js"; export type McpConnectorId = Extract; diff --git a/src/connectors/sources/gmail.ts b/src/connectors/sources/gmail.ts index a4e0d470..fd6c15c4 100644 --- a/src/connectors/sources/gmail.ts +++ b/src/connectors/sources/gmail.ts @@ -1,7 +1,7 @@ import { OPENWIKI_GMAIL_ACCESS_TOKEN_ENV_KEY, OPENWIKI_GMAIL_REFRESH_TOKEN_ENV_KEY, -} from "../../constants.js"; +} from "../../config/constants.js"; import { getOAuthAccessToken, refreshOAuthAccessToken, diff --git a/src/connectors/sources/langsmith/index.ts b/src/connectors/sources/langsmith/index.ts index b1648a5a..f08833f0 100644 --- a/src/connectors/sources/langsmith/index.ts +++ b/src/connectors/sources/langsmith/index.ts @@ -1,4 +1,4 @@ -import { sanitizeDiagnosticText } from "../../../diagnostics.js"; +import { sanitizeDiagnosticText } from "../../../platform/diagnostics.js"; import { createRunId, readConnectorState, diff --git a/src/connectors/sources/langsmith/repo-config.ts b/src/connectors/sources/langsmith/repo-config.ts index aea9ccce..8e8bd630 100644 --- a/src/connectors/sources/langsmith/repo-config.ts +++ b/src/connectors/sources/langsmith/repo-config.ts @@ -1,7 +1,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { OPEN_WIKI_DIR } from "../../../constants.js"; -import { isFileNotFoundError } from "../../../fs-errors.js"; +import { OPEN_WIKI_DIR } from "../../../config/constants.js"; +import { isFileNotFoundError } from "../../../platform/fs-errors.js"; import type { LangSmithProjectConfig } from "./types.js"; /** diff --git a/src/connectors/sources/slack.ts b/src/connectors/sources/slack.ts index b57a2c08..b22a5ea1 100644 --- a/src/connectors/sources/slack.ts +++ b/src/connectors/sources/slack.ts @@ -1,4 +1,4 @@ -import { OPENWIKI_SLACK_USER_TOKEN_ENV_KEY } from "../../constants.js"; +import { OPENWIKI_SLACK_USER_TOKEN_ENV_KEY } from "../../config/constants.js"; import { getOAuthAccessToken } from "../../auth/tokens.js"; import { normalizeStringArray } from "../config.js"; import { diff --git a/src/connectors/sources/web-search.ts b/src/connectors/sources/web-search.ts index a75a9f98..a1118e7b 100644 --- a/src/connectors/sources/web-search.ts +++ b/src/connectors/sources/web-search.ts @@ -1,5 +1,5 @@ import { TavilySearch } from "@langchain/tavily"; -import { OPENWIKI_TAVILY_API_KEY_ENV_KEY } from "../../constants.js"; +import { OPENWIKI_TAVILY_API_KEY_ENV_KEY } from "../../config/constants.js"; import { normalizeStringArray } from "../config.js"; import { createRunId, diff --git a/src/connectors/sources/x.ts b/src/connectors/sources/x.ts index 48213d83..bb368bb8 100644 --- a/src/connectors/sources/x.ts +++ b/src/connectors/sources/x.ts @@ -12,7 +12,7 @@ import type { ConnectorIngestResult, ConnectorRuntime, } from "../types.js"; -import { OPENWIKI_X_ACCESS_TOKEN_ENV_KEY } from "../../constants.js"; +import { OPENWIKI_X_ACCESS_TOKEN_ENV_KEY } from "../../config/constants.js"; import { getOAuthAccessToken } from "../../auth/tokens.js"; import { fetchWithResilience } from "../http.js"; import { normalizeStringArray } from "../config.js"; diff --git a/src/connectors/tools.ts b/src/connectors/tools.ts index 822194fa..f8f2750f 100644 --- a/src/connectors/tools.ts +++ b/src/connectors/tools.ts @@ -11,7 +11,7 @@ import { openWikiHomeDir, openWikiLocalWikiDir, resolveConnectorRawPath, -} from "../openwiki-home.js"; +} from "../config/openwiki-home.js"; import { createConnectorRegistry, isConnectorId } from "./registry.js"; import { callMcpConnectorTool, diff --git a/src/code-mode.ts b/src/ingestion/code-mode.ts similarity index 97% rename from src/code-mode.ts rename to src/ingestion/code-mode.ts index b08b3871..33ea5f8c 100644 --- a/src/code-mode.ts +++ b/src/ingestion/code-mode.ts @@ -1,11 +1,11 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { OPENWIKI_VERSION } from "./constants.js"; -import { isFileNotFoundError } from "./fs-errors.js"; -import { createConnectorRegistry } from "./connectors/registry.js"; -import { UPDATE_METADATA_PATH } from "./constants.js"; +import { OPENWIKI_VERSION } from "../config/constants.js"; +import { isFileNotFoundError } from "../platform/fs-errors.js"; +import { createConnectorRegistry } from "../connectors/registry.js"; +import { UPDATE_METADATA_PATH } from "../config/constants.js"; import { createConnectorSynthesisGuidance } from "./ingestion.js"; -import type { OpenWikiRunEvent } from "./agent/types.js"; +import type { OpenWikiRunEvent } from "../agent/types.js"; const OPENWIKI_AGENTS_SNIPPET_START = ""; const OPENWIKI_AGENTS_SNIPPET_END = ""; diff --git a/src/ingestion.ts b/src/ingestion/ingestion.ts similarity index 98% rename from src/ingestion.ts rename to src/ingestion/ingestion.ts index 4683295d..97c1c292 100644 --- a/src/ingestion.ts +++ b/src/ingestion/ingestion.ts @@ -1,33 +1,33 @@ import { createConnectorRegistry, isConnectorId, -} from "./connectors/registry.js"; +} from "../connectors/registry.js"; import type { ConnectorId, ConnectorIngestResult, ConnectorRuntime, -} from "./connectors/types.js"; -import { loadOpenWikiEnv } from "./env.js"; +} from "../connectors/types.js"; +import { loadOpenWikiEnv } from "../config/env.js"; import { readOpenWikiOnboardingConfig, type OnboardingSourceInstanceConfig, type OpenWikiOnboardingConfig, -} from "./onboarding.js"; +} from "../setup/onboarding.js"; import { ensureOpenWikiHome, getConnectorConfigPath, openWikiLocalWikiDir, -} from "./openwiki-home.js"; -import { createOpenWikiThreadId, runOpenWikiAgent } from "./agent/index.js"; +} from "../config/openwiki-home.js"; +import { createOpenWikiThreadId, runOpenWikiAgent } from "../agent/index.js"; import type { OpenWikiRunEvent, OpenWikiRunOptions, OpenWikiRunResult, -} from "./agent/types.js"; +} from "../agent/types.js"; import { withRunTelemetry, type RunTelemetryContext, -} from "./telemetry/index.js"; +} from "../telemetry/index.js"; const INGESTION_WINDOW_HOURS = 24; diff --git a/src/mermaid/validate.ts b/src/mermaid/validate.ts index 9700474d..67521229 100644 --- a/src/mermaid/validate.ts +++ b/src/mermaid/validate.ts @@ -1,4 +1,4 @@ -import { sanitizeDiagnosticText } from "../diagnostics.js"; +import { sanitizeDiagnosticText } from "../platform/diagnostics.js"; import { ensureDomGlobals } from "./dom-shim.js"; import { extractMermaidFences, type MermaidFence } from "./fences.js"; diff --git a/src/diagnostics.ts b/src/platform/diagnostics.ts similarity index 99% rename from src/diagnostics.ts rename to src/platform/diagnostics.ts index ac5ef334..ac2f234b 100644 --- a/src/diagnostics.ts +++ b/src/platform/diagnostics.ts @@ -17,7 +17,7 @@ import { OPENAI_API_KEY_ENV_KEY, OPENAI_COMPATIBLE_API_KEY_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, -} from "./constants.js"; +} from "../config/constants.js"; /** * Redacts secrets from text before it is shown to the user or written to a log. diff --git a/src/fs-errors.ts b/src/platform/fs-errors.ts similarity index 100% rename from src/fs-errors.ts rename to src/platform/fs-errors.ts diff --git a/src/language.ts b/src/platform/language.ts similarity index 100% rename from src/language.ts rename to src/platform/language.ts diff --git a/src/utils.ts b/src/platform/utils.ts similarity index 100% rename from src/utils.ts rename to src/platform/utils.ts diff --git a/src/windows-acl.ts b/src/platform/windows-acl.ts similarity index 100% rename from src/windows-acl.ts rename to src/platform/windows-acl.ts diff --git a/src/schedules.ts b/src/scheduling/schedules.ts similarity index 99% rename from src/schedules.ts rename to src/scheduling/schedules.ts index d3c4d11a..739ab8b5 100644 --- a/src/schedules.ts +++ b/src/scheduling/schedules.ts @@ -5,9 +5,12 @@ import path from "node:path"; import { promisify } from "node:util"; import { CronExpressionParser } from "cron-parser"; import cronstrue from "cronstrue"; -import { ensureOpenWikiHome, openWikiHomeDir } from "./openwiki-home.js"; -import type { ConnectorId } from "./connectors/types.js"; -import type { OpenWikiOnboardingConfig } from "./onboarding.js"; +import { + ensureOpenWikiHome, + openWikiHomeDir, +} from "../config/openwiki-home.js"; +import type { ConnectorId } from "../connectors/types.js"; +import type { OpenWikiOnboardingConfig } from "../setup/onboarding.js"; const execFileAsync = promisify(execFile); const DEFAULT_FIRST_HOUR = 2; diff --git a/src/credentials.tsx b/src/setup/credentials.tsx similarity index 99% rename from src/credentials.tsx rename to src/setup/credentials.tsx index 6ad83b12..899dd51c 100644 --- a/src/credentials.tsx +++ b/src/setup/credentials.tsx @@ -4,8 +4,8 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { homedir } from "node:os"; import path from "node:path"; import { Box, Text, useInput, useStdin, useStdout } from "ink"; -import { configureAuthProvider } from "./auth/configure.js"; -import { runOAuthAuth } from "./auth/oauth.js"; +import { configureAuthProvider } from "../auth/configure.js"; +import { runOAuthAuth } from "../auth/oauth.js"; import { AWS_ACCESS_KEY_ID_ENV_KEY, AWS_BEARER_TOKEN_BEDROCK_ENV_KEY, @@ -49,7 +49,7 @@ import { resolveConfiguredProvider, resolveProviderRegion, SELECTABLE_OPENWIKI_PROVIDERS, -} from "./constants.js"; +} from "../config/constants.js"; import { type ChatGptLoginHandle, type CodexTokens, @@ -58,30 +58,30 @@ import { isChatGptTokenExpired, loginWithChatGPT, readCodexTokensFromEnv, -} from "./agent/openai-chatgpt-oauth.js"; -import type { AuthProviderId } from "./auth/types.js"; -import type { OpenWikiRunMode } from "./commands.js"; +} from "../agent/openai-chatgpt-oauth.js"; +import type { AuthProviderId } from "../auth/types.js"; +import type { OpenWikiRunMode } from "../cli/commands.js"; import { loadLangSmithSetup, nextLangSmithApiKeyEnv, saveLangSmithSetup, -} from "./connectors/sources/langsmith/setup.js"; -import type { LangSmithRegion } from "./connectors/sources/langsmith/setup.js"; -import type { ConnectorId } from "./connectors/types.js"; +} from "../connectors/sources/langsmith/setup.js"; +import type { LangSmithRegion } from "../connectors/sources/langsmith/setup.js"; +import type { ConnectorId } from "../connectors/types.js"; import { detectExternalCliCredential, getExternalCliAuthAdapter, isExternalCliAvailable, runExternalCliLogin, type ExternalCliAuthState, -} from "./external-cli-auth.js"; -import { getConnectorConfigPath } from "./openwiki-home.js"; +} from "../auth/external-cli-auth.js"; +import { getConnectorConfigPath } from "../config/openwiki-home.js"; import { getSavedEnvValue, getShellEnvValue, openWikiEnvPath, saveOpenWikiEnv, -} from "./env.js"; +} from "../config/env.js"; import { createEmptyOnboardingConfig, isOpenWikiOnboardingCompleteSync, @@ -98,7 +98,7 @@ import { installOpenWikiPowerSchedule, installConnectorSchedule, validateCronExpression, -} from "./schedules.js"; +} from "../scheduling/schedules.js"; export type InitSetupResult = { mode: OpenWikiRunMode; diff --git a/src/onboarding.ts b/src/setup/onboarding.ts similarity index 98% rename from src/onboarding.ts rename to src/setup/onboarding.ts index 7844cf64..6ddeb5fd 100644 --- a/src/onboarding.ts +++ b/src/setup/onboarding.ts @@ -1,9 +1,12 @@ import { existsSync, readFileSync } from "node:fs"; import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; -import { OPEN_WIKI_DIR } from "./constants.js"; -import { ensureOpenWikiHome, openWikiHomeDir } from "./openwiki-home.js"; -import type { ConnectorId } from "./connectors/types.js"; +import { OPEN_WIKI_DIR } from "../config/constants.js"; +import { + ensureOpenWikiHome, + openWikiHomeDir, +} from "../config/openwiki-home.js"; +import type { ConnectorId } from "../connectors/types.js"; export const openWikiOnboardingPath = path.join( openWikiHomeDir, diff --git a/src/telemetry/config.ts b/src/telemetry/config.ts index b151381e..11e5ab86 100644 --- a/src/telemetry/config.ts +++ b/src/telemetry/config.ts @@ -1,6 +1,6 @@ import path from "node:path"; -import { openWikiHomeDir } from "../openwiki-home.js"; +import { openWikiHomeDir } from "../config/openwiki-home.js"; /** * Publishable PostHog project key. Safe to ship (client/ingestion key). @@ -29,7 +29,7 @@ export const TELEMETRY_RUN_EVENT = "openwiki_run"; /** * The one-time disclosure copy, single-sourced here. Stored unwrapped so each * surface wraps it to its own width: the interactive TUI renders these in an Ink - * box, and the print/non-TTY path frames and wraps them (see cli.tsx). + * box, and the print/non-TTY path frames and wraps them (see cli/cli.tsx). */ export const FIRST_RUN_NOTICE_BODY = "OpenWiki collects anonymous, aggregate usage data: which command you run (init or update), the brain mode and model provider you set up, whether runs succeed or fail (and a general error category), and which connectors you configured. No file contents, repository data, credentials, prompts, model output, IP address, or personal information are ever collected."; diff --git a/src/telemetry/install-id.ts b/src/telemetry/install-id.ts index feadd62e..696be93e 100644 --- a/src/telemetry/install-id.ts +++ b/src/telemetry/install-id.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; -import { openWikiHomeDir } from "../openwiki-home.js"; +import { openWikiHomeDir } from "../config/openwiki-home.js"; import { INSTALL_ID_PATH } from "./config.js"; import { noticeSuppressed } from "./gates.js"; diff --git a/src/telemetry/record-run-safe.ts b/src/telemetry/record-run-safe.ts index 99c9667f..a248fb7b 100644 --- a/src/telemetry/record-run-safe.ts +++ b/src/telemetry/record-run-safe.ts @@ -4,7 +4,7 @@ import type { OpenWikiRunOptions, } from "../agent/types.js"; import { getConfiguredConnectorIds } from "../connectors/registry.js"; -import type { OpenWikiProvider } from "../constants.js"; +import type { OpenWikiProvider } from "../config/constants.js"; import { recordRun } from "./senders.js"; import type { diff --git a/test/agent-runtime-root.test.ts b/test/agent/agent-runtime-root.test.ts similarity index 91% rename from test/agent-runtime-root.test.ts rename to test/agent/agent-runtime-root.test.ts index 3a209062..a5b2438a 100644 --- a/test/agent-runtime-root.test.ts +++ b/test/agent/agent-runtime-root.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { formatRuntimeRootInstruction } from "../src/agent/prompt.ts"; +import { formatRuntimeRootInstruction } from "../../src/agent/prompt.ts"; describe("formatRuntimeRootInstruction", () => { test("points repository runs at the repo-local openwiki directory", () => { diff --git a/test/bedrock-credentials-integration.test.ts b/test/agent/bedrock-credentials-integration.test.ts similarity index 97% rename from test/bedrock-credentials-integration.test.ts rename to test/agent/bedrock-credentials-integration.test.ts index c93674a6..1e4429b4 100644 --- a/test/bedrock-credentials-integration.test.ts +++ b/test/agent/bedrock-credentials-integration.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { ChatBedrockConverse } from "@langchain/aws"; -import { createModel } from "../src/agent/index.ts"; +import { createModel } from "../../src/agent/index.ts"; const ENV_KEYS = [ "AWS_ACCESS_KEY_ID", diff --git a/test/bedrock-model.test.ts b/test/agent/bedrock-model.test.ts similarity index 97% rename from test/bedrock-model.test.ts rename to test/agent/bedrock-model.test.ts index b694d64e..cf7a1d96 100644 --- a/test/bedrock-model.test.ts +++ b/test/agent/bedrock-model.test.ts @@ -12,7 +12,7 @@ vi.mock("@langchain/aws", () => ({ }, })); -const { createModel } = await import("../src/agent/index.ts"); +const { createModel } = await import("../../src/agent/index.ts"); const ENV_KEYS = [ "AWS_DEFAULT_REGION", diff --git a/test/checkpoint-policy.test.ts b/test/agent/checkpoint-policy.test.ts similarity index 90% rename from test/checkpoint-policy.test.ts rename to test/agent/checkpoint-policy.test.ts index 17a3f066..a35219a8 100644 --- a/test/checkpoint-policy.test.ts +++ b/test/agent/checkpoint-policy.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { resolveCheckpointTarget } from "../src/agent/index.ts"; +import { resolveCheckpointTarget } from "../../src/agent/index.ts"; describe("checkpoint persistence policy", () => { test("keeps chat checkpoints in the persistent OpenWiki database", () => { diff --git a/test/checkpoint-pruning.test.ts b/test/agent/checkpoint-pruning.test.ts similarity index 97% rename from test/checkpoint-pruning.test.ts rename to test/agent/checkpoint-pruning.test.ts index 98709711..9a1d73ff 100644 --- a/test/checkpoint-pruning.test.ts +++ b/test/agent/checkpoint-pruning.test.ts @@ -1,6 +1,6 @@ import { SqliteSaver } from "@langchain/langgraph-checkpoint-sqlite"; import { describe, expect, test } from "vitest"; -import { pruneCheckpointHistory } from "../src/agent/index.ts"; +import { pruneCheckpointHistory } from "../../src/agent/index.ts"; describe("pruneCheckpointHistory", () => { test("keeps only the latest checkpoint per thread/namespace and leaves other threads untouched", async () => { diff --git a/test/create-model.test.ts b/test/agent/create-model.test.ts similarity index 98% rename from test/create-model.test.ts rename to test/agent/create-model.test.ts index 50816767..f26a2412 100644 --- a/test/create-model.test.ts +++ b/test/agent/create-model.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { ChatAnthropic } from "@langchain/anthropic"; import { ChatGoogle } from "@langchain/google/node"; import { ChatOpenAI } from "@langchain/openai"; -import { createModel } from "../src/agent/index.ts"; +import { createModel } from "../../src/agent/index.ts"; // Constructing a LangChain chat model makes no network calls (auth/clients // resolve lazily on first request), so these assert the gemini-enterprise diff --git a/test/docs-only-backend.test.ts b/test/agent/docs-only-backend.test.ts similarity index 98% rename from test/docs-only-backend.test.ts rename to test/agent/docs-only-backend.test.ts index 17f4db0a..e8a59c3b 100644 --- a/test/docs-only-backend.test.ts +++ b/test/agent/docs-only-backend.test.ts @@ -6,7 +6,7 @@ import { isOpenWikiDocsPath, MUTATION_PATH_METADATA_KEY, OpenWikiLocalShellBackend, -} from "../src/agent/docs-only-backend.ts"; +} from "../../src/agent/docs-only-backend.ts"; describe("OpenWikiLocalShellBackend", () => { test("recognizes only openwiki virtual paths as docs paths", () => { diff --git a/test/frontmatter-validator.test.ts b/test/agent/frontmatter-validator.test.ts similarity index 96% rename from test/frontmatter-validator.test.ts rename to test/agent/frontmatter-validator.test.ts index 6f1f4436..1d4fdf0e 100644 --- a/test/frontmatter-validator.test.ts +++ b/test/agent/frontmatter-validator.test.ts @@ -1,9 +1,9 @@ import { ToolMessage } from "@langchain/core/messages"; import type { BackendProtocolV2 } from "deepagents"; import { describe, expect, test, vi } from "vitest"; -import { MUTATION_PATH_METADATA_KEY } from "../src/agent/docs-only-backend.ts"; -import { addFrontmatterWarning } from "../src/agent/okf-middleware.ts"; -import { validateOkfFrontmatter } from "../src/okf/frontmatter.ts"; +import { MUTATION_PATH_METADATA_KEY } from "../../src/agent/docs-only-backend.ts"; +import { addFrontmatterWarning } from "../../src/agent/okf-middleware.ts"; +import { validateOkfFrontmatter } from "../../src/okf/frontmatter.ts"; function markdown(frontmatter: string): string { return `---\n${frontmatter}\n---\n\n# Page\n`; diff --git a/test/gemini-enterprise-claude.e2e.test.ts b/test/agent/gemini-enterprise-claude.e2e.test.ts similarity index 97% rename from test/gemini-enterprise-claude.e2e.test.ts rename to test/agent/gemini-enterprise-claude.e2e.test.ts index d474fcc2..78009f52 100644 --- a/test/gemini-enterprise-claude.e2e.test.ts +++ b/test/agent/gemini-enterprise-claude.e2e.test.ts @@ -2,8 +2,8 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { ensureDomGlobals } from "../src/mermaid/dom-shim.ts"; -import { createModel } from "../src/agent/index.ts"; +import { ensureDomGlobals } from "../../src/mermaid/dom-shim.ts"; +import { createModel } from "../../src/agent/index.ts"; // End-to-end regression for issue #3, using the REAL Anthropic Vertex SDK (no // mock) and the REAL Mermaid DOM shim. The optional Mermaid validation path diff --git a/test/gemini-enterprise-claude.test.ts b/test/agent/gemini-enterprise-claude.test.ts similarity index 98% rename from test/gemini-enterprise-claude.test.ts rename to test/agent/gemini-enterprise-claude.test.ts index f88b7826..6abdba60 100644 --- a/test/gemini-enterprise-claude.test.ts +++ b/test/agent/gemini-enterprise-claude.test.ts @@ -32,7 +32,7 @@ vi.mock("@anthropic-ai/vertex-sdk", () => ({ }, })); -const { createModel } = await import("../src/agent/index.ts"); +const { createModel } = await import("../../src/agent/index.ts"); const PROJECT_KEY = "GOOGLE_CLOUD_PROJECT"; const LOCATION_KEY = "GOOGLE_CLOUD_LOCATION"; diff --git a/test/gemini-retry.test.ts b/test/agent/gemini-retry.test.ts similarity index 98% rename from test/gemini-retry.test.ts rename to test/agent/gemini-retry.test.ts index 5a29f11e..addae155 100644 --- a/test/gemini-retry.test.ts +++ b/test/agent/gemini-retry.test.ts @@ -33,7 +33,7 @@ vi.mock("@langchain/openai", () => ({ })); // Imported after vi.mock so the mocked constructors are in effect. -const { createModel } = await import("../src/agent/index.ts"); +const { createModel } = await import("../../src/agent/index.ts"); const PROJECT_KEY = "GOOGLE_CLOUD_PROJECT"; const GEMINI_KEY = "GEMINI_API_KEY"; diff --git a/test/index-middleware.test.ts b/test/agent/index-middleware.test.ts similarity index 98% rename from test/index-middleware.test.ts rename to test/agent/index-middleware.test.ts index ea0ce72c..0c0aab37 100644 --- a/test/index-middleware.test.ts +++ b/test/agent/index-middleware.test.ts @@ -2,13 +2,13 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; -import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; -import { createOpenWikiIndexMiddleware } from "../src/agent/okf-middleware.ts"; -import { ENGLISH_INDEX_LABELS } from "../src/okf/index-labels.ts"; +import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; +import { createOpenWikiIndexMiddleware } from "../../src/agent/okf-middleware.ts"; +import { ENGLISH_INDEX_LABELS } from "../../src/okf/index-labels.ts"; import { migrateWikiToOkf, synchronizeWikiIndexes, -} from "../src/okf/index-sync.ts"; +} from "../../src/okf/index-sync.ts"; // A flowchart node named `end` is reserved, so this fence fails to parse. const BROKEN_MERMAID = [ diff --git a/test/model-resolution.test.ts b/test/agent/model-resolution.test.ts similarity index 92% rename from test/model-resolution.test.ts rename to test/agent/model-resolution.test.ts index 812328fc..673f0523 100644 --- a/test/model-resolution.test.ts +++ b/test/agent/model-resolution.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "vitest"; -import { resolveModelId } from "../src/agent/index.ts"; -import { OPENWIKI_MODEL_ID_ENV_KEY } from "../src/constants.ts"; +import { resolveModelId } from "../../src/agent/index.ts"; +import { OPENWIKI_MODEL_ID_ENV_KEY } from "../../src/config/constants.ts"; const originalModelId = process.env[OPENWIKI_MODEL_ID_ENV_KEY]; diff --git a/test/openai-chatgpt-oauth.test.ts b/test/agent/openai-chatgpt-oauth.test.ts similarity index 99% rename from test/openai-chatgpt-oauth.test.ts rename to test/agent/openai-chatgpt-oauth.test.ts index 79b3e996..2785e4e0 100644 --- a/test/openai-chatgpt-oauth.test.ts +++ b/test/agent/openai-chatgpt-oauth.test.ts @@ -11,7 +11,7 @@ import { parseManualCallbackInput, readCodexTokensFromEnv, refreshChatGptTokens, -} from "../src/agent/openai-chatgpt-oauth.ts"; +} from "../../src/agent/openai-chatgpt-oauth.ts"; function makeAccessToken( accountId: string | null, diff --git a/test/prompt-okf.test.ts b/test/agent/prompt-okf.test.ts similarity index 93% rename from test/prompt-okf.test.ts rename to test/agent/prompt-okf.test.ts index 9b7097b6..5cb93f78 100644 --- a/test/prompt-okf.test.ts +++ b/test/agent/prompt-okf.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { createSystemPrompt } from "../src/agent/prompt.ts"; +import { createSystemPrompt } from "../../src/agent/prompt.ts"; describe("createSystemPrompt OKF guidance", () => { test("keeps init requirements compact and update preservation explicit", () => { diff --git a/test/agent/prompt.test.ts b/test/agent/prompt.test.ts new file mode 100644 index 00000000..4dff59cc --- /dev/null +++ b/test/agent/prompt.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "vitest"; +import { + createDiagramInstructions, + createLinkIntegrityInstructions, + createSystemPrompt, +} from "../../src/agent/prompt.ts"; + +describe("createSystemPrompt output language", () => { + test("instructs the agent to write wiki documentation in the selected language", () => { + const prompt = createSystemPrompt("init", "repository", "zh-CN"); + + expect(prompt).toContain("Output language:"); + expect(prompt).toContain( + "Write generated wiki prose, headings, table content, and documentation in zh-CN.", + ); + expect(prompt).toContain( + 'write the human-readable "title", "description", and "type" values in zh-CN', + ); + // The field rule must dominate the "keep technical terms unchanged" rule, or + // a technical-term-dense description gets left in the source language. + expect(prompt).toContain( + "dense with product names, feature names, or technical terminology", + ); + // Tags stay canonical (an aggregation key), so they are written in English. + expect(prompt).toContain('Write the "tags" values in English'); + // Whole-wiki language reconciliation is code-owned: the agent must not + // re-translate existing pages on a switch, so it never fights the separate + // deterministic translation pass or acts on stale language metadata. + expect(prompt).toContain( + "brought existing pages into zh-CN in a separate deterministic pass", + ); + expect(prompt).toContain("that whole-wiki reconciliation is code-owned"); + expect(prompt).toContain( + "Apply this language only to generated wiki files.", + ); + expect(prompt).toContain( + "Keep code identifiers, file paths, commands, API names, URLs, and code blocks unchanged", + ); + }); + + test("preserves the existing prompt behavior when no language is supplied", () => { + expect(createSystemPrompt("init", "repository")).not.toContain( + "Output language:", + ); + }); +}); + +/** + * Guards against the 0.2 regression where the shared "Canonical wiki location" + * and "Wiki-first question answering" blocks hardcoded ~/.openwiki/wiki and + * leaked into repository (code) mode. In code mode the filesystem virtual root + * maps to the repo, so instructing the model to use ~/.openwiki/wiki made it + * type non-absolute host paths into filesystem tools and crash the run. + */ +describe("createSystemPrompt filesystem path guidance", () => { + const commands = ["init", "update", "chat"] as const; + + describe("repository mode", () => { + for (const command of commands) { + test(`${command}: does not point the wiki at ~/.openwiki/wiki`, () => { + const prompt = createSystemPrompt(command, "repository"); + + // The canonical location must be the repo-local /openwiki, never the + // personal-brain home dir. + expect(prompt).not.toMatch(/lives in ~\/\.openwiki\/wiki/); + expect(prompt).not.toMatch(/inspect ~\/\.openwiki\/wiki first/); + expect(prompt).toContain("/openwiki"); + }); + } + }); + + describe("local-wiki mode", () => { + for (const command of commands) { + test(`${command}: roots the wiki at ~/.openwiki/wiki via virtual /`, () => { + const prompt = createSystemPrompt(command, "local-wiki"); + + expect(prompt).toContain("~/.openwiki/wiki"); + expect(prompt).toContain("/quickstart.md"); + }); + + test(`${command}: does not treat repository agent files as personal instructions`, () => { + const prompt = createSystemPrompt(command, "local-wiki"); + + expect(prompt).toContain( + "Repository /AGENTS.md and /CLAUDE.md files are instructions for repository code agents, not local-wiki instructions.", + ); + expect(prompt).toContain( + "do not read or follow those files unless the user explicitly asks about their contents", + ); + }); + } + + test("preserves unresolved source conflicts as contested knowledge", () => { + const prompt = createSystemPrompt("update", "local-wiki"); + + expect(prompt).toContain("contested:"); + expect(prompt).toContain("## Contested section"); + expect(prompt).toContain( + "Never resolve a contested fact by recency alone", + ); + expect(prompt).toContain( + "Never present either side as confirmed or source-backed while the conflict remains unsettled", + ); + expect(prompt).toContain( + "Add an /open-questions.md entry only when the unresolved conflict would impair future assistance", + ); + }); + }); + + test("both modes forbid typing host/tilde paths into filesystem tools", () => { + for (const outputMode of ["repository", "local-wiki"] as const) { + const prompt = createSystemPrompt("update", outputMode); + expect(prompt).toMatch( + /Never type ~, ~\/\.openwiki\/wiki, or host paths/, + ); + } + }); +}); + +/** + * The deterministic post-run pass repairs missing or invalid front matter and + * tags the page `openwiki_generated`. The prompt must tell the agent that code + * owns conformance and that it should enrich those flagged pages, so quality + * fills in over later runs instead of code guessing forever. + */ +describe("createSystemPrompt openwiki_generated enrichment guidance", () => { + for (const outputMode of ["repository", "local-wiki"] as const) { + test(`${outputMode} mode: instructs the agent to enrich and clear the mark`, () => { + const prompt = createSystemPrompt("update", outputMode); + + expect(prompt).toContain("openwiki_generated: true"); + expect(prompt).toMatch(/repairs front matter deterministically/); + expect(prompt).toMatch(/remove the `openwiki_generated` field/); + }); + } +}); + +/** + * The translation middleware is the sole owner of the + * `openwiki_translation_pending` marker. The prompt must tell the agent to leave + * it alone so the model never adds, edits, or clears a marker code manages. + */ +describe("createSystemPrompt translation-marker guidance", () => { + for (const outputMode of ["repository", "local-wiki"] as const) { + test(`${outputMode} mode: tells the agent to ignore the pending marker`, () => { + const prompt = createSystemPrompt("update", outputMode); + + expect(prompt).toContain("openwiki_translation_pending"); + expect(prompt).toMatch(/Do not add, edit, remove, or act on it/); + }); + } +}); + +describe("createDiagramInstructions", () => { + test("nudges toward diagrams and defers label-safety to the skill", () => { + const text = createDiagramInstructions(); + + expect(text).toContain("Diagram discipline:"); + expect(text).toContain("```mermaid"); + // Names each of the four diagram types the skill documents. + for (const type of [ + "sequenceDiagram", + "stateDiagram-v2", + "erDiagram", + "flowchart", + ]) { + expect(text).toContain(type); + } + // Detailed syntax rules moved to the skill; the prompt points at it instead + // of restating them. + expect(text).toContain("mermaid-diagrams skill"); + expect(text.toLowerCase()).not.toContain("semicolons"); + }); +}); + +describe("createLinkIntegrityInstructions", () => { + test("teaches the post-run broken-link stamp marker for self-repair", () => { + const text = createLinkIntegrityInstructions(); + + expect(text).toContain("Link integrity:"); + expect(text).toContain("openwiki: broken internal link"); + expect(text).toContain("delete the comment"); + }); +}); + +describe("createSystemPrompt diagram guidance", () => { + test("is always present for init and update runs", () => { + for (const command of ["init", "update"] as const) { + const prompt = createSystemPrompt(command); + + expect(prompt).toContain("Diagram discipline:"); + expect(prompt).toContain("```mermaid"); + // Contract with the post-run degrade pass: the prompt must teach the exact + // marker the validator embeds, or the repair loop never triggers. + expect(prompt).toContain("openwiki: mermaid parse failed"); + expect(prompt).toContain("Link integrity:"); + expect(prompt).toContain("openwiki: broken internal link"); + expect(prompt).toContain("Mode-specific behavior:"); + } + }); + + test("update mode permits opportunistically adding a missing diagram", () => { + // Surgical-update discipline would otherwise suppress net-new diagrams on an + // existing wiki; this carve-out lets diagrams reach already-built wikis. + const update = createSystemPrompt("update"); + expect(update).toContain("adding one is a valuable improvement"); + + // The carve-out is scoped to update runs, not repeated in init guidance. + const init = createSystemPrompt("init"); + expect(init).not.toContain("adding one is a valuable improvement"); + }); +}); diff --git a/test/redaction.test.ts b/test/agent/redaction.test.ts similarity index 99% rename from test/redaction.test.ts rename to test/agent/redaction.test.ts index e7eb8fdf..af81a370 100644 --- a/test/redaction.test.ts +++ b/test/agent/redaction.test.ts @@ -5,11 +5,11 @@ import { isOpenRouterServerError, isSecretLikeKey, sanitizeDiagnosticText, -} from "../src/diagnostics.ts"; +} from "../../src/platform/diagnostics.ts"; import { formatEnvironmentDebugValue, sanitizeOpenRouterResponseBody, -} from "../src/agent/index.ts"; +} from "../../src/agent/index.ts"; describe("isSecretLikeKey", () => { // The shared predicate must be the union of every term the three former diff --git a/test/run-context.test.ts b/test/agent/run-context.test.ts similarity index 98% rename from test/run-context.test.ts rename to test/agent/run-context.test.ts index cd1688ea..cbfe7a24 100644 --- a/test/run-context.test.ts +++ b/test/agent/run-context.test.ts @@ -5,7 +5,7 @@ import { describe, expect, test } from "vitest"; import { createRunContext, writeLastUpdateMetadata, -} from "../src/agent/utils.ts"; +} from "../../src/agent/utils.ts"; describe("createRunContext output language", () => { test("propagates a selected language and defaults to English", async () => { diff --git a/test/run-metadata.test.ts b/test/agent/run-metadata.test.ts similarity index 98% rename from test/run-metadata.test.ts rename to test/agent/run-metadata.test.ts index 6ecbbf82..8c0ceb4a 100644 --- a/test/run-metadata.test.ts +++ b/test/agent/run-metadata.test.ts @@ -6,8 +6,8 @@ import { createOpenWikiContentSnapshot, persistRunMetadataIfChanged, removeTemporaryPlanFile, -} from "../src/agent/utils.ts"; -import type { OpenWikiOutputMode } from "../src/agent/types.ts"; +} from "../../src/agent/utils.ts"; +import type { OpenWikiOutputMode } from "../../src/agent/types.ts"; async function createTempRepo(): Promise { return mkdtemp(path.join(tmpdir(), "openwiki-run-metadata-")); diff --git a/test/skills.test.ts b/test/agent/skills.test.ts similarity index 98% rename from test/skills.test.ts rename to test/agent/skills.test.ts index e6c4f61f..54aff7ad 100644 --- a/test/skills.test.ts +++ b/test/agent/skills.test.ts @@ -10,7 +10,7 @@ import { import os from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; -import { replaceSkillDirectories } from "../src/agent/skills.ts"; +import { replaceSkillDirectories } from "../../src/agent/skills.ts"; describe("replaceSkillDirectories", () => { test("overwrites bundled skills and preserves unrelated skills", async () => { diff --git a/test/stream-redaction.test.ts b/test/agent/stream-redaction.test.ts similarity index 98% rename from test/stream-redaction.test.ts rename to test/agent/stream-redaction.test.ts index da660206..2c63e600 100644 --- a/test/stream-redaction.test.ts +++ b/test/agent/stream-redaction.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseAgentStreamChunk } from "../src/agent/index.ts"; +import { parseAgentStreamChunk } from "../../src/agent/index.ts"; function makeChunk( contentBlocks: unknown[], diff --git a/test/translation-middleware.test.ts b/test/agent/translation-middleware.test.ts similarity index 99% rename from test/translation-middleware.test.ts rename to test/agent/translation-middleware.test.ts index 533c9da0..54f788d1 100644 --- a/test/translation-middleware.test.ts +++ b/test/agent/translation-middleware.test.ts @@ -4,12 +4,12 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; -import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; +import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; import { createWikiTranslationMiddleware, resolveTranslationPlan, type TranslationPlan, -} from "../src/agent/translation-middleware.ts"; +} from "../../src/agent/translation-middleware.ts"; /** * A translate-all plan (a real language switch) into the given target. diff --git a/test/update-noop.test.ts b/test/agent/update-noop.test.ts similarity index 99% rename from test/update-noop.test.ts rename to test/agent/update-noop.test.ts index f4dd8fa1..faf8c5be 100644 --- a/test/update-noop.test.ts +++ b/test/agent/update-noop.test.ts @@ -8,7 +8,7 @@ import { OpenWikiIgnore } from "../src/agent/openwiki-ignore.ts"; import { getUpdateNoopStatus, shouldCheckUpdateNoop, -} from "../src/agent/utils.ts"; +} from "../../src/agent/utils.ts"; const execFileAsync = promisify(execFile); diff --git a/test/vertex-surface.test.ts b/test/agent/vertex-surface.test.ts similarity index 99% rename from test/vertex-surface.test.ts rename to test/agent/vertex-surface.test.ts index dcd192ce..5e1458c1 100644 --- a/test/vertex-surface.test.ts +++ b/test/agent/vertex-surface.test.ts @@ -15,7 +15,7 @@ const { toVertexPublisherModel, vertexOpenAIBaseUrl, withAnthropicAuthEnvNeutralized, -} = await import("../src/agent/vertex-surface.ts"); +} = await import("../../src/agent/vertex-surface.ts"); describe("resolveVertexSurface", () => { test("routes Claude ids to the anthropic surface", () => { diff --git a/test/external-cli-auth.test.ts b/test/auth/external-cli-auth.test.ts similarity index 98% rename from test/external-cli-auth.test.ts rename to test/auth/external-cli-auth.test.ts index 25fa597b..b6477d94 100644 --- a/test/external-cli-auth.test.ts +++ b/test/auth/external-cli-auth.test.ts @@ -11,7 +11,7 @@ import { getExternalCliAuthAdapter, resolveExternalCliCredential, validateExternalCliCredential, -} from "../src/external-cli-auth.ts"; +} from "../../src/auth/external-cli-auth.ts"; afterEach(() => { execFileMock.mockReset(); diff --git a/test/oauth-callback-server.test.ts b/test/auth/oauth-callback-server.test.ts similarity index 97% rename from test/oauth-callback-server.test.ts rename to test/auth/oauth-callback-server.test.ts index d2559346..54e0ff9b 100644 --- a/test/oauth-callback-server.test.ts +++ b/test/auth/oauth-callback-server.test.ts @@ -1,8 +1,8 @@ import { once } from "node:events"; import net from "node:net"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { createCallbackServer } from "../src/auth/oauth.ts"; -import { getAuthProvider } from "../src/auth/providers.ts"; +import { createCallbackServer } from "../../src/auth/oauth.ts"; +import { getAuthProvider } from "../../src/auth/providers.ts"; // `createCallbackServer` backs `openwiki auth `: it captures the // OAuth redirect on a loopback HTTP server, then the server is closed while diff --git a/test/oauth-url-validation.test.ts b/test/auth/oauth-url-validation.test.ts similarity index 97% rename from test/oauth-url-validation.test.ts rename to test/auth/oauth-url-validation.test.ts index 23cf30c6..debed8d8 100644 --- a/test/oauth-url-validation.test.ts +++ b/test/auth/oauth-url-validation.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { discoverAuthorizationServerMetadata, validateOAuthEndpointUrl, -} from "../src/auth/oauth-discovery.ts"; +} from "../../src/auth/oauth-discovery.ts"; describe("validateOAuthEndpointUrl", () => { test("allows HTTPS URLs on explicitly allowed hosts", () => { diff --git a/test/commands.test.ts b/test/cli/commands.test.ts similarity index 99% rename from test/commands.test.ts rename to test/cli/commands.test.ts index c03e8d9f..def22d9c 100644 --- a/test/commands.test.ts +++ b/test/cli/commands.test.ts @@ -3,7 +3,7 @@ import { getHelpText, parseCommand, shouldRunNonInteractively, -} from "../src/commands.ts"; +} from "../../src/cli/commands.ts"; // parseCommand's --dry-run gate consults isDevelopmentMode(), which reads // NODE_ENV / OPENWIKI_DEV. Pin both to a non-development state per test and diff --git a/test/startup.test.ts b/test/cli/startup.test.ts similarity index 98% rename from test/startup.test.ts rename to test/cli/startup.test.ts index 5a1dd16a..0ccfcd4c 100644 --- a/test/startup.test.ts +++ b/test/cli/startup.test.ts @@ -4,8 +4,8 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { promisify } from "node:util"; import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { resolveStartupCommand } from "../src/startup.ts"; -import type { CliCommand } from "../src/commands.ts"; +import { resolveStartupCommand } from "../../src/cli/startup.ts"; +import type { CliCommand } from "../../src/cli/commands.ts"; import { AWS_ACCESS_KEY_ID_ENV_KEY, AWS_DEFAULT_REGION_ENV_KEY, @@ -22,7 +22,7 @@ import { OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_PROVIDER_ENV_KEY, -} from "../src/constants.ts"; +} from "../../src/config/constants.ts"; const execFileAsync = promisify(execFile); const MANAGED_ENV_KEYS = [ diff --git a/test/telemetry-cli.test.ts b/test/cli/telemetry-cli.test.ts similarity index 93% rename from test/telemetry-cli.test.ts rename to test/cli/telemetry-cli.test.ts index b8a73895..58fda5e0 100644 --- a/test/telemetry-cli.test.ts +++ b/test/cli/telemetry-cli.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { commandEmitsTelemetry, parseCommand } from "../src/commands.ts"; +import { commandEmitsTelemetry, parseCommand } from "../../src/cli/commands.ts"; describe("commandEmitsTelemetry", () => { const emits = (argv: string[]): boolean => diff --git a/test/constants.test.ts b/test/config/constants.test.ts similarity index 99% rename from test/constants.test.ts rename to test/config/constants.test.ts index a1f98a96..5b9ec51c 100644 --- a/test/constants.test.ts +++ b/test/config/constants.test.ts @@ -34,7 +34,7 @@ import { resolveProviderLocation, resolveProviderRegion, resolveProviderRetryAttempts, -} from "../src/constants.ts"; +} from "../../src/config/constants.ts"; describe("isValidModelId", () => { test("accepts normal provider/model ids", () => { diff --git a/test/copilot-provider.test.ts b/test/config/copilot-provider.test.ts similarity index 96% rename from test/copilot-provider.test.ts rename to test/config/copilot-provider.test.ts index b37a4527..8af08b28 100644 --- a/test/copilot-provider.test.ts +++ b/test/config/copilot-provider.test.ts @@ -6,7 +6,7 @@ import { providerRequiresApiKey, providerUsesExternalCliAuth, providerUsesResponsesApi, -} from "../src/constants.ts"; +} from "../../src/config/constants.ts"; describe("GitHub Copilot provider config", () => { test("uses the generic external CLI authentication strategy", () => { diff --git a/test/env-behavior.test.ts b/test/config/env-behavior.test.ts similarity index 98% rename from test/env-behavior.test.ts rename to test/config/env-behavior.test.ts index 2d190270..866e62ac 100644 --- a/test/env-behavior.test.ts +++ b/test/config/env-behavior.test.ts @@ -20,7 +20,7 @@ import { OPENROUTER_API_KEY_ENV_KEY, OPENWIKI_MODEL_ID_ENV_KEY, OPENWIKI_PROVIDER_ENV_KEY, -} from "../src/constants.ts"; +} from "../../src/config/constants.ts"; // `loadOpenWikiEnv`, `saveOpenWikiEnv`, and `getCredentialDiagnostics` all read // from / write to `~/.openwiki/.env`, and `src/env.ts` resolves that path from @@ -42,7 +42,7 @@ import { // above — the deprecation-dropping, source resolution, file permissions, and // secret masking — which previously had no coverage. -type EnvModule = typeof import("../src/env.ts"); +type EnvModule = typeof import("../../src/config/env.ts"); const KEYS_UNDER_TEST = [ ANTHROPIC_API_KEY_ENV_KEY, @@ -81,7 +81,7 @@ beforeEach(async () => { default: { ...(actual.default as typeof import("node:os")), homedir }, }; }); - env = await import("../src/env.ts"); + env = await import("../../src/config/env.ts"); for (const key of KEYS_UNDER_TEST) { delete process.env[key]; @@ -273,7 +273,7 @@ describe("saveOpenWikiEnv", () => { }); try { - const failingEnv = await import("../src/env.ts"); + const failingEnv = await import("../../src/config/env.ts"); await expect( failingEnv.saveOpenWikiEnv({ [OPENAI_API_KEY_ENV_KEY]: "sk-new" }), ).rejects.toThrow(/ENOSPC/); diff --git a/test/env.test.ts b/test/config/env.test.ts similarity index 98% rename from test/env.test.ts rename to test/config/env.test.ts index f56916b9..1f542e42 100644 --- a/test/env.test.ts +++ b/test/config/env.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { formatEnv, MANAGED_ENV_KEYS, parseEnv } from "../src/env.ts"; +import { formatEnv, MANAGED_ENV_KEYS, parseEnv } from "../../src/config/env.ts"; describe("parseEnv", () => { test("parses simple KEY=value lines", () => { diff --git a/test/openai-chatgpt-provider.test.ts b/test/config/openai-chatgpt-provider.test.ts similarity index 98% rename from test/openai-chatgpt-provider.test.ts rename to test/config/openai-chatgpt-provider.test.ts index 7a58c83c..f5168c95 100644 --- a/test/openai-chatgpt-provider.test.ts +++ b/test/config/openai-chatgpt-provider.test.ts @@ -11,7 +11,7 @@ import { OPENAI_CHATGPT_REFRESH_TOKEN_ENV_KEY, providerUsesOAuth, SELECTABLE_OPENWIKI_PROVIDERS, -} from "../src/constants.ts"; +} from "../../src/config/constants.ts"; describe("openai-chatgpt provider config", () => { test("is a valid, selectable provider", () => { diff --git a/test/connector-config-overrides.test.ts b/test/connectors/connector-config-overrides.test.ts similarity index 98% rename from test/connector-config-overrides.test.ts rename to test/connectors/connector-config-overrides.test.ts index afa34ccb..7415fc54 100644 --- a/test/connector-config-overrides.test.ts +++ b/test/connectors/connector-config-overrides.test.ts @@ -105,7 +105,8 @@ async function loadXConnector(home: string) { vi.resetModules(); setConnectorTestHome(home); clearConnectorTokens(); - const { createXConnector } = await import("../src/connectors/sources/x.ts"); + const { createXConnector } = + await import("../../src/connectors/sources/x.ts"); return createXConnector(); } @@ -114,7 +115,7 @@ async function loadSlackConnector(home: string) { setConnectorTestHome(home); clearConnectorTokens(); const { createSlackConnector } = - await import("../src/connectors/sources/slack.ts"); + await import("../../src/connectors/sources/slack.ts"); return createSlackConnector(); } @@ -123,7 +124,7 @@ async function loadGmailConnector(home: string) { setConnectorTestHome(home); clearConnectorTokens(); const { createGmailConnector } = - await import("../src/connectors/sources/gmail.ts"); + await import("../../src/connectors/sources/gmail.ts"); return createGmailConnector(); } diff --git a/test/connector-config.test.ts b/test/connectors/connector-config.test.ts similarity index 92% rename from test/connector-config.test.ts rename to test/connectors/connector-config.test.ts index 1fdacd2a..b9381a20 100644 --- a/test/connector-config.test.ts +++ b/test/connectors/connector-config.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { normalizeStringArray } from "../src/connectors/config.ts"; +import { normalizeStringArray } from "../../src/connectors/config.ts"; describe("normalizeStringArray", () => { test("keeps non-empty strings and trims each", () => { diff --git a/test/fetch-with-resilience.test.ts b/test/connectors/fetch-with-resilience.test.ts similarity index 99% rename from test/fetch-with-resilience.test.ts rename to test/connectors/fetch-with-resilience.test.ts index e98ee422..591b22f1 100644 --- a/test/fetch-with-resilience.test.ts +++ b/test/connectors/fetch-with-resilience.test.ts @@ -3,7 +3,7 @@ import { fetchWithResilience, isRetryableStatus, parseRetryAfterMs, -} from "../src/connectors/http.ts"; +} from "../../src/connectors/http.ts"; // Connectors used to call fetch directly with no timeout and no retry, so a // single 429 or transient 5xx aborted the whole run (issue #412 / connector diff --git a/test/hackernews.test.ts b/test/connectors/hackernews.test.ts similarity index 99% rename from test/hackernews.test.ts rename to test/connectors/hackernews.test.ts index 07f41ebe..dc2bd3b3 100644 --- a/test/hackernews.test.ts +++ b/test/connectors/hackernews.test.ts @@ -49,7 +49,7 @@ async function loadHackerNewsConnector(home: string) { vi.resetModules(); setConnectorTestHome(home); const { createHackerNewsConnector } = - await import("../src/connectors/sources/hackernews.ts"); + await import("../../src/connectors/sources/hackernews.ts"); return createHackerNewsConnector(); } diff --git a/test/langsmith-api.test.ts b/test/connectors/langsmith-api.test.ts similarity index 98% rename from test/langsmith-api.test.ts rename to test/connectors/langsmith-api.test.ts index 27d1d114..fe0396fc 100644 --- a/test/langsmith-api.test.ts +++ b/test/connectors/langsmith-api.test.ts @@ -28,7 +28,7 @@ vi.mock("langsmith", () => { }); const { createLangSmithApi, isRateLimitError } = - await import("../src/connectors/sources/langsmith/api.ts"); + await import("../../src/connectors/sources/langsmith/api.ts"); beforeEach(() => { sdk.runs = []; diff --git a/test/langsmith-index.test.ts b/test/connectors/langsmith-index.test.ts similarity index 94% rename from test/langsmith-index.test.ts rename to test/connectors/langsmith-index.test.ts index b2e577e2..f2700c10 100644 --- a/test/langsmith-index.test.ts +++ b/test/connectors/langsmith-index.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -vi.mock("../src/connectors/io.ts", () => ({ +vi.mock("../../src/connectors/io.ts", () => ({ createRunId: () => "run-1", readConnectorState: () => Promise.resolve({ version: 1 }), updateStateWithRun: (state: Record, entry: unknown) => ({ @@ -14,27 +14,27 @@ vi.mock("../src/connectors/io.ts", () => ({ ), })); -vi.mock("../src/connectors/sources/langsmith/api.ts", () => ({ +vi.mock("../../src/connectors/sources/langsmith/api.ts", () => ({ createLangSmithApi: vi.fn(), })); // Keep the real sanitizers (the validation under test) and mock only the reader. vi.mock( - "../src/connectors/sources/langsmith/repo-config.ts", + "../../src/connectors/sources/langsmith/repo-config.ts", async (importOriginal) => ({ ...(await importOriginal< - typeof import("../src/connectors/sources/langsmith/repo-config.ts") + typeof import("../../src/connectors/sources/langsmith/repo-config.ts") >()), readLangSmithRepoConfig: vi.fn(), }), ); -import { writeRawJson } from "../src/connectors/io.ts"; -import type { LangSmithApi } from "../src/connectors/sources/langsmith/api.ts"; -import { createLangSmithApi } from "../src/connectors/sources/langsmith/api.ts"; -import { createLangSmithConnector } from "../src/connectors/sources/langsmith/index.ts"; -import type { LangSmithRepoConfig } from "../src/connectors/sources/langsmith/repo-config.ts"; -import { readLangSmithRepoConfig } from "../src/connectors/sources/langsmith/repo-config.ts"; +import { writeRawJson } from "../../src/connectors/io.ts"; +import type { LangSmithApi } from "../../src/connectors/sources/langsmith/api.ts"; +import { createLangSmithApi } from "../../src/connectors/sources/langsmith/api.ts"; +import { createLangSmithConnector } from "../../src/connectors/sources/langsmith/index.ts"; +import type { LangSmithRepoConfig } from "../../src/connectors/sources/langsmith/repo-config.ts"; +import { readLangSmithRepoConfig } from "../../src/connectors/sources/langsmith/repo-config.ts"; import type { Run } from "langsmith"; const KEY = "OPENWIKI_LANGSMITH_API_KEY"; diff --git a/test/langsmith-repo-config.test.ts b/test/connectors/langsmith-repo-config.test.ts similarity index 99% rename from test/langsmith-repo-config.test.ts rename to test/connectors/langsmith-repo-config.test.ts index 761c2a0c..c62815a3 100644 --- a/test/langsmith-repo-config.test.ts +++ b/test/connectors/langsmith-repo-config.test.ts @@ -9,7 +9,7 @@ import { sanitizeLangSmithApiBaseUrl, sanitizeLangSmithApiKeyEnv, writeLangSmithRepoConfig, -} from "../src/connectors/sources/langsmith/repo-config.ts"; +} from "../../src/connectors/sources/langsmith/repo-config.ts"; const tempRoots: string[] = []; diff --git a/test/langsmith-runs.test.ts b/test/connectors/langsmith-runs.test.ts similarity index 98% rename from test/langsmith-runs.test.ts rename to test/connectors/langsmith-runs.test.ts index 9ddffded..d6506922 100644 --- a/test/langsmith-runs.test.ts +++ b/test/connectors/langsmith-runs.test.ts @@ -4,8 +4,8 @@ import { isErrorRun, selectSampleBuckets, summarizeSample, -} from "../src/connectors/sources/langsmith/runs.ts"; -import type { BucketedRoot } from "../src/connectors/sources/langsmith/runs.ts"; +} from "../../src/connectors/sources/langsmith/runs.ts"; +import type { BucketedRoot } from "../../src/connectors/sources/langsmith/runs.ts"; import type { Run } from "langsmith"; function run(fields: Record): Run { diff --git a/test/langsmith-setup.test.ts b/test/connectors/langsmith-setup.test.ts similarity index 96% rename from test/langsmith-setup.test.ts rename to test/connectors/langsmith-setup.test.ts index 91b909d5..b8a9a0fc 100644 --- a/test/langsmith-setup.test.ts +++ b/test/connectors/langsmith-setup.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -vi.mock("../src/connectors/sources/langsmith/repo-config.ts", () => ({ +vi.mock("../../src/connectors/sources/langsmith/repo-config.ts", () => ({ readLangSmithRepoConfig: vi.fn(), writeLangSmithRepoConfig: vi.fn(() => Promise.resolve()), })); @@ -8,12 +8,12 @@ vi.mock("../src/connectors/sources/langsmith/repo-config.ts", () => ({ import { readLangSmithRepoConfig, writeLangSmithRepoConfig, -} from "../src/connectors/sources/langsmith/repo-config.ts"; +} from "../../src/connectors/sources/langsmith/repo-config.ts"; import { loadLangSmithSetup, nextLangSmithApiKeyEnv, saveLangSmithSetup, -} from "../src/connectors/sources/langsmith/setup.ts"; +} from "../../src/connectors/sources/langsmith/setup.ts"; const REPO = "/repo"; const EU = "https://eu.api.smith.langchain.com"; diff --git a/test/mcp-client.test.ts b/test/connectors/mcp-client.test.ts similarity index 96% rename from test/mcp-client.test.ts rename to test/connectors/mcp-client.test.ts index 44cc55e5..0ddd940a 100644 --- a/test/mcp-client.test.ts +++ b/test/connectors/mcp-client.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { buildChildEnv } from "../src/connectors/mcp-client.ts"; +import { buildChildEnv } from "../../src/connectors/mcp-client.ts"; describe("buildChildEnv", () => { const SECRET_KEYS = [ diff --git a/test/raw-connector-tools.test.ts b/test/connectors/raw-connector-tools.test.ts similarity index 98% rename from test/raw-connector-tools.test.ts rename to test/connectors/raw-connector-tools.test.ts index 881f9236..90759648 100644 --- a/test/raw-connector-tools.test.ts +++ b/test/connectors/raw-connector-tools.test.ts @@ -43,7 +43,7 @@ describe("raw connector tools", () => { test("normalizes Windows raw paths before run-id parsing", async () => { const { normalizeRawRelativePath } = - await import("../src/connectors/tools.ts"); + await import("../../src/connectors/tools.ts"); expect( normalizeRawRelativePath("2026-07-20T000000Z\\nested\\new.json"), @@ -138,7 +138,7 @@ async function loadConnectorTools( process.env.HOME = home; process.env.USERPROFILE = home; const { createOpenWikiConnectorTools } = - await import("../src/connectors/tools.ts"); + await import("../../src/connectors/tools.ts"); return createOpenWikiConnectorTools(); } diff --git a/test/code-mode.test.ts b/test/ingestion/code-mode.test.ts similarity index 99% rename from test/code-mode.test.ts rename to test/ingestion/code-mode.test.ts index eead6d5a..45b77c6c 100644 --- a/test/code-mode.test.ts +++ b/test/ingestion/code-mode.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { ensureCodeModeRepoSetup } from "../src/code-mode.ts"; +import { ensureCodeModeRepoSetup } from "../../src/ingestion/code-mode.ts"; const SNIPPET_START = ""; const SNIPPET_END = ""; diff --git a/test/langsmith-modes.test.ts b/test/ingestion/langsmith-modes.test.ts similarity index 95% rename from test/langsmith-modes.test.ts rename to test/ingestion/langsmith-modes.test.ts index da8a463f..75cdfa10 100644 --- a/test/langsmith-modes.test.ts +++ b/test/ingestion/langsmith-modes.test.ts @@ -2,8 +2,8 @@ import { describe, expect, test } from "vitest"; import { CONNECTOR_IDS, createConnectorRegistry, -} from "../src/connectors/registry.ts"; -import { createConnectorSynthesisGuidance } from "../src/ingestion.ts"; +} from "../../src/connectors/registry.ts"; +import { createConnectorSynthesisGuidance } from "../../src/ingestion/ingestion.ts"; const registry = createConnectorRegistry(); diff --git a/test/mermaid-fences.test.ts b/test/mermaid/mermaid-fences.test.ts similarity index 96% rename from test/mermaid-fences.test.ts rename to test/mermaid/mermaid-fences.test.ts index e93e992a..d188a4ae 100644 --- a/test/mermaid-fences.test.ts +++ b/test/mermaid/mermaid-fences.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { extractMermaidFences } from "../src/mermaid/fences.ts"; +import { extractMermaidFences } from "../../src/mermaid/fences.ts"; const VALID_SEQUENCE = `sequenceDiagram Alice->>Bob: Hello diff --git a/test/mermaid-validate.test.ts b/test/mermaid/mermaid-validate.test.ts similarity index 99% rename from test/mermaid-validate.test.ts rename to test/mermaid/mermaid-validate.test.ts index 23364d0d..f85f40f8 100644 --- a/test/mermaid-validate.test.ts +++ b/test/mermaid/mermaid-validate.test.ts @@ -4,7 +4,7 @@ import { findInvalidMermaidFences, heuristicError, sanitizeMermaidError, -} from "../src/mermaid/validate.ts"; +} from "../../src/mermaid/validate.ts"; const VALID_SEQUENCE = `sequenceDiagram Alice->>Bob: Hello diff --git a/test/mermaid-wiki.test.ts b/test/mermaid/mermaid-wiki.test.ts similarity index 96% rename from test/mermaid-wiki.test.ts rename to test/mermaid/mermaid-wiki.test.ts index 182c34a4..ae970f0f 100644 --- a/test/mermaid-wiki.test.ts +++ b/test/mermaid/mermaid-wiki.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; -import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; -import { validateWikiMermaid } from "../src/mermaid/wiki.ts"; +import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; +import { validateWikiMermaid } from "../../src/mermaid/wiki.ts"; const VALID = [ "```mermaid", diff --git a/test/index-labels.test.ts b/test/okf/index-labels.test.ts similarity index 98% rename from test/index-labels.test.ts rename to test/okf/index-labels.test.ts index 04b972d8..9a096e5e 100644 --- a/test/index-labels.test.ts +++ b/test/okf/index-labels.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { resolveConceptTypeLabel, resolveIndexLabels, -} from "../src/okf/index-labels.ts"; +} from "../../src/okf/index-labels.ts"; const ENGLISH = { files: "Files", directories: "Directories" }; diff --git a/test/diagnostics.test.ts b/test/platform/diagnostics.test.ts similarity index 96% rename from test/diagnostics.test.ts rename to test/platform/diagnostics.test.ts index 9abf0db6..ca6acd6d 100644 --- a/test/diagnostics.test.ts +++ b/test/platform/diagnostics.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { isAuthError } from "../src/diagnostics.ts"; +import { isAuthError } from "../../src/platform/diagnostics.ts"; describe("isAuthError", () => { test("classifies 401/403 status codes (number or string) as auth errors", () => { diff --git a/test/fs-errors.test.ts b/test/platform/fs-errors.test.ts similarity index 97% rename from test/fs-errors.test.ts rename to test/platform/fs-errors.test.ts index e2b41a95..8350d1c0 100644 --- a/test/fs-errors.test.ts +++ b/test/platform/fs-errors.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest"; import { isExpectedSnapshotRaceError, isFileNotFoundError, -} from "../src/fs-errors.ts"; +} from "../../src/platform/fs-errors.ts"; function errnoError(code: string): NodeJS.ErrnoException { const error = new Error(code) as NodeJS.ErrnoException; diff --git a/test/language.test.ts b/test/platform/language.test.ts similarity index 94% rename from test/language.test.ts rename to test/platform/language.test.ts index 3964bdf0..d0218867 100644 --- a/test/language.test.ts +++ b/test/platform/language.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { resolveLanguage } from "../src/language.ts"; +import { resolveLanguage } from "../../src/platform/language.ts"; describe("resolveLanguage", () => { test("canonicalizes recognized BCP-47 codes", () => { diff --git a/test/utils.test.ts b/test/platform/utils.test.ts similarity index 95% rename from test/utils.test.ts rename to test/platform/utils.test.ts index 0492e454..7a4ea631 100644 --- a/test/utils.test.ts +++ b/test/platform/utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { stripHtmlTags } from "../src/utils.ts"; +import { stripHtmlTags } from "../../src/platform/utils.ts"; describe("stripHtmlTags", () => { test("removes a complete tag pair", () => { diff --git a/test/windows-acl.test.ts b/test/platform/windows-acl.test.ts similarity index 96% rename from test/windows-acl.test.ts rename to test/platform/windows-acl.test.ts index 854df57d..c11a5ae2 100644 --- a/test/windows-acl.test.ts +++ b/test/platform/windows-acl.test.ts @@ -17,7 +17,7 @@ vi.mock("node:child_process", () => ({ execFile: execFileMock, })); -import { restrictDirToCurrentUser } from "../src/windows-acl.ts"; +import { restrictDirToCurrentUser } from "../../src/platform/windows-acl.ts"; const realPlatform = process.platform; diff --git a/test/launchd-calendar-interval.test.ts b/test/scheduling/launchd-calendar-interval.test.ts similarity index 95% rename from test/launchd-calendar-interval.test.ts rename to test/scheduling/launchd-calendar-interval.test.ts index 7a868f79..1e65e474 100644 --- a/test/launchd-calendar-interval.test.ts +++ b/test/scheduling/launchd-calendar-interval.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseLaunchdCalendarInterval } from "../src/schedules.ts"; +import { parseLaunchdCalendarInterval } from "../../src/scheduling/schedules.ts"; // launchd's StartCalendarInterval ANDs every field it is given, whereas cron // ORs day-of-month and day-of-week when both are restricted. A cron like diff --git a/test/credentials.test.ts b/test/setup/credentials.test.ts similarity index 98% rename from test/credentials.test.ts rename to test/setup/credentials.test.ts index 212af4a1..34f1c674 100644 --- a/test/credentials.test.ts +++ b/test/setup/credentials.test.ts @@ -12,8 +12,8 @@ import { nextSetupStep, orderedSetupSteps, resolveStepStatus, -} from "../src/credentials.tsx"; -import type { OpenWikiOnboardingConfig } from "../src/onboarding.ts"; +} from "../../src/setup/credentials.tsx"; +import type { OpenWikiOnboardingConfig } from "../../src/setup/onboarding.ts"; const ENV_KEYS = [ "AWS_ACCESS_KEY_ID", diff --git a/test/onboarding.test.ts b/test/setup/onboarding.test.ts similarity index 98% rename from test/onboarding.test.ts rename to test/setup/onboarding.test.ts index ed3346e2..1b30f5a6 100644 --- a/test/onboarding.test.ts +++ b/test/setup/onboarding.test.ts @@ -15,7 +15,7 @@ async function createTempHome(): Promise { async function loadOnboardingModule(home: string) { vi.resetModules(); process.env.HOME = home; - return await import("../src/onboarding.ts"); + return await import("../../src/setup/onboarding.ts"); } afterEach(async () => { diff --git a/test/openai-chatgpt-credentials.test.ts b/test/setup/openai-chatgpt-credentials.test.ts similarity index 99% rename from test/openai-chatgpt-credentials.test.ts rename to test/setup/openai-chatgpt-credentials.test.ts index 0f0bb3d4..049fcef7 100644 --- a/test/openai-chatgpt-credentials.test.ts +++ b/test/setup/openai-chatgpt-credentials.test.ts @@ -4,7 +4,7 @@ import { getInitialStep, getNextStepAfterProvider, needsCredentialSetup, -} from "../src/credentials.tsx"; +} from "../../src/setup/credentials.tsx"; const MANAGED_KEYS = [ "OPENWIKI_PROVIDER", diff --git a/test/telemetry-install-id.test.ts b/test/telemetry/telemetry-install-id.test.ts similarity index 95% rename from test/telemetry-install-id.test.ts rename to test/telemetry/telemetry-install-id.test.ts index c9c1354f..cc1156e4 100644 --- a/test/telemetry-install-id.test.ts +++ b/test/telemetry/telemetry-install-id.test.ts @@ -10,7 +10,7 @@ const fsMock = vi.hoisted(() => ({ })); vi.mock("node:fs/promises", () => fsMock); -import { getOrCreateInstallId } from "../src/telemetry/install-id.ts"; +import { getOrCreateInstallId } from "../../src/telemetry/install-id.ts"; const UUID = /^[0-9a-f-]{36}$/i; diff --git a/test/telemetry.test.ts b/test/telemetry/telemetry.test.ts similarity index 98% rename from test/telemetry.test.ts rename to test/telemetry/telemetry.test.ts index bb8b9192..4db948b6 100644 --- a/test/telemetry.test.ts +++ b/test/telemetry/telemetry.test.ts @@ -21,10 +21,10 @@ const posthog = vi.hoisted(() => { }); vi.mock("posthog-node", () => ({ PostHog: posthog.PostHog })); -import { PROVIDER_CONFIGS } from "../src/constants.ts"; -import { getConfiguredConnectorIds } from "../src/connectors/registry.ts"; -import { capture as captureEvent } from "../src/telemetry/client.ts"; -import { DEFAULT_POSTHOG_KEY } from "../src/telemetry/config.ts"; +import { PROVIDER_CONFIGS } from "../../src/config/constants.ts"; +import { getConfiguredConnectorIds } from "../../src/connectors/registry.ts"; +import { capture as captureEvent } from "../../src/telemetry/client.ts"; +import { DEFAULT_POSTHOG_KEY } from "../../src/telemetry/config.ts"; import { classifyError, describeErrorForTelemetry, @@ -34,25 +34,25 @@ import { safeConstructorName, tagErrorStage, unwrapErrorChain, -} from "../src/telemetry/errors.ts"; +} from "../../src/telemetry/errors.ts"; import { deriveOwner, normalizeErrorDetail, -} from "../src/telemetry/taxonomy.ts"; +} from "../../src/telemetry/taxonomy.ts"; import { ciSentinelId, isCiEnvironment, isTelemetryDisabled, noticeSuppressed, -} from "../src/telemetry/gates.ts"; -import { buildRunEvent, recordRun } from "../src/telemetry/senders.ts"; -import type { RunEventContext } from "../src/telemetry/senders.ts"; +} from "../../src/telemetry/gates.ts"; +import { buildRunEvent, recordRun } from "../../src/telemetry/senders.ts"; +import type { RunEventContext } from "../../src/telemetry/senders.ts"; import type { RunTelemetry, TelemetryErrorClass, TelemetryErrorStage, TelemetryEvent, -} from "../src/telemetry/types.ts"; +} from "../../src/telemetry/types.ts"; const ENV_KEYS = [ "OPENWIKI_TELEMETRY_DISABLED", From 012a301db8a2729d6ebb4313a6f329f1b9fc1dcf Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 11:36:00 -0700 Subject: [PATCH 02/13] backfill unit tests for untested auth/connector/telemetry units --- test/auth/configure.test.ts | 137 ++++++++++++++ test/auth/ngrok.test.ts | 86 +++++++++ test/auth/tokens.test.ts | 95 ++++++++++ test/connectors/git-repo.test.ts | 222 ++++++++++++++++++++++ test/connectors/mcp-runtime.test.ts | 215 ++++++++++++++++++++++ test/connectors/mcp.test.ts | 175 ++++++++++++++++++ test/connectors/web-search.test.ts | 244 +++++++++++++++++++++++++ test/telemetry/record-run-safe.test.ts | 143 +++++++++++++++ 8 files changed, 1317 insertions(+) create mode 100644 test/auth/configure.test.ts create mode 100644 test/auth/ngrok.test.ts create mode 100644 test/auth/tokens.test.ts create mode 100644 test/connectors/git-repo.test.ts create mode 100644 test/connectors/mcp-runtime.test.ts create mode 100644 test/connectors/mcp.test.ts create mode 100644 test/connectors/web-search.test.ts create mode 100644 test/telemetry/record-run-safe.test.ts diff --git a/test/auth/configure.test.ts b/test/auth/configure.test.ts new file mode 100644 index 00000000..402fa00b --- /dev/null +++ b/test/auth/configure.test.ts @@ -0,0 +1,137 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempHomes: string[] = []; + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-auth-configure-")); + tempHomes.push(home); + return home; +} + +async function loadConfigure(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + return await import("../../src/auth/configure.ts"); +} + +async function readJson(filePath: string): Promise> { + return JSON.parse(await readFile(filePath, "utf8")) as Record< + string, + unknown + >; +} + +afterEach(async () => { + vi.resetModules(); + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +describe("configureAuthProvider", () => { + test("creates a default connector config on first run", async () => { + const home = await createTempHome(); + const { configureAuthProvider } = await loadConfigure(home); + + const result = await configureAuthProvider("notion"); + + expect(result.status).toBe("created"); + expect(result.provider).toBe("notion"); + expect(result.nextSteps[0]).toBe("Review the generated connector config."); + + const config = await readJson(result.configPath); + expect(config.enabled).toBe(true); + expect(config.transport).toMatchObject({ + type: "http", + url: "https://mcp.notion.com/mcp", + }); + }); + + test("maps gmail to the google connector and writes gmail defaults", async () => { + const home = await createTempHome(); + const { configureAuthProvider } = await loadConfigure(home); + + const result = await configureAuthProvider("gmail"); + + expect(result.status).toBe("created"); + expect(result.configPath).toContain(path.join("connectors", "google")); + const config = await readJson(result.configPath); + expect(config).toMatchObject({ provider: "gmail", query: "newer_than:1d" }); + }); + + test("preserves an existing config and reports exists without --force", async () => { + const home = await createTempHome(); + const { configureAuthProvider } = await loadConfigure(home); + + await configureAuthProvider("slack"); + const second = await configureAuthProvider("slack"); + + expect(second.status).toBe("exists"); + expect(second.nextSteps[0]).toContain("pass --force to overwrite"); + }); + + test("overwrites an existing config when --force is passed", async () => { + const home = await createTempHome(); + const { configureAuthProvider } = await loadConfigure(home); + + await configureAuthProvider("slack"); + const forced = await configureAuthProvider("slack", { force: true }); + + expect(forced.status).toBe("updated"); + expect(forced.nextSteps[0]).toBe("Review the generated connector config."); + }); +}); + +describe("shouldDiscoverToolsAfterAuth", () => { + test("is true only for MCP-backed providers", async () => { + const home = await createTempHome(); + const { shouldDiscoverToolsAfterAuth } = await loadConfigure(home); + + expect(shouldDiscoverToolsAfterAuth("notion")).toBe(true); + expect(shouldDiscoverToolsAfterAuth("slack")).toBe(false); + expect(shouldDiscoverToolsAfterAuth("gmail")).toBe(false); + }); +}); + +describe("listAuthProviderTools", () => { + test("throws with setup guidance when no config exists yet", async () => { + const home = await createTempHome(); + const { listAuthProviderTools } = await loadConfigure(home); + + await expect(listAuthProviderTools("x")).rejects.toThrow( + "Run openwiki auth x first", + ); + }); + + test("throws when the provider does not expose MCP tools", async () => { + const home = await createTempHome(); + const { configureAuthProvider, listAuthProviderTools } = + await loadConfigure(home); + + await configureAuthProvider("slack"); + + await expect(listAuthProviderTools("slack")).rejects.toThrow( + "does not expose MCP tools", + ); + }); +}); diff --git a/test/auth/ngrok.test.ts b/test/auth/ngrok.test.ts new file mode 100644 index 00000000..7c28d404 --- /dev/null +++ b/test/auth/ngrok.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, test } from "vitest"; +import { getRedirectUriFromNgrokTunnels } from "../../src/auth/ngrok.ts"; + +const PORT = 53682; + +/** + * Builds an ngrok `/api/tunnels` style payload from tunnel descriptors. + */ +function tunnels(entries: { addr?: string; public_url: string }[]): { + tunnels: unknown[]; +} { + return { + tunnels: entries.map(({ addr, public_url }) => ({ + config: addr === undefined ? {} : { addr }, + public_url, + })), + }; +} + +describe("getRedirectUriFromNgrokTunnels", () => { + test("returns null when the payload is not a tunnels object", () => { + expect(getRedirectUriFromNgrokTunnels(null, PORT)).toBeNull(); + expect(getRedirectUriFromNgrokTunnels({}, PORT)).toBeNull(); + expect( + getRedirectUriFromNgrokTunnels({ tunnels: "nope" }, PORT), + ).toBeNull(); + }); + + test("builds a callback URL from the tunnel whose addr matches the port", () => { + const payload = tunnels([ + { addr: "localhost:1111", public_url: "https://other.ngrok.app" }, + { addr: "http://localhost:53682", public_url: "https://match.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://match.ngrok.app/callback", + ); + }); + + test("matches a bare port addr and strips a trailing slash", () => { + const payload = tunnels([ + { addr: String(PORT), public_url: "https://match.ngrok.app/" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://match.ngrok.app/callback", + ); + }); + + test("falls back to the sole https tunnel when none match the port", () => { + const payload = tunnels([ + { addr: "localhost:9999", public_url: "https://only.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://only.ngrok.app/callback", + ); + }); + + test("returns null when several tunnels exist but none match the port", () => { + const payload = tunnels([ + { addr: "localhost:1111", public_url: "https://a.ngrok.app" }, + { addr: "localhost:2222", public_url: "https://b.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBeNull(); + }); + + test("ignores non-https tunnels", () => { + const payload = tunnels([ + { addr: `localhost:${PORT}`, public_url: "http://insecure.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBeNull(); + }); + + test("ignores tunnels whose public url carries a port, query, or credentials", () => { + const payload = tunnels([ + { addr: `localhost:${PORT}`, public_url: "https://a.ngrok.app:8443" }, + { addr: `localhost:${PORT}`, public_url: "https://b.ngrok.app?x=1" }, + { addr: `localhost:${PORT}`, public_url: "https://user:pw@c.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBeNull(); + }); +}); diff --git a/test/auth/tokens.test.ts b/test/auth/tokens.test.ts new file mode 100644 index 00000000..c20278c5 --- /dev/null +++ b/test/auth/tokens.test.ts @@ -0,0 +1,95 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// tokens.ts loads/saves the OpenWiki env file at the edges; both are mocked so +// the tests drive behavior purely through process.env and never touch disk. +vi.mock("../../src/config/env.ts", () => ({ + loadOpenWikiEnv: vi.fn(() => Promise.resolve()), + saveOpenWikiEnv: vi.fn(() => Promise.resolve()), +})); + +import { saveOpenWikiEnv } from "../../src/config/env.ts"; +import { getAuthProvider } from "../../src/auth/providers.ts"; +import { + getOAuthAccessToken, + getOAuthProviderIdForAccessTokenEnvKey, + isOAuthAccessTokenExpired, +} from "../../src/auth/tokens.ts"; + +const slack = getAuthProvider("slack"); +const ACCESS_KEY = slack.tokenMapping.accessTokenEnvKey; +const EXPIRES_KEY = slack.tokenMapping.expiresAtEnvKey!; +const MANAGED = [ACCESS_KEY, EXPIRES_KEY]; +const saved: Record = {}; + +beforeEach(() => { + vi.clearAllMocks(); + for (const key of MANAGED) { + saved[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + vi.unstubAllGlobals(); + for (const key of MANAGED) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } +}); + +describe("getOAuthProviderIdForAccessTokenEnvKey", () => { + test("maps a known access-token env key back to its provider", () => { + expect(getOAuthProviderIdForAccessTokenEnvKey(ACCESS_KEY)).toBe("slack"); + }); + + test("returns null for an unrecognized env key", () => { + expect( + getOAuthProviderIdForAccessTokenEnvKey("OPENWIKI_NOT_A_REAL_TOKEN"), + ).toBeNull(); + }); +}); + +describe("isOAuthAccessTokenExpired", () => { + test("treats a missing expiry as not expired", () => { + expect(isOAuthAccessTokenExpired("slack")).toBe(false); + }); + + test("treats an unparseable expiry as expired", () => { + process.env[EXPIRES_KEY] = "not-a-date"; + expect(isOAuthAccessTokenExpired("slack")).toBe(true); + }); + + test("treats a comfortably future expiry as not expired", () => { + process.env[EXPIRES_KEY] = new Date(Date.now() + 600_000).toISOString(); + expect(isOAuthAccessTokenExpired("slack")).toBe(false); + }); + + test("treats an already-past expiry as expired", () => { + process.env[EXPIRES_KEY] = new Date(Date.now() - 1_000).toISOString(); + expect(isOAuthAccessTokenExpired("slack")).toBe(true); + }); + + test("treats an expiry inside the refresh skew window as expired", () => { + // The refresh skew is 60s; 30s out is still considered expired. + process.env[EXPIRES_KEY] = new Date(Date.now() + 30_000).toISOString(); + expect(isOAuthAccessTokenExpired("slack")).toBe(true); + }); +}); + +describe("getOAuthAccessToken", () => { + test("returns the cached token without refreshing when it is still valid", async () => { + process.env[ACCESS_KEY] = "cached-token"; + process.env[EXPIRES_KEY] = new Date(Date.now() + 600_000).toISOString(); + const fetchMock = vi.fn(() => { + throw new Error("fetch should not be called for a valid cached token"); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(getOAuthAccessToken("slack")).resolves.toBe("cached-token"); + expect(fetchMock).not.toHaveBeenCalled(); + expect(saveOpenWikiEnv).not.toHaveBeenCalled(); + }); +}); diff --git a/test/connectors/git-repo.test.ts b/test/connectors/git-repo.test.ts new file mode 100644 index 00000000..6a77e337 --- /dev/null +++ b/test/connectors/git-repo.test.ts @@ -0,0 +1,222 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +const execFileAsync = promisify(execFile); + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempDirs: string[] = []; + +type GitRepoManifest = { + branch: string; + changedFiles: string[]; + head: string; + id: string; + path: string; + recentCommits: string[]; + status: string; +}; + +type ManifestDump = { + generatedAt: string; + repos: GitRepoManifest[]; +}; + +async function createTempDir(prefix: string): Promise { + const dir = await mkdtemp(path.join(tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function runGit(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trim(); +} + +/** + * Creates a real git repo on `main` with a single committed file so the + * connector has a genuine history, branch, and HEAD to read. + */ +async function initRepo(name: string): Promise { + const repoPath = await createTempDir(`openwiki-git-src-${name}-`); + await runGit(repoPath, ["-c", "init.defaultBranch=main", "init"]); + await writeFile(path.join(repoPath, "README.md"), "hello\n", "utf8"); + await runGit(repoPath, ["add", "README.md"]); + await runGit(repoPath, [ + "-c", + "user.email=test@openwiki.dev", + "-c", + "user.name=OpenWiki Test", + "-c", + "commit.gpgsign=false", + "commit", + "-m", + "initial commit", + ]); + return repoPath; +} + +async function writeGitRepoConfig( + home: string, + config: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", "git-repo"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); +} + +async function loadGitRepoConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const { createGitRepoConnector } = + await import("../../src/connectors/sources/git-repo.ts"); + return createGitRepoConnector(); +} + +async function readManifest(rawFile: string): Promise { + return JSON.parse(await readFile(rawFile, "utf8")) as ManifestDump; +} + +afterEach(async () => { + vi.resetModules(); + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { force: true, recursive: true })), + ); +}); + +describe("git-repo connector configuration", () => { + test("skips with guidance when no repositories are configured", async () => { + const home = await createTempDir("openwiki-git-home-"); + const connector = await loadGitRepoConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.message).toContain( + "~/.openwiki/connectors/git-repo/config.json", + ); + expect(result.warnings).toEqual([]); + }); + + test("warns and skips a repo whose id is unsafe", async () => { + const home = await createTempDir("openwiki-git-home-"); + const repoPath = await initRepo("unsafe"); + await writeGitRepoConfig(home, { + repos: [{ id: "../escape", path: repoPath }], + }); + const connector = await loadGitRepoConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.warnings).toEqual(["Skipped repo with unsafe id: ../escape"]); + + const dump = await readManifest(result.rawFiles[0] ?? ""); + expect(dump.repos).toEqual([]); + }); +}); + +describe("git-repo connector manifest building", () => { + test("captures branch, head, recent commits, and clean status", async () => { + const home = await createTempDir("openwiki-git-home-"); + const repoPath = await initRepo("clean"); + await writeGitRepoConfig(home, { + repos: [{ id: "clean-repo", path: repoPath }], + }); + const connector = await loadGitRepoConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(result.warnings).toEqual([]); + + const dump = await readManifest(result.rawFiles[0] ?? ""); + expect(dump.repos).toHaveLength(1); + const manifest = dump.repos[0]; + expect(manifest.id).toBe("clean-repo"); + expect(manifest.path).toBe(path.resolve(repoPath)); + expect(manifest.branch).toBe("main"); + expect(manifest.head).toBe(await runGit(repoPath, ["rev-parse", "HEAD"])); + expect(manifest.head).toMatch(/^[0-9a-f]{40}$/u); + expect(manifest.recentCommits.join("\n")).toContain("initial commit"); + // A committed, otherwise-untouched repo has an empty working tree. + expect(manifest.status).toBe(""); + expect(manifest.changedFiles).toEqual([]); + }); + + test("reports uncommitted working-tree changes", async () => { + const home = await createTempDir("openwiki-git-home-"); + const repoPath = await initRepo("dirty"); + await writeFile(path.join(repoPath, "README.md"), "hello again\n", "utf8"); + await writeGitRepoConfig(home, { + repos: [{ id: "dirty-repo", path: repoPath }], + }); + const connector = await loadGitRepoConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const dump = await readManifest(result.rawFiles[0] ?? ""); + const manifest = dump.repos[0]; + expect(manifest.status).toContain("README.md"); + expect(manifest.changedFiles).toEqual(["M\tREADME.md"]); + }); + + test("records a per-repo warning when the path is not a git repo", async () => { + const home = await createTempDir("openwiki-git-home-"); + const notARepo = await createTempDir("openwiki-git-plain-"); + await writeGitRepoConfig(home, { + repos: [{ id: "missing", path: notARepo }], + }); + const connector = await loadGitRepoConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.warnings).toHaveLength(1); + expect(result.warnings[0]).toContain("missing: "); + + const dump = await readManifest(result.rawFiles[0] ?? ""); + expect(dump.repos).toEqual([]); + }); + + test("honors the limit option and ingests only the first repos", async () => { + const home = await createTempDir("openwiki-git-home-"); + const first = await initRepo("first"); + const second = await initRepo("second"); + await writeGitRepoConfig(home, { + repos: [ + { id: "first-repo", path: first }, + { id: "second-repo", path: second }, + ], + }); + const connector = await loadGitRepoConnector(home); + + const result = await connector.ingest({ limit: 1 }); + + expect(result.status).toBe("success"); + const dump = await readManifest(result.rawFiles[0] ?? ""); + expect(dump.repos.map((repo) => repo.id)).toEqual(["first-repo"]); + }); +}); diff --git a/test/connectors/mcp-runtime.test.ts b/test/connectors/mcp-runtime.test.ts new file mode 100644 index 00000000..4039c493 --- /dev/null +++ b/test/connectors/mcp-runtime.test.ts @@ -0,0 +1,215 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// mcp-runtime brokers between the io layer and the MCP client and enforces the +// read-only tool-call policy. io and the client are mocked; the diagnostics +// redaction (isSecretLikeKey) is left real because it is part of what we assert. +vi.mock("../../src/connectors/io.ts", () => ({ + createRunId: () => "run-1", + readConnectorConfig: vi.fn(), + readConnectorState: () => Promise.resolve({ version: 1 }), + updateStateWithRun: (state: Record, entry: unknown) => ({ + ...state, + runs: [entry], + version: 1, + }), + writeConnectorState: vi.fn(() => Promise.resolve()), + writeRawJson: vi.fn(() => Promise.resolve("/raw/notion/run-1/output.json")), +})); + +vi.mock("../../src/connectors/mcp-client.ts", () => ({ + executeMcpTool: vi.fn(), + listMcpTools: vi.fn(), +})); + +import { readConnectorConfig, writeRawJson } from "../../src/connectors/io.ts"; +import type { McpToolDescriptor } from "../../src/connectors/mcp-client.ts"; +import { + executeMcpTool, + listMcpTools, +} from "../../src/connectors/mcp-client.ts"; +import { + callMcpConnectorTool, + discoverMcpConnectorTools, + isMcpConnectorId, + sanitizeMcpTransport, +} from "../../src/connectors/mcp-runtime.ts"; +import type { McpConnectorConfig } from "../../src/connectors/types.ts"; + +const HOSTED_NOTION_TRANSPORT = { + type: "http", + url: "https://mcp.notion.com/mcp", +} as McpConnectorConfig["transport"]; + +/** + * Makes the (mocked) config reader return an enabled Notion MCP config. + */ +function configureNotion(overrides: Partial = {}): void { + vi.mocked(readConnectorConfig).mockResolvedValue({ + enabled: true, + readOnlyOperations: [], + transport: HOSTED_NOTION_TRANSPORT, + ...overrides, + }); +} + +/** + * Makes tools/list return exactly the given descriptors. + */ +function withTools(...tools: McpToolDescriptor[]): void { + vi.mocked(listMcpTools).mockResolvedValue({ tools } as never); +} + +function tool(descriptor: Partial): McpToolDescriptor { + return descriptor as McpToolDescriptor; +} + +function writtenPayload(): Record { + return vi.mocked(writeRawJson).mock.calls[0]?.[3] as Record; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("isMcpConnectorId", () => { + test("recognizes notion and rejects other connectors", () => { + expect(isMcpConnectorId("notion")).toBe(true); + expect(isMcpConnectorId("git-repo")).toBe(false); + }); +}); + +describe("sanitizeMcpTransport", () => { + test("returns null for an absent transport", () => { + expect(sanitizeMcpTransport(undefined)).toBeNull(); + }); + + test("redacts env references in headers and preserves other fields", () => { + const sanitized = sanitizeMcpTransport({ + args: ["--flag"], + command: "notion-mcp", + headers: { + Authorization: "Bearer ${NOTION_TOKEN}", + "X-Api": "$API_KEY", + "X-Plain": "static-value", + }, + type: "http", + url: "https://mcp.notion.com/mcp", + }); + + expect(sanitized).toMatchObject({ + args: ["--flag"], + command: "notion-mcp", + headers: { + Authorization: "Bearer ", + "X-Api": "", + "X-Plain": "static-value", + }, + type: "http", + url: "https://mcp.notion.com/mcp", + }); + }); +}); + +describe("callMcpConnectorTool policy", () => { + test("throws when the requested tool was not discovered", async () => { + configureNotion(); + withTools(tool({ name: "search" })); + + await expect(callMcpConnectorTool("notion", "missing", {})).rejects.toThrow( + "was not returned by tools/list", + ); + expect(executeMcpTool).not.toHaveBeenCalled(); + }); + + test("allows a tool flagged read-only by annotation", async () => { + configureNotion(); + withTools(tool({ annotations: { readOnlyHint: true }, name: "do_thing" })); + vi.mocked(executeMcpTool).mockResolvedValue({ ok: true }); + + const result = await callMcpConnectorTool("notion", "do_thing", { q: "x" }); + + expect(result.allowedBy).toBe("allowed by MCP readOnlyHint annotation"); + expect(result.result).toEqual({ ok: true }); + expect(executeMcpTool).toHaveBeenCalledWith(expect.anything(), "do_thing", { + q: "x", + }); + expect(vi.mocked(writeRawJson).mock.calls[0]?.[2]).toBe( + "mcp-tool-result.json", + ); + }); + + test("allows a tool explicitly listed in allowedTools", async () => { + configureNotion({ allowedTools: ["custom_tool"] }); + withTools(tool({ name: "custom_tool" })); + vi.mocked(executeMcpTool).mockResolvedValue("ok"); + + const result = await callMcpConnectorTool("notion", "custom_tool", {}); + + expect(result.allowedBy).toBe("allowed by connector config allowedTools"); + }); + + test("allows a read-only-looking hosted Notion tool", async () => { + configureNotion(); + withTools(tool({ description: "Search pages", name: "search_pages" })); + vi.mocked(executeMcpTool).mockResolvedValue([] as never); + + const result = await callMcpConnectorTool("notion", "search_pages", {}); + + expect(result.allowedBy).toBe( + "allowed by hosted Notion read-only tool name/description", + ); + }); + + test("rejects a mutating tool that is not marked read-only", async () => { + configureNotion(); + withTools(tool({ description: "Create a page", name: "create_page" })); + + await expect( + callMcpConnectorTool("notion", "create_page", {}), + ).rejects.toThrow("is not marked read-only"); + expect(executeMcpTool).not.toHaveBeenCalled(); + }); + + test("redacts secret-like argument keys in the written raw file", async () => { + configureNotion(); + withTools(tool({ annotations: { readOnlyHint: true }, name: "read_db" })); + vi.mocked(executeMcpTool).mockResolvedValue({}); + + await callMcpConnectorTool("notion", "read_db", { + query: "select", + token: "super-secret", + }); + + const args = writtenPayload().args as Record; + expect(args.token).toBe(""); + expect(args.query).toBe("select"); + }); +}); + +describe("discoverMcpConnectorTools", () => { + test("lists tools, writes a sanitized discovery file, and returns them", async () => { + configureNotion({ + transport: { + headers: { Authorization: "Bearer ${NOTION_TOKEN}" }, + type: "http", + url: "https://mcp.notion.com/mcp", + }, + }); + withTools(tool({ name: "search" }), tool({ name: "fetch" })); + + const result = await discoverMcpConnectorTools("notion"); + + expect(result.tools.map((entry) => entry.name)).toEqual([ + "search", + "fetch", + ]); + expect(result.rawFile).toBe("/raw/notion/run-1/output.json"); + expect(result.runId).toBe("run-1"); + expect(vi.mocked(writeRawJson).mock.calls[0]?.[2]).toBe("mcp-tools.json"); + + const transport = writtenPayload().transport as { + headers: Record; + }; + expect(transport.headers.Authorization).toBe("Bearer "); + }); +}); diff --git a/test/connectors/mcp.test.ts b/test/connectors/mcp.test.ts new file mode 100644 index 00000000..9f0bc48e --- /dev/null +++ b/test/connectors/mcp.test.ts @@ -0,0 +1,175 @@ +import { beforeEach, describe, expect, test, vi } from "vitest"; + +// The MCP source is a config-driven branch selector over three collaborators: +// the io layer, the MCP client (tool listing / execution), and the transport +// sanitizer. All three are mocked so we assert only the branching and the +// shape of what gets written. +vi.mock("../../src/connectors/io.ts", () => ({ + createRunId: () => "run-1", + readConnectorConfig: vi.fn(), + readConnectorState: () => Promise.resolve({ version: 1 }), + updateStateWithRun: (state: Record, entry: unknown) => ({ + ...state, + runs: [entry], + version: 1, + }), + writeConnectorState: vi.fn(() => Promise.resolve()), + writeRawJson: vi.fn(() => Promise.resolve("/raw/mcp/run-1/output.json")), +})); + +vi.mock("../../src/connectors/mcp-client.ts", () => ({ + executeMcpReadOnlyOperations: vi.fn(), + listMcpTools: vi.fn(), +})); + +vi.mock("../../src/connectors/mcp-runtime.ts", () => ({ + sanitizeMcpTransport: vi.fn(() => ({ redacted: true })), +})); + +import { + readConnectorConfig, + writeConnectorState, + writeRawJson, +} from "../../src/connectors/io.ts"; +import { + executeMcpReadOnlyOperations, + listMcpTools, +} from "../../src/connectors/mcp-client.ts"; +import { sanitizeMcpTransport } from "../../src/connectors/mcp-runtime.ts"; +import { createMcpConnector } from "../../src/connectors/sources/mcp.ts"; +import type { McpConnectorConfig } from "../../src/connectors/types.ts"; + +const INPUT = { + description: "Notion MCP", + displayName: "Notion", + id: "notion", + requiredEnv: [], +} as unknown as Parameters[0]; + +const TRANSPORT = { command: "notion-mcp", type: "stdio" }; + +/** + * Makes the (mocked) config reader return a specific MCP config for one ingest. + */ +function configure(config: Partial): void { + vi.mocked(readConnectorConfig).mockResolvedValue(config); +} + +/** + * Returns the value object handed to the (mocked) writeRawJson. + */ +function writtenPayload(): Record { + const call = vi.mocked(writeRawJson).mock.calls[0]; + return call?.[3] as Record; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("mcp connector gating", () => { + test("skips when the connector is not enabled", async () => { + configure({ enabled: false, readOnlyOperations: [] }); + const connector = createMcpConnector(INPUT); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.message).toContain("is not enabled"); + expect(result.rawFiles).toEqual([]); + expect(writeRawJson).not.toHaveBeenCalled(); + expect(writeConnectorState).not.toHaveBeenCalled(); + }); + + test("errors when the transport is missing", async () => { + configure({ enabled: true, readOnlyOperations: [] }); + const connector = createMcpConnector(INPUT); + + const result = await connector.ingest(); + + expect(result.status).toBe("error"); + expect(result.message).toBe( + "MCP config must include transport and readOnlyOperations.", + ); + expect(result.warnings).toEqual([ + "MCP config must include transport and readOnlyOperations.", + ]); + // Errors still persist a run to state so the failure is observable. + expect(writeConnectorState).toHaveBeenCalledTimes(1); + expect(listMcpTools).not.toHaveBeenCalled(); + }); + + test("errors when readOnlyOperations is not an array", async () => { + configure({ + enabled: true, + readOnlyOperations: undefined, + transport: TRANSPORT as never, + }); + const connector = createMcpConnector(INPUT); + + const result = await connector.ingest(); + + expect(result.status).toBe("error"); + expect(result.message).toBe( + "MCP config must include transport and readOnlyOperations.", + ); + }); +}); + +describe("mcp connector tool discovery", () => { + test("lists tools and skips when no read-only operations are configured", async () => { + configure({ + enabled: true, + readOnlyOperations: [], + transport: TRANSPORT as never, + }); + vi.mocked(listMcpTools).mockResolvedValue({ + tools: [{ name: "search" }, { name: "read" }], + } as never); + const connector = createMcpConnector(INPUT); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.message).toContain("Discovered 2 MCP tool(s)"); + expect(result.warnings).toEqual([ + "No readOnlyOperations configured; listed available MCP tools instead of guessing.", + ]); + expect(executeMcpReadOnlyOperations).not.toHaveBeenCalled(); + + expect(vi.mocked(writeRawJson).mock.calls[0]?.[2]).toBe("mcp-tools.json"); + const payload = writtenPayload(); + expect(payload.tools).toEqual([{ name: "search" }, { name: "read" }]); + // The transport is redacted through the sanitizer before it is written. + expect(sanitizeMcpTransport).toHaveBeenCalledWith(TRANSPORT); + expect(payload.transport).toEqual({ redacted: true }); + }); +}); + +describe("mcp connector read-only execution", () => { + test("executes configured read-only operations and succeeds", async () => { + const operations = [{ args: { q: "x" }, name: "search", type: "tool" }]; + configure({ + enabled: true, + readOnlyOperations: operations as never, + transport: TRANSPORT as never, + }); + vi.mocked(executeMcpReadOnlyOperations).mockResolvedValue({ + operations: [{ name: "search", result: "ok" }], + } as never); + const connector = createMcpConnector(INPUT); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(result.message).toBe("Executed 1 read-only MCP operation(s)."); + expect(listMcpTools).not.toHaveBeenCalled(); + expect(executeMcpReadOnlyOperations).toHaveBeenCalledTimes(1); + + expect(vi.mocked(writeRawJson).mock.calls[0]?.[2]).toBe("mcp-results.json"); + const payload = writtenPayload(); + expect(payload.operations).toEqual([{ name: "search", result: "ok" }]); + expect(payload.transport).toEqual({ redacted: true }); + expect(writeConnectorState).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/connectors/web-search.test.ts b/test/connectors/web-search.test.ts new file mode 100644 index 00000000..ab255954 --- /dev/null +++ b/test/connectors/web-search.test.ts @@ -0,0 +1,244 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { OPENWIKI_TAVILY_API_KEY_ENV_KEY } from "../../src/config/constants.ts"; + +// Tavily is the one network boundary; the constructor options and per-query +// invoke calls are captured so we can assert query building without hitting the +// network. The io layer stays real (temp HOME) so we read back the raw dump. +const tavily = vi.hoisted(() => { + const invoke = vi.fn(() => + Promise.resolve({ answer: "an answer", results: [{ title: "hit" }] }), + ); + const constructed: Record[] = []; + const TavilySearch = vi.fn(function ( + this: Record, + options: Record, + ) { + constructed.push(options); + this.invoke = invoke; + }); + return { TavilySearch, constructed, invoke }; +}); +vi.mock("@langchain/tavily", () => ({ TavilySearch: tavily.TavilySearch })); + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const originalApiKey = process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY]; +const tempHomes: string[] = []; + +type WebSearchDump = { + maxResults: number; + queryCount: number; + results: { query: string; response: unknown }[]; + searchDepth: string; + timeRange?: string; + topic: string; +}; + +type ConnectorStateDump = { + runs: { rawFiles: string[]; runId: string; status: string }[]; +}; + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-web-search-")); + tempHomes.push(home); + return home; +} + +async function writeWebSearchConfig( + home: string, + config: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", "web-search"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); +} + +async function loadWebSearchConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const { createWebSearchConnector } = + await import("../../src/connectors/sources/web-search.ts"); + return createWebSearchConnector(); +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +beforeEach(() => { + tavily.constructed.splice(0); + tavily.invoke.mockClear(); + tavily.invoke.mockResolvedValue({ + answer: "an answer", + results: [{ title: "hit" }], + }); +}); + +afterEach(async () => { + vi.resetModules(); + restoreEnv("HOME", originalHome); + restoreEnv("USERPROFILE", originalUserProfile); + restoreEnv(OPENWIKI_TAVILY_API_KEY_ENV_KEY, originalApiKey); + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +describe("web-search connector gating", () => { + test("skips when the connector is disabled", async () => { + const home = await createTempHome(); + await writeWebSearchConfig(home, { enabled: false, queries: ["x"] }); + process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY] = "tvly-key"; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.message).toContain("not enabled"); + expect(tavily.TavilySearch).not.toHaveBeenCalled(); + }); + + test("errors when the Tavily API key is missing", async () => { + const home = await createTempHome(); + await writeWebSearchConfig(home, { enabled: true, queries: ["x"] }); + delete process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY]; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("error"); + expect(result.message).toContain(OPENWIKI_TAVILY_API_KEY_ENV_KEY); + expect(tavily.TavilySearch).not.toHaveBeenCalled(); + }); + + test("skips when no queries are configured", async () => { + const home = await createTempHome(); + await writeWebSearchConfig(home, { enabled: true, queries: [] }); + process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY] = "tvly-key"; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.message).toContain("No web search queries"); + expect(tavily.TavilySearch).not.toHaveBeenCalled(); + }); +}); + +describe("web-search connector query execution", () => { + test("builds the Tavily tool from config and invokes it per query", async () => { + const home = await createTempHome(); + await writeWebSearchConfig(home, { + enabled: true, + maxResults: 3, + queries: ["openwiki", "langchain"], + searchDepth: "advanced", + topic: "news", + }); + process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY] = "tvly-key"; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(result.message).toBe( + "Fetched Tavily results for 2 web search queries.", + ); + + expect(tavily.TavilySearch).toHaveBeenCalledTimes(1); + expect(tavily.constructed[0]).toMatchObject({ + includeAnswer: true, + maxResults: 3, + searchDepth: "advanced", + tavilyApiKey: "tvly-key", + topic: "news", + }); + expect(tavily.invoke.mock.calls).toEqual([ + [{ query: "openwiki" }], + [{ query: "langchain" }], + ]); + + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as WebSearchDump; + expect(dump.queryCount).toBe(2); + expect(dump.maxResults).toBe(3); + expect(dump.searchDepth).toBe("advanced"); + expect(dump.topic).toBe("news"); + expect(dump.results.map((entry) => entry.query)).toEqual([ + "openwiki", + "langchain", + ]); + expect(dump.results[0]?.response).toEqual({ + answer: "an answer", + results: [{ title: "hit" }], + }); + + const statePath = path.join( + home, + ".openwiki", + "connectors", + "web-search", + "state.json", + ); + const state = JSON.parse( + await readFile(statePath, "utf8"), + ) as ConnectorStateDump; + expect(state.runs[0]).toMatchObject({ + runId: result.runId, + status: "success", + }); + }); + + test("clamps the result limit and prefers the option limit over config", async () => { + const home = await createTempHome(); + await writeWebSearchConfig(home, { + enabled: true, + maxResults: 50, + queries: ["openwiki"], + }); + process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY] = "tvly-key"; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest({ limit: 100 }); + + expect(result.status).toBe("success"); + expect(result.message).toBe( + "Fetched Tavily results for 1 web search query.", + ); + // getOptionLimit clamps to a maximum of 20. + expect(tavily.constructed[0]?.maxResults).toBe(20); + }); + + test("derives a day time range from a short window when none is configured", async () => { + const home = await createTempHome(); + await writeWebSearchConfig(home, { enabled: true, queries: ["openwiki"] }); + process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY] = "tvly-key"; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest({ windowHours: 6 }); + + expect(result.status).toBe("success"); + expect(tavily.constructed[0]?.timeRange).toBe("day"); + + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as WebSearchDump; + expect(dump.timeRange).toBe("day"); + }); +}); diff --git a/test/telemetry/record-run-safe.test.ts b/test/telemetry/record-run-safe.test.ts new file mode 100644 index 00000000..eda8da6d --- /dev/null +++ b/test/telemetry/record-run-safe.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// recordRunSafe is a pure branching bridge: it never touches the network or +// filesystem itself, so both boundaries are mocked and we assert on the single +// event object handed to recordRun. +vi.mock("../../src/telemetry/senders.ts", () => ({ + recordRun: vi.fn(() => Promise.resolve()), +})); + +vi.mock("../../src/connectors/registry.ts", () => ({ + getConfiguredConnectorIds: vi.fn(() => ["git-repo", "web-search"]), +})); + +import type { OpenWikiRunOptions } from "../../src/agent/types.ts"; +import { getConfiguredConnectorIds } from "../../src/connectors/registry.ts"; +import { recordRun } from "../../src/telemetry/senders.ts"; +import { recordRunSafe } from "../../src/telemetry/record-run-safe.ts"; + +type RunFacts = Parameters[2]; +type RecordedEvent = Parameters[0]; + +/** + * Builds run options with only the fields recordRunSafe reads, cast to the full + * options shape the rest of which it never touches. + */ +function runOptions( + overrides: Partial = {}, +): OpenWikiRunOptions { + return overrides; +} + +/** + * Returns the single event object passed to the (mocked) recordRun. + */ +function recordedEvent(): RecordedEvent { + const calls = vi.mocked(recordRun).mock.calls; + expect(calls).toHaveLength(1); + return calls[0]?.[0]; +} + +const SUCCESS: RunFacts = { outcome: "success", provider: "anthropic" }; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("recordRunSafe command gating", () => { + test("does not record chat runs", async () => { + await recordRunSafe("chat", runOptions(), SUCCESS); + + expect(recordRun).not.toHaveBeenCalled(); + expect(getConfiguredConnectorIds).not.toHaveBeenCalled(); + }); + + test("records init runs", async () => { + await recordRunSafe("init", runOptions(), SUCCESS); + + expect(recordRun).toHaveBeenCalledTimes(1); + }); + + test("records update runs", async () => { + await recordRunSafe("update", runOptions(), SUCCESS); + + expect(recordRun).toHaveBeenCalledTimes(1); + }); +}); + +describe("recordRunSafe init setup fields", () => { + test("attaches mode, provider, and configured connectors on init", async () => { + await recordRunSafe( + "init", + runOptions({ outputMode: "repository", telemetryFile: "/tmp/tee.json" }), + { outcome: "success", provider: "openai" }, + ); + + expect(recordedEvent()).toEqual({ + command: "init", + configuredConnectors: ["git-repo", "web-search"], + errorClass: undefined, + mode: "code", + outcome: "success", + provider: "openai", + telemetryFile: "/tmp/tee.json", + }); + expect(getConfiguredConnectorIds).toHaveBeenCalledTimes(1); + }); + + test("maps a non-repository output mode to the personal brain mode", async () => { + await recordRunSafe( + "init", + runOptions({ outputMode: "local-wiki" }), + SUCCESS, + ); + + expect(recordedEvent().mode).toBe("personal"); + }); + + test("defaults the output mode to local-wiki (personal) when unset", async () => { + await recordRunSafe("init", runOptions(), SUCCESS); + + expect(recordedEvent().mode).toBe("personal"); + }); + + test("falls back to an unknown provider when resolution never produced one", async () => { + await recordRunSafe("init", runOptions(), { outcome: "failure" }); + + expect(recordedEvent().provider).toBe("unknown"); + }); + + test("forwards the run outcome and error class", async () => { + await recordRunSafe("init", runOptions(), { + errorClass: "agent_error", + outcome: "failure", + provider: "anthropic", + }); + + const event = recordedEvent(); + expect(event.outcome).toBe("failure"); + expect(event.errorClass).toBe("agent_error"); + }); +}); + +describe("recordRunSafe update runs omit setup fields", () => { + test("records only lifecycle fields and never reads configured connectors", async () => { + await recordRunSafe( + "update", + runOptions({ outputMode: "repository", telemetryFile: "/tmp/up.json" }), + { outcome: "success", provider: "openai" }, + ); + + expect(recordedEvent()).toEqual({ + command: "update", + errorClass: undefined, + outcome: "success", + telemetryFile: "/tmp/up.json", + }); + expect(getConfiguredConnectorIds).not.toHaveBeenCalled(); + }); +}); From 3fbb1c23f7d3358ed51bba99a87bc2da170c9844 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 15:00:44 -0700 Subject: [PATCH 03/13] fix --- .github/workflows/checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 609026f5..0f2a91df 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -146,7 +146,7 @@ jobs: run: pnpm install --frozen-lockfile - name: Run Windows portability tests - run: pnpm exec vitest run test/skills.test.ts test/env-behavior.test.ts + run: pnpm exec vitest run test/agent/skills.test.ts test/config/env-behavior.test.ts audit: name: Audit From ad366c6c581cf6427e0525394f94338bb386b88c Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 16:01:00 -0700 Subject: [PATCH 04/13] increase test coverage --- test/agent/utils.test.ts | 246 +++++++++ test/auth/ngrok.test.ts | 135 ++++- test/auth/oauth.test.ts | 247 +++++++++ test/auth/tokens.test.ts | 533 ++++++++++++++++-- test/cli/commands.test.ts | 387 +++++++++++++ test/connectors/mcp-client.test.ts | 197 ++++++- test/connectors/sources/gmail.test.ts | 352 ++++++++++++ test/connectors/sources/slack.test.ts | 568 ++++++++++++++++++++ test/connectors/sources/x.test.ts | 338 ++++++++++++ test/connectors/tools.test.ts | 465 ++++++++++++++++ test/ingestion/code-mode.test.ts | 64 ++- test/ingestion/ingestion.test.ts | 110 ++++ test/scheduling/schedule-operations.test.ts | 397 ++++++++++++++ test/scheduling/schedules.test.ts | 116 ++++ test/setup/onboarding.test.ts | 173 +++++- vitest.config.ts | 22 + 16 files changed, 4293 insertions(+), 57 deletions(-) create mode 100644 test/agent/utils.test.ts create mode 100644 test/auth/oauth.test.ts create mode 100644 test/connectors/sources/gmail.test.ts create mode 100644 test/connectors/sources/slack.test.ts create mode 100644 test/connectors/sources/x.test.ts create mode 100644 test/connectors/tools.test.ts create mode 100644 test/ingestion/ingestion.test.ts create mode 100644 test/scheduling/schedule-operations.test.ts create mode 100644 test/scheduling/schedules.test.ts create mode 100644 vitest.config.ts diff --git a/test/agent/utils.test.ts b/test/agent/utils.test.ts new file mode 100644 index 00000000..4be91ddb --- /dev/null +++ b/test/agent/utils.test.ts @@ -0,0 +1,246 @@ +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { describe, expect, test } from "vitest"; +import { + createOpenWikiContentSnapshot, + createRunContext, + getUpdateNoopStatus, + removeTemporaryPlanFile, +} from "../../src/agent/utils.ts"; + +// These cover the branches of utils.ts that the sibling run-context, +// run-metadata, and update-noop suites do not reach: the repository-mode git +// evidence block, the local-wiki summary text, the degenerate no-op paths, the +// snapshot recursion, and the unexpected-error path of plan-file removal. + +const execFileAsync = promisify(execFile); + +async function git(cwd: string, args: string[]): Promise { + const { stdout } = await execFileAsync("git", args, { cwd }); + return stdout.trim(); +} + +/** + * Creates a temp git repo with one commit so createGitSummary has real + * `git status`/`git log`/`git diff` output to format. + */ +async function createGitRepo(): Promise { + const repo = await mkdtemp(path.join(tmpdir(), "openwiki-utils-")); + await git(repo, ["init"]); + await git(repo, ["config", "user.email", "test@example.com"]); + await git(repo, ["config", "user.name", "OpenWiki Test"]); + await writeFile(path.join(repo, "README.md"), "# Test Repo\n", "utf8"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "initial"]); + return repo; +} + +async function writeMetadata( + repo: string, + metadata: Record, +): Promise { + await mkdir(path.join(repo, "openwiki"), { recursive: true }); + await writeFile( + path.join(repo, "openwiki", ".last-update.json"), + `${JSON.stringify(metadata)}\n`, + "utf8", + ); +} + +describe("createRunContext git summary", () => { + test("init in a repository embeds the standard git evidence sections", async () => { + const repo = await createGitRepo(); + + try { + const context = await createRunContext("init", repo, "repository"); + + // The prompt relies on these labeled sections to reason about the repo, + // so their presence is the observable contract of createGitSummary. + expect(context.gitSummary).toContain("$ git status --short"); + expect(context.gitSummary).toContain("$ git rev-parse HEAD"); + expect(context.gitSummary).toContain( + "$ git log --max-count=20 --name-status --oneline", + ); + expect(context.gitSummary).toContain("$ git diff --name-status HEAD"); + // An init run has no prior timestamp, but the "No prior" note is reserved + // for update runs and must not appear here. + expect(context.gitSummary).not.toContain( + "No prior OpenWiki update timestamp", + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + test("update without prior metadata falls back to the recent log with a note", async () => { + const repo = await createGitRepo(); + + try { + const context = await createRunContext("update", repo, "repository"); + + expect(context.gitSummary).toContain( + "No prior OpenWiki update timestamp was found.", + ); + expect(context.gitSummary).toContain( + "$ git log --max-count=20 --name-status --oneline", + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + test("update diffs since the recorded git head when one exists", async () => { + const repo = await createGitRepo(); + + try { + const firstHead = await git(repo, ["rev-parse", "HEAD"]); + await writeFile(path.join(repo, "README.md"), "# Changed\n", "utf8"); + await git(repo, ["add", "."]); + await git(repo, ["commit", "-m", "second"]); + await writeMetadata(repo, { + updatedAt: new Date().toISOString(), + command: "update", + gitHead: firstHead, + model: "test-model", + }); + + const context = await createRunContext("update", repo, "repository"); + + // A recorded head drives a precise range diff rather than the timestamp + // fallback or the recent-log fallback. + expect(context.gitSummary).toContain( + `$ git log ${firstHead}..HEAD --name-status --oneline`, + ); + expect(context.gitSummary).not.toContain( + "No prior OpenWiki update timestamp", + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + test("update falls back to a --since log when only a timestamp was recorded", async () => { + const repo = await createGitRepo(); + + try { + // Metadata that predates the gitHead field still carries updatedAt, which + // selects the `git log --since` branch. + await writeMetadata(repo, { + updatedAt: "2020-01-01T00:00:00.000Z", + command: "update", + model: "test-model", + }); + + const context = await createRunContext("update", repo, "repository"); + + expect(context.gitSummary).toContain( + "$ git log --since 2020-01-01T00:00:00.000Z --name-status --oneline", + ); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + test("local-wiki mode reports that git context is not used", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "openwiki-utils-local-")); + + try { + const context = await createRunContext("update", cwd, "local-wiki"); + + expect(context.gitSummary).toContain("Local wiki mode"); + expect(context.gitSummary).not.toContain("$ git status"); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +describe("getUpdateNoopStatus degenerate cases", () => { + test("does not skip when prior metadata has no git head", async () => { + const repo = await createGitRepo(); + + try { + // Metadata without a gitHead cannot be diffed against, so a skip would be + // unsafe: the run must proceed. + await writeMetadata(repo, { + updatedAt: new Date().toISOString(), + command: "update", + model: "test-model", + }); + + expect(await getUpdateNoopStatus(repo)).toEqual({ + shouldSkip: false, + reason: "missing previous update git head", + }); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); + + test("treats structurally invalid metadata as no prior update", async () => { + const repo = await createGitRepo(); + + try { + // Valid JSON but missing the required fields readLastUpdate checks: it is + // rejected as if there were no prior run at all. + await writeMetadata(repo, { note: "not real metadata" }); + + expect(await getUpdateNoopStatus(repo)).toEqual({ + shouldSkip: false, + reason: "missing previous update git head", + }); + } finally { + await rm(repo, { recursive: true, force: true }); + } + }); +}); + +describe("removeTemporaryPlanFile error handling", () => { + test("propagates unexpected errors instead of swallowing them", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "openwiki-utils-plan-")); + + try { + // A directory where the plan file is expected makes rm fail with a + // non-ENOENT error. That is not the tolerated "already gone" case, so it + // must surface rather than be reported as a benign "nothing removed". + await mkdir(path.join(cwd, "openwiki", "_plan.md"), { recursive: true }); + + await expect( + removeTemporaryPlanFile(cwd, "repository"), + ).rejects.toThrow(); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); + +describe("createOpenWikiContentSnapshot recursion", () => { + test("hashes nested files and changes when nested content changes", async () => { + const cwd = await mkdtemp(path.join(tmpdir(), "openwiki-utils-snap-")); + + try { + const nestedDir = path.join(cwd, "openwiki", "guides"); + await mkdir(nestedDir, { recursive: true }); + await writeFile(path.join(nestedDir, "intro.md"), "# Intro\n", "utf8"); + + const before = await createOpenWikiContentSnapshot(cwd, "repository"); + // The snapshot must be stable for identical content so unchanged runs are + // detected as no-ops. + expect(await createOpenWikiContentSnapshot(cwd, "repository")).toBe( + before, + ); + + await writeFile(path.join(nestedDir, "intro.md"), "# Changed\n", "utf8"); + const after = await createOpenWikiContentSnapshot(cwd, "repository"); + + // A change buried in a subdirectory must still alter the hash, proving the + // walk recurses rather than only hashing the top level. + expect(after).not.toBe(before); + } finally { + await rm(cwd, { recursive: true, force: true }); + } + }); +}); diff --git a/test/auth/ngrok.test.ts b/test/auth/ngrok.test.ts index 7c28d404..8119f3ac 100644 --- a/test/auth/ngrok.test.ts +++ b/test/auth/ngrok.test.ts @@ -1,5 +1,17 @@ -import { describe, expect, test } from "vitest"; -import { getRedirectUriFromNgrokTunnels } from "../../src/auth/ngrok.ts"; +import { describe, expect, test, vi } from "vitest"; + +// startNgrokTunnel persists the resolved redirect config through the env file. +// It is mocked so the validation-rejection cases below cannot touch disk; those +// cases all throw during validatePort / normalizeNgrokUrl, before any save or +// `ngrok` spawn, so no real tunnel process is ever launched here. +vi.mock("../../src/config/env.ts", () => ({ + saveOpenWikiEnv: vi.fn(() => Promise.resolve()), +})); + +import { + getRedirectUriFromNgrokTunnels, + startNgrokTunnel, +} from "../../src/auth/ngrok.ts"; const PORT = 53682; @@ -83,4 +95,123 @@ describe("getRedirectUriFromNgrokTunnels", () => { expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBeNull(); }); + + test("skips tunnel entries that are not objects or lack a public url", () => { + // The ngrok API is trusted loosely: a null entry or one missing public_url + // must be dropped rather than crash discovery, while a valid sibling still + // resolves. + const payload = { + tunnels: [ + null, + { config: { addr: `localhost:${PORT}` } }, + { config: { addr: `localhost:${PORT}` }, public_url: 42 }, + { + config: { addr: `localhost:${PORT}` }, + public_url: "https://ok.ngrok.app", + }, + ], + }; + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://ok.ngrok.app/callback", + ); + }); + + test("skips tunnels whose public url does not parse", () => { + const payload = tunnels([ + { addr: `localhost:${PORT}`, public_url: "://not-a-url" }, + { addr: `localhost:${PORT}`, public_url: "https://parses.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://parses.ngrok.app/callback", + ); + }); + + test("matches a full-url addr by parsing out its port", () => { + // The addr is a full URL that does not literally end in `:53682`, so the + // match must come from URL parsing rather than the suffix shortcut. + const payload = tunnels([ + { + addr: "http://127.0.0.1:53682/callback", + public_url: "https://a.ngrok.app", + }, + { + addr: "http://127.0.0.1:9999/callback", + public_url: "https://b.ngrok.app", + }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://a.ngrok.app/callback", + ); + }); + + test("does not match a full-url addr whose parsed port differs", () => { + const payload = tunnels([ + { + addr: "http://127.0.0.1:1111/callback", + public_url: "https://a.ngrok.app", + }, + { + addr: "http://127.0.0.1:2222/callback", + public_url: "https://b.ngrok.app", + }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBeNull(); + }); +}); + +describe("startNgrokTunnel validation", () => { + // Every case here rejects during synchronous validation, before saveOpenWikiEnv + // or the ngrok spawn, so a bad operator input never brings up a tunnel. + test.each([80, 70000, 1024.5])( + "rejects a local port outside the unprivileged TCP range: %s", + async (port) => { + await expect(startNgrokTunnel({ port })).rejects.toThrow( + "ngrok local port must be between 1024 and 65535.", + ); + }, + ); + + test("rejects a custom url that is not https", async () => { + // A plaintext tunnel would ship the OAuth redirect over http, so it is + // refused rather than silently downgraded. + await expect( + startNgrokTunnel({ url: "http://tunnel.ngrok.app" }), + ).rejects.toThrow("ngrok custom URL must use https."); + }); + + test("rejects a custom url that carries credentials, query, or fragment", async () => { + for (const url of [ + "https://user:pw@tunnel.ngrok.app", + "https://tunnel.ngrok.app/?token=abc", + "https://tunnel.ngrok.app/#frag", + ]) { + await expect(startNgrokTunnel({ url })).rejects.toThrow( + "ngrok custom URL must not include credentials, query, or fragment.", + ); + } + }); + + test("rejects a custom url that pins a port", async () => { + await expect( + startNgrokTunnel({ url: "https://tunnel.ngrok.app:8443" }), + ).rejects.toThrow("ngrok custom URL must not include a port."); + }); + + test("rejects a custom url whose path is neither empty nor /callback", async () => { + await expect( + startNgrokTunnel({ url: "https://tunnel.ngrok.app/elsewhere" }), + ).rejects.toThrow("ngrok custom URL path must be empty or /callback."); + }); + + test("rejects a custom url without a valid dns hostname", async () => { + // Underscores are not legal DNS label characters; the hostname guard keeps + // a bogus authority from being registered as a Slack redirect. + await expect(startNgrokTunnel({ url: "https://bad_host" })).rejects.toThrow( + "ngrok custom URL must include a valid DNS hostname.", + ); + }); }); diff --git a/test/auth/oauth.test.ts b/test/auth/oauth.test.ts new file mode 100644 index 00000000..705b8fce --- /dev/null +++ b/test/auth/oauth.test.ts @@ -0,0 +1,247 @@ +import net from "node:net"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { + createCallbackServer, + formatAuthProviderList, +} from "../../src/auth/oauth.ts"; +import { getAuthProvider } from "../../src/auth/providers.ts"; + +// oauth.ts keeps almost every helper module-private; the exported seams reached +// here are formatAuthProviderList (pure) and createCallbackServer, which routes +// through the private getCallbackPort / getProviderRedirectUri / state-matching +// logic. The port-validation cases throw before the loopback server ever binds, +// so they stay dependency-free; the redirect-uri and callback-handler cases do +// bind a 127.0.0.1 server and are closed in every branch to avoid open handles. + +const CALLBACK_PORT_ENV_KEY = "OPENWIKI_OAUTH_CALLBACK_PORT"; +const HTTPS_REDIRECT_ENV_KEY = "OPENWIKI_HTTPS_OAUTH_REDIRECT_URI"; + +const originalCallbackPort = process.env[CALLBACK_PORT_ENV_KEY]; +const originalHttpsRedirect = process.env[HTTPS_REDIRECT_ENV_KEY]; + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + +beforeEach(() => { + delete process.env[CALLBACK_PORT_ENV_KEY]; + delete process.env[HTTPS_REDIRECT_ENV_KEY]; +}); + +afterEach(() => { + restoreEnv(CALLBACK_PORT_ENV_KEY, originalCallbackPort); + restoreEnv(HTTPS_REDIRECT_ENV_KEY, originalHttpsRedirect); +}); + +/** + * Reserves an ephemeral loopback port and releases it so a callback server can + * bind it, keeping concurrent test files off the fixed default port. + */ +async function findFreePort(): Promise { + return await new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address() as net.AddressInfo; + probe.close(() => resolve(address.port)); + }); + }); +} + +describe("formatAuthProviderList", () => { + test("names every supported provider and the auth commands", () => { + const listing = formatAuthProviderList(); + + // The four provider ids the CLI accepts must all be discoverable here, or + // `openwiki auth` help silently drops a working integration. + for (const provider of ["slack", "gmail", "x", "notion"]) { + expect(listing).toContain(provider); + } + expect(listing).toContain("openwiki auth "); + expect(listing).toContain("openwiki auth configure "); + expect(listing).toContain("openwiki auth tools "); + }); +}); + +describe("createCallbackServer port validation", () => { + // A malformed OPENWIKI_OAUTH_CALLBACK_PORT is rejected before the server + // binds, so an operator typo fails loudly instead of listening somewhere + // unexpected. + test("rejects a non-numeric callback port before binding", async () => { + process.env[CALLBACK_PORT_ENV_KEY] = "not-a-port"; + + await expect( + createCallbackServer(getAuthProvider("gmail")), + ).rejects.toThrow(`${CALLBACK_PORT_ENV_KEY} must be a TCP port.`); + }); + + test("rejects an over-long numeric string that cannot be a TCP port", async () => { + // Six digits fail the 1-5 digit shape check outright. + process.env[CALLBACK_PORT_ENV_KEY] = "999999"; + + await expect( + createCallbackServer(getAuthProvider("gmail")), + ).rejects.toThrow(`${CALLBACK_PORT_ENV_KEY} must be a TCP port.`); + }); + + test("rejects a privileged port below the unprivileged range", async () => { + process.env[CALLBACK_PORT_ENV_KEY] = "80"; + + await expect( + createCallbackServer(getAuthProvider("gmail")), + ).rejects.toThrow( + `${CALLBACK_PORT_ENV_KEY} must be between 1024 and 65535.`, + ); + }); + + test("rejects a five-digit port above the TCP maximum", async () => { + // Passes the digit-shape check but exceeds 65535, so the range guard trips. + process.env[CALLBACK_PORT_ENV_KEY] = "70000"; + + await expect( + createCallbackServer(getAuthProvider("gmail")), + ).rejects.toThrow( + `${CALLBACK_PORT_ENV_KEY} must be between 1024 and 65535.`, + ); + }); +}); + +describe("createCallbackServer redirect uri", () => { + test("uses the loopback callback url for a non-override provider", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + const callback = await createCallbackServer(getAuthProvider("gmail")); + + try { + expect(callback.redirectUri).toBe(`http://127.0.0.1:${port}/callback`); + } finally { + await callback.close(); + } + }); + + test("ignores an https override for a provider that does not opt into it", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + // Only Slack consumes the https redirect override; Gmail must keep loopback + // even when the env var is present, so a stray override cannot redirect the + // Gmail flow off-box. + process.env[HTTPS_REDIRECT_ENV_KEY] = "https://example.ngrok.app/callback"; + const callback = await createCallbackServer(getAuthProvider("gmail")); + + try { + expect(callback.redirectUri).toBe(`http://127.0.0.1:${port}/callback`); + } finally { + await callback.close(); + } + }); + + test("keeps loopback for slack when no override is configured", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + const callback = await createCallbackServer(getAuthProvider("slack")); + + try { + expect(callback.redirectUri).toBe(`http://127.0.0.1:${port}/callback`); + } finally { + await callback.close(); + } + }); + + test("adopts a valid https override for slack", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + process.env[HTTPS_REDIRECT_ENV_KEY] = "https://tunnel.ngrok.app/callback"; + const callback = await createCallbackServer(getAuthProvider("slack")); + + try { + expect(callback.redirectUri).toBe("https://tunnel.ngrok.app/callback"); + } finally { + await callback.close(); + } + }); +}); + +describe("createCallbackServer callback handling", () => { + test("captures the authorization code when the state matches", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + const callback = await createCallbackServer(getAuthProvider("gmail")); + + try { + const codePromise = callback.waitForCode("state-123"); + const response = await fetch( + `http://127.0.0.1:${port}/callback?code=auth-code&state=state-123`, + ); + + expect(response.status).toBe(200); + await expect(codePromise).resolves.toBe("auth-code"); + } finally { + await callback.close(); + } + }); + + test("rejects a callback whose state does not match the request", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + const callback = await createCallbackServer(getAuthProvider("gmail")); + + try { + // A mismatched state is the CSRF signal: the redirect did not originate + // from the authorization request this process started, so the code must + // never be accepted. The rejection handler is attached before the fetch + // so the pending rejection is never momentarily unhandled. + const assertion = expect( + callback.waitForCode("expected-state"), + ).rejects.toThrow("OAuth callback state did not match."); + await fetch( + `http://127.0.0.1:${port}/callback?code=auth-code&state=attacker-state`, + ); + + await assertion; + } finally { + await callback.close(); + } + }); + + test("surfaces a provider error and answers with a 400", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + const callback = await createCallbackServer(getAuthProvider("gmail")); + + try { + const assertion = expect( + callback.waitForCode("state-123"), + ).rejects.toThrow("OAuth provider returned error: access_denied"); + const response = await fetch( + `http://127.0.0.1:${port}/callback?error=access_denied`, + ); + + expect(response.status).toBe(400); + await assertion; + } finally { + await callback.close(); + } + }); + + test("rejects a callback that is missing the code or state", async () => { + const port = await findFreePort(); + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + const callback = await createCallbackServer(getAuthProvider("gmail")); + + try { + const assertion = expect( + callback.waitForCode("state-123"), + ).rejects.toThrow("OAuth callback was missing code or state."); + const response = await fetch(`http://127.0.0.1:${port}/callback`); + + expect(response.status).toBe(400); + await assertion; + } finally { + await callback.close(); + } + }); +}); diff --git a/test/auth/tokens.test.ts b/test/auth/tokens.test.ts index c20278c5..064883d9 100644 --- a/test/auth/tokens.test.ts +++ b/test/auth/tokens.test.ts @@ -1,95 +1,526 @@ +import { mkdtemp, readFile, rm, stat } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { parseEnv } from "../../src/config/env.ts"; +import { getAuthProvider } from "../../src/auth/providers.ts"; +import type { AuthProviderId } from "../../src/auth/types.ts"; -// tokens.ts loads/saves the OpenWiki env file at the edges; both are mocked so -// the tests drive behavior purely through process.env and never touch disk. -vi.mock("../../src/config/env.ts", () => ({ - loadOpenWikiEnv: vi.fn(() => Promise.resolve()), - saveOpenWikiEnv: vi.fn(() => Promise.resolve()), -})); +// tokens.ts persists refreshed credentials by delegating to saveOpenWikiEnv, +// which writes ~/.openwiki/.env. These tests exercise that real persistence +// path (rather than mocking the env layer) so the on-disk permission invariant +// can be asserted end to end. Only fetch is stubbed; a real network call to a +// provider token endpoint is never made. os.homedir() is read at module load, +// so every test loads tokens.ts fresh under a throwaway HOME. -import { saveOpenWikiEnv } from "../../src/config/env.ts"; -import { getAuthProvider } from "../../src/auth/providers.ts"; -import { - getOAuthAccessToken, - getOAuthProviderIdForAccessTokenEnvKey, - isOAuthAccessTokenExpired, -} from "../../src/auth/tokens.ts"; - -const slack = getAuthProvider("slack"); -const ACCESS_KEY = slack.tokenMapping.accessTokenEnvKey; -const EXPIRES_KEY = slack.tokenMapping.expiresAtEnvKey!; -const MANAGED = [ACCESS_KEY, EXPIRES_KEY]; -const saved: Record = {}; +const PROVIDER_IDS: AuthProviderId[] = ["gmail", "notion", "slack", "x"]; + +/** + * Every process.env key any provider's token mapping can read or write. + * Collected up front so each test starts from a clean slate: saveOpenWikiEnv + * mirrors persisted values back into process.env, so without this reset a + * refreshed token would leak into the next test's expiry/cache assertions. + */ +const managedKeys = collectManagedKeys(); + +function collectManagedKeys(): string[] { + const keys = new Set(); + + for (const providerId of PROVIDER_IDS) { + const provider = getAuthProvider(providerId); + const mapping = provider.tokenMapping; + + keys.add(mapping.accessTokenEnvKey); + + for (const key of [ + mapping.refreshTokenEnvKey, + mapping.expiresAtEnvKey, + mapping.tokenTypeEnvKey, + mapping.clientIdEnvKey, + provider.clientIdEnvKey, + provider.clientSecretEnvKey, + ]) { + if (key) { + keys.add(key); + } + } + } + + return [...keys]; +} + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const savedManaged: Record = {}; +const tempHomes: string[] = []; + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-tokens-")); + tempHomes.push(home); + return home; +} + +/** + * Load a fresh tokens.ts bound to the given HOME. resetModules is required + * because env.ts captures os.homedir() into a module-level constant at import + * time, so the temp HOME must be set before the (re)import. + */ +async function loadTokensModule(home: string) { + vi.resetModules(); + process.env.HOME = home; + // Windows resolves the home directory from USERPROFILE, not HOME; set both so + // the temp-HOME redirection holds regardless of platform. + process.env.USERPROFILE = home; + return await import("../../src/auth/tokens.ts"); +} + +/** + * Stub global fetch with a real Response so tokens.ts sees genuine ok/status + * and json() semantics. Returns the mock for request-body inspection. + */ +function stubFetch(body: unknown, status = 200): ReturnType { + const fetchMock = vi.fn(() => + Promise.resolve( + new Response(typeof body === "string" ? body : JSON.stringify(body), { + status, + }), + ), + ); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +/** + * Stub fetch to fail loudly. Used where tokens.ts must reject before ever + * reaching the network, so a real request would be a bug the test should catch. + */ +function stubFetchNeverCalled(): ReturnType { + const fetchMock = vi.fn(() => { + throw new Error("fetch must not be called on this path"); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function envDirFor(home: string): string { + return path.join(home, ".openwiki"); +} + +function envPathFor(home: string): string { + return path.join(envDirFor(home), ".env"); +} + +async function readPersistedEnv(home: string): Promise> { + return parseEnv(await readFile(envPathFor(home), "utf8")); +} beforeEach(() => { - vi.clearAllMocks(); - for (const key of MANAGED) { - saved[key] = process.env[key]; + for (const key of managedKeys) { + savedManaged[key] = process.env[key]; delete process.env[key]; } }); -afterEach(() => { +afterEach(async () => { vi.unstubAllGlobals(); - for (const key of MANAGED) { - if (saved[key] === undefined) { + vi.resetModules(); + + for (const key of managedKeys) { + if (savedManaged[key] === undefined) { delete process.env[key]; } else { - process.env[key] = saved[key]; + process.env[key] = savedManaged[key]; } } + + restoreEnv("HOME", originalHome); + restoreEnv("USERPROFILE", originalUserProfile); + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); }); +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} + describe("getOAuthProviderIdForAccessTokenEnvKey", () => { - test("maps a known access-token env key back to its provider", () => { - expect(getOAuthProviderIdForAccessTokenEnvKey(ACCESS_KEY)).toBe("slack"); + test("maps each provider's access-token env key back to its id", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + + for (const providerId of PROVIDER_IDS) { + const accessKey = + getAuthProvider(providerId).tokenMapping.accessTokenEnvKey; + expect(tokens.getOAuthProviderIdForAccessTokenEnvKey(accessKey)).toBe( + providerId, + ); + } }); - test("returns null for an unrecognized env key", () => { + test("returns null for an env key no provider owns", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + expect( - getOAuthProviderIdForAccessTokenEnvKey("OPENWIKI_NOT_A_REAL_TOKEN"), + tokens.getOAuthProviderIdForAccessTokenEnvKey("OPENWIKI_NOT_A_TOKEN"), ).toBeNull(); }); }); describe("isOAuthAccessTokenExpired", () => { - test("treats a missing expiry as not expired", () => { - expect(isOAuthAccessTokenExpired("slack")).toBe(false); + const EXPIRES_KEY = getAuthProvider("slack").tokenMapping.expiresAtEnvKey!; + + test("treats a missing expiry as not expired", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + + expect(tokens.isOAuthAccessTokenExpired("slack")).toBe(false); }); - test("treats an unparseable expiry as expired", () => { - process.env[EXPIRES_KEY] = "not-a-date"; - expect(isOAuthAccessTokenExpired("slack")).toBe(true); + test("treats an unparseable expiry as expired", async () => { + // A corrupt timestamp should fail safe toward refreshing, never toward + // handing back a token whose validity cannot be established. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + process.env[EXPIRES_KEY] = "not-a-timestamp"; + + expect(tokens.isOAuthAccessTokenExpired("slack")).toBe(true); }); - test("treats a comfortably future expiry as not expired", () => { + test("treats a comfortably future expiry as not expired", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); process.env[EXPIRES_KEY] = new Date(Date.now() + 600_000).toISOString(); - expect(isOAuthAccessTokenExpired("slack")).toBe(false); + + expect(tokens.isOAuthAccessTokenExpired("slack")).toBe(false); }); - test("treats an already-past expiry as expired", () => { + test("treats an already-past expiry as expired", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); process.env[EXPIRES_KEY] = new Date(Date.now() - 1_000).toISOString(); - expect(isOAuthAccessTokenExpired("slack")).toBe(true); + + expect(tokens.isOAuthAccessTokenExpired("slack")).toBe(true); }); - test("treats an expiry inside the refresh skew window as expired", () => { - // The refresh skew is 60s; 30s out is still considered expired. + test("treats an expiry inside the 60s refresh skew as expired", async () => { + // The skew (REFRESH_EXPIRY_SKEW_MS = 60s) forces a proactive refresh: a + // token expiring 30s from now is already treated as expired so a request + // is never sent with a credential about to lapse mid-flight. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); process.env[EXPIRES_KEY] = new Date(Date.now() + 30_000).toISOString(); - expect(isOAuthAccessTokenExpired("slack")).toBe(true); + + expect(tokens.isOAuthAccessTokenExpired("slack")).toBe(true); }); }); -describe("getOAuthAccessToken", () => { - test("returns the cached token without refreshing when it is still valid", async () => { - process.env[ACCESS_KEY] = "cached-token"; - process.env[EXPIRES_KEY] = new Date(Date.now() + 600_000).toISOString(); - const fetchMock = vi.fn(() => { - throw new Error("fetch should not be called for a valid cached token"); +describe("refreshOAuthAccessToken persistence", () => { + const isPosix = process.platform !== "win32"; + + test("writes the refreshed env to a 0o600 file inside a 0o700 dir", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + stubFetch({ + access_token: "gmail-access-new", + refresh_token: "gmail-refresh-new", + token_type: "Bearer", + expires_in: 3600, }); - vi.stubGlobal("fetch", fetchMock); - await expect(getOAuthAccessToken("slack")).resolves.toBe("cached-token"); + const before = Date.now(); + const result = await tokens.refreshOAuthAccessToken("gmail"); + const after = Date.now(); + + expect(result).toBe("gmail-access-new"); + + // The credential file holds long-lived OAuth secrets, so the persistence + // layer must keep it readable only by the owner (0o600) inside an + // owner-only directory (0o700). This is the security invariant under test; + // POSIX mode bits do not exist on Windows, so assert them only there. + if (isPosix) { + const dirMode = (await stat(envDirFor(home))).mode & 0o777; + const fileMode = (await stat(envPathFor(home))).mode & 0o777; + expect(dirMode).toBe(0o700); + expect(fileMode).toBe(0o600); + } + + const persisted = await readPersistedEnv(home); + expect(persisted[gmail.tokenMapping.accessTokenEnvKey]).toBe( + "gmail-access-new", + ); + expect(persisted[gmail.tokenMapping.refreshTokenEnvKey!]).toBe( + "gmail-refresh-new", + ); + expect(persisted[gmail.tokenMapping.tokenTypeEnvKey!]).toBe("Bearer"); + + // expires_in is seconds-from-now; it must be materialized as an absolute + // ISO timestamp bounded by when the refresh actually ran. + const expiresAt = Date.parse( + persisted[gmail.tokenMapping.expiresAtEnvKey!], + ); + expect(expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000); + expect(expiresAt).toBeLessThanOrEqual(after + 3600 * 1000); + }); + + test("sends client_secret for a client_secret_post provider", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + const fetchMock = stubFetch({ access_token: "gmail-access-new" }); + + await tokens.refreshOAuthAccessToken("gmail"); + + const [, init] = fetchMock.mock.calls[0] as [ + string, + { body: URLSearchParams }, + ]; + const sentBody = init.body.toString(); + expect(sentBody).toContain("grant_type=refresh_token"); + expect(sentBody).toContain("client_secret=gmail-client-secret"); + }); + + test("omits client_secret for a public (clientAuth none) provider", async () => { + // X uses clientAuth "none"; the refresh must authenticate with client_id + // alone and must never emit a client_secret field. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const x = getAuthProvider("x"); + + process.env[x.tokenMapping.refreshTokenEnvKey!] = "x-refresh"; + process.env[x.clientIdEnvKey!] = "x-client-id"; + const fetchMock = stubFetch({ access_token: "x-access-new" }); + + const result = await tokens.refreshOAuthAccessToken("x"); + + expect(result).toBe("x-access-new"); + const [, init] = fetchMock.mock.calls[0] as [ + string, + { body: URLSearchParams }, + ]; + const sentBody = init.body.toString(); + expect(sentBody).toContain("client_id=x-client-id"); + expect(sentBody).not.toContain("client_secret"); + }); +}); + +describe("refreshOAuthAccessToken untrusted-response handling", () => { + test("rejects a response with no access token", async () => { + // The token endpoint response is untrusted input; a body missing the one + // required field must surface as an error, not a silently blank credential. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + stubFetch({ token_type: "Bearer" }); + + await expect(tokens.refreshOAuthAccessToken("gmail")).rejects.toThrow( + /did not return an access token/u, + ); + }); + + test("rejects a non-string access token", async () => { + // JSON can carry any type; a numeric access_token must be rejected rather + // than coerced and persisted. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + stubFetch({ access_token: 12345 }); + + await expect(tokens.refreshOAuthAccessToken("gmail")).rejects.toThrow( + /did not return an access token/u, + ); + }); + + test("ignores a non-numeric expires_in instead of persisting a bad expiry", async () => { + // A string expires_in fails the Number.isFinite guard, so no expiry is + // written; a bogus timestamp must never reach the credential file. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + stubFetch({ access_token: "gmail-access-new", expires_in: "not-a-number" }); + + await tokens.refreshOAuthAccessToken("gmail"); + + const persisted = await readPersistedEnv(home); + expect(persisted[gmail.tokenMapping.accessTokenEnvKey]).toBe( + "gmail-access-new", + ); + expect(persisted[gmail.tokenMapping.expiresAtEnvKey!]).toBeUndefined(); + }); + + test("only persists optional fields the response actually provides", async () => { + // A response omitting refresh_token/token_type must not fabricate or blank + // those keys; only the access token is required. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + stubFetch({ access_token: "gmail-access-new" }); + + await tokens.refreshOAuthAccessToken("gmail"); + + const persisted = await readPersistedEnv(home); + expect(persisted[gmail.tokenMapping.accessTokenEnvKey]).toBe( + "gmail-access-new", + ); + expect(persisted[gmail.tokenMapping.refreshTokenEnvKey!]).toBeUndefined(); + expect(persisted[gmail.tokenMapping.tokenTypeEnvKey!]).toBeUndefined(); + }); + + test("reads Slack's tokens from the nested authed_user object", async () => { + // Slack returns the user token under authed_user, not at the top level; + // getTokenValue's Slack branch must read the nested fields and ignore the + // decoy top-level access_token. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const slack = getAuthProvider("slack"); + + process.env[slack.tokenMapping.refreshTokenEnvKey!] = "slack-refresh"; + process.env[slack.clientIdEnvKey!] = "slack-client-id"; + process.env[slack.clientSecretEnvKey!] = "slack-client-secret"; + stubFetch({ + access_token: "top-level-bot-token-ignored", + authed_user: { + access_token: "slack-user-token", + refresh_token: "slack-user-refresh", + token_type: "Bearer", + }, + }); + + const result = await tokens.refreshOAuthAccessToken("slack"); + + expect(result).toBe("slack-user-token"); + const persisted = await readPersistedEnv(home); + expect(persisted[slack.tokenMapping.accessTokenEnvKey]).toBe( + "slack-user-token", + ); + expect(persisted[slack.tokenMapping.refreshTokenEnvKey!]).toBe( + "slack-user-refresh", + ); + }); + + test("throws on a non-ok token endpoint response", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + stubFetch("denied", 400); + + await expect(tokens.refreshOAuthAccessToken("gmail")).rejects.toThrow( + /token refresh failed: 400/u, + ); + }); +}); + +describe("refreshOAuthAccessToken precondition failures", () => { + test("requires a refresh token", async () => { + // Guard runs before any network use, so fetch must never be reached. + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const fetchMock = stubFetchNeverCalled(); + + await expect(tokens.refreshOAuthAccessToken("gmail")).rejects.toThrow( + /refresh token is required/u, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("requires a client id", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + const fetchMock = stubFetchNeverCalled(); + + await expect(tokens.refreshOAuthAccessToken("gmail")).rejects.toThrow( + /client id is required/u, + ); expect(fetchMock).not.toHaveBeenCalled(); - expect(saveOpenWikiEnv).not.toHaveBeenCalled(); + }); + + test("requires a client secret for a client_secret_post provider", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + const fetchMock = stubFetchNeverCalled(); + + await expect(tokens.refreshOAuthAccessToken("gmail")).rejects.toThrow( + /is required to refresh/u, + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("getOAuthAccessToken", () => { + test("returns the cached token without refreshing when still valid", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + process.env[gmail.tokenMapping.accessTokenEnvKey] = "cached-token"; + process.env[gmail.tokenMapping.expiresAtEnvKey!] = new Date( + Date.now() + 600_000, + ).toISOString(); + const fetchMock = stubFetchNeverCalled(); + + await expect(tokens.getOAuthAccessToken("gmail")).resolves.toBe( + "cached-token", + ); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("refreshes when the cached token is expired", async () => { + const home = await createTempHome(); + const tokens = await loadTokensModule(home); + const gmail = getAuthProvider("gmail"); + process.env[gmail.tokenMapping.accessTokenEnvKey] = "stale-token"; + process.env[gmail.tokenMapping.expiresAtEnvKey!] = new Date( + Date.now() - 1_000, + ).toISOString(); + process.env[gmail.tokenMapping.refreshTokenEnvKey!] = "gmail-refresh"; + process.env[gmail.clientIdEnvKey!] = "gmail-client-id"; + process.env[gmail.clientSecretEnvKey!] = "gmail-client-secret"; + const fetchMock = stubFetch({ access_token: "fresh-token" }); + + await expect(tokens.getOAuthAccessToken("gmail")).resolves.toBe( + "fresh-token", + ); + expect(fetchMock).toHaveBeenCalledTimes(1); }); }); diff --git a/test/cli/commands.test.ts b/test/cli/commands.test.ts index def22d9c..9292a754 100644 --- a/test/cli/commands.test.ts +++ b/test/cli/commands.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { + commandEmitsTelemetry, getHelpText, parseCommand, shouldRunNonInteractively, @@ -574,4 +575,390 @@ describe("parseCommand — cron", () => { const result = parseCommand(["cron", "pause", "all", "extra"]); expect(result.kind).toBe("error"); }); + + test("an unknown cron subcommand falls through to usage guidance", () => { + const result = parseCommand(["cron", "bogus"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/list \| pause all \| resume all/u); + } + }); + + test("cron with no subcommand is an error", () => { + expect(parseCommand(["cron"]).kind).toBe("error"); + }); + + test("cron list with an extra argument is rejected", () => { + // `list` only matches when it stands alone; a trailing token drops it into + // the usage-error branch rather than silently ignoring the extra input. + expect(parseCommand(["cron", "list", "extra"]).kind).toBe("error"); + }); +}); + +describe("parseCommand — ngrok", () => { + test("bare ngrok start uses the default OAuth callback port and no url", () => { + expect(parseCommand(["ngrok", "start"])).toEqual({ + kind: "ngrok", + action: "start", + exitCode: 0, + port: 53682, + url: null, + }); + }); + + test("a positional url is captured as the fixed tunnel url", () => { + expect( + parseCommand(["ngrok", "start", "https://openwiki.ngrok.app"]), + ).toMatchObject({ + kind: "ngrok", + url: "https://openwiki.ngrok.app", + port: 53682, + }); + }); + + test("--port accepts a space-separated value", () => { + expect(parseCommand(["ngrok", "start", "--port", "8080"])).toMatchObject({ + kind: "ngrok", + port: 8080, + }); + }); + + test("--port= equals form is accepted", () => { + expect(parseCommand(["ngrok", "start", "--port=9000"])).toMatchObject({ + kind: "ngrok", + port: 9000, + }); + }); + + test("url and --port can be combined in either order", () => { + expect( + parseCommand(["ngrok", "start", "https://x.ngrok.app", "--port", "8080"]), + ).toMatchObject({ + kind: "ngrok", + url: "https://x.ngrok.app", + port: 8080, + }); + }); + + test("ngrok without the start subcommand is an error", () => { + const result = parseCommand(["ngrok"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/ngrok start/u); + } + }); + + test("--port with no value is an error", () => { + const result = parseCommand(["ngrok", "start", "--port"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/--port requires a value/u); + } + }); + + test("a non-integer port is rejected by the range check", () => { + const result = parseCommand(["ngrok", "start", "--port", "abc"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/between 1024 and 65535/u); + } + }); + + test("a privileged port below 1024 is rejected", () => { + expect(parseCommand(["ngrok", "start", "--port", "80"]).kind).toBe("error"); + }); + + test("a port above 65535 is rejected", () => { + expect(parseCommand(["ngrok", "start", "--port", "70000"]).kind).toBe( + "error", + ); + }); + + test("a second positional argument is an unknown option", () => { + // The url slot only fills once; a second bare token is not silently + // dropped but surfaced as an unknown option. + const result = parseCommand(["ngrok", "start", "https://a", "https://b"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Unknown option for ngrok/u); + } + }); + + test("an unknown flag is reported", () => { + const result = parseCommand(["ngrok", "start", "--nope"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Unknown option for ngrok/u); + } + }); +}); + +describe("parseCommand — auth listing and validation", () => { + test("bare auth lists providers via the oauth-list branch", () => { + expect(parseCommand(["auth"])).toEqual({ + kind: "auth", + action: "list", + exitCode: 0, + force: false, + provider: null, + }); + }); + + test("explicit auth list also returns the list command", () => { + expect(parseCommand(["auth", "list"])).toMatchObject({ + kind: "auth", + action: "list", + provider: null, + }); + }); + + test("an unrecognized provider is rejected", () => { + const result = parseCommand(["auth", "bogus-provider"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Unknown auth provider: bogus-provider/u); + } + }); + + test("auth configure without a provider prints its usage", () => { + const result = parseCommand(["auth", "configure"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/auth configure /u); + } + }); + + test("auth tools without a provider prints its usage", () => { + const result = parseCommand(["auth", "tools"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/auth tools /u); + } + }); + + test("auth tools with a valid provider parses and never carries force", () => { + expect(parseCommand(["auth", "tools", "notion"])).toEqual({ + kind: "auth", + action: "tools", + exitCode: 0, + force: false, + provider: "notion", + }); + }); +}); + +describe("parseCommand — ingest --modelId", () => { + test("space-separated valid model id is normalized onto the ingest run", () => { + expect( + parseCommand(["ingest", "all", "--modelId", "claude-opus-4-8"]), + ).toMatchObject({ + kind: "ingest", + target: "all", + modelId: "claude-opus-4-8", + }); + }); + + test("--model-id equals form is accepted for ingest", () => { + expect(parseCommand(["ingest", "all", "--model-id=gpt-5.5"])).toMatchObject( + { + kind: "ingest", + modelId: "gpt-5.5", + }, + ); + }); + + test("ingest --modelId with no value is an error", () => { + const result = parseCommand(["ingest", "all", "--modelId"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/requires a model ID/u); + } + }); + + test("ingest --modelId with an invalid id is rejected, not passed through", () => { + const result = parseCommand(["ingest", "all", "--modelId", "http://evil"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Invalid model ID/u); + } + }); + + test("ingest --modelId= equals form with an invalid id is rejected", () => { + expect(parseCommand(["ingest", "all", "--modelId=http://evil"]).kind).toBe( + "error", + ); + }); + + test("an unknown ingest flag is reported", () => { + const result = parseCommand(["ingest", "all", "--nope"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Unknown option for ingest/u); + } + }); +}); + +describe("parseCommand — --mode option forms and conflicts", () => { + test("--mode with no value is an error", () => { + const result = parseCommand(["--mode"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/--mode requires personal or code/u); + } + }); + + test("--mode followed by a flag is treated as a missing value", () => { + expect(parseCommand(["--mode", "--init"]).kind).toBe("error"); + }); + + test("an invalid --mode value is rejected", () => { + const result = parseCommand(["--mode", "hybrid"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Invalid mode: hybrid/u); + } + }); + + test("--mode= equals form selects the mode", () => { + expect(parseCommand(["--mode=personal", "--init"])).toMatchObject({ + kind: "run", + mode: "personal", + modeSource: "option", + command: "init", + }); + }); + + test("an invalid --mode= equals value is rejected", () => { + expect(parseCommand(["--mode=hybrid"]).kind).toBe("error"); + }); + + test("--mode that contradicts a positional mode is a conflict", () => { + // `code` fixes the mode positionally; a later --mode personal cannot + // silently override it, so the parser reports the conflict. + const result = parseCommand(["code", "--mode", "personal"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/Conflicting modes: code and personal/u); + } + }); + + test("--mode= that contradicts a positional mode is a conflict", () => { + expect(parseCommand(["code", "--mode=personal"]).kind).toBe("error"); + }); + + test("--mode restating the same mode is not a conflict", () => { + expect(parseCommand(["code", "--mode", "code", "--init"])).toMatchObject({ + kind: "run", + mode: "code", + command: "init", + }); + }); +}); + +describe("parseCommand — --telemetry-file", () => { + test("space-separated path is captured alongside the run", () => { + expect( + parseCommand(["--print", "--telemetry-file", "/tmp/payload.json", "hi"]), + ).toMatchObject({ + kind: "run", + telemetryFile: "/tmp/payload.json", + userMessage: "hi", + print: true, + }); + }); + + test("--telemetry-file= equals form is captured", () => { + expect( + parseCommand(["--init", "--telemetry-file=/tmp/out.json"]), + ).toMatchObject({ + kind: "run", + command: "init", + telemetryFile: "/tmp/out.json", + }); + }); + + test("--telemetry-file with no path is an error", () => { + const result = parseCommand(["--telemetry-file"]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/--telemetry-file requires a path/u); + } + }); + + test("--telemetry-file followed by a flag is treated as a missing path", () => { + expect(parseCommand(["--telemetry-file", "--init"]).kind).toBe("error"); + }); + + test("an empty --telemetry-file= value is an error", () => { + const result = parseCommand(["--telemetry-file="]); + + expect(result.kind).toBe("error"); + if (result.kind === "error") { + expect(result.message).toMatch(/--telemetry-file requires a path/u); + } + }); + + test("telemetry file defaults to null when the option is absent", () => { + expect(parseCommand(["--init"])).toMatchObject({ telemetryFile: null }); + }); +}); + +describe("commandEmitsTelemetry", () => { + test("init and update runs emit the single telemetry event", () => { + expect(commandEmitsTelemetry(parseCommand(["--init"]))).toBe(true); + expect(commandEmitsTelemetry(parseCommand(["--update"]))).toBe(true); + }); + + test("a plain chat run emits nothing", () => { + expect(commandEmitsTelemetry(parseCommand(["hello there"]))).toBe(false); + }); + + test("a dry-run init records nothing because the agent never runs", () => { + process.env.OPENWIKI_DEV = "1"; + + expect(commandEmitsTelemetry(parseCommand(["--dry-run", "--init"]))).toBe( + false, + ); + }); + + test("ingest, auth, help, and error commands never emit telemetry", () => { + expect(commandEmitsTelemetry(parseCommand(["ingest", "all"]))).toBe(false); + expect(commandEmitsTelemetry(parseCommand(["auth", "notion"]))).toBe(false); + expect(commandEmitsTelemetry(parseCommand(["--help"]))).toBe(false); + expect(commandEmitsTelemetry(parseCommand(["--nope"]))).toBe(false); + }); +}); + +describe("getHelpText — development sections", () => { + test("dev-only sections are hidden outside development mode", () => { + const helpText = getHelpText(); + + expect(helpText).not.toContain("Development Options"); + expect(helpText).not.toContain("--dry-run"); + }); + + test("development mode reveals the --dry-run option and example", () => { + process.env.OPENWIKI_DEV = "1"; + const helpText = getHelpText(); + + expect(helpText).toContain("Development Options"); + expect(helpText).toContain("--dry-run"); + expect(helpText).toContain("openwiki --dry-run"); + }); }); diff --git a/test/connectors/mcp-client.test.ts b/test/connectors/mcp-client.test.ts index 0ddd940a..18d79c36 100644 --- a/test/connectors/mcp-client.test.ts +++ b/test/connectors/mcp-client.test.ts @@ -1,5 +1,10 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { buildChildEnv } from "../../src/connectors/mcp-client.ts"; +import { + buildChildEnv, + executeMcpReadOnlyOperations, + executeMcpTool, + listMcpTools, +} from "../../src/connectors/mcp-client.ts"; describe("buildChildEnv", () => { const SECRET_KEYS = [ @@ -68,3 +73,193 @@ describe("buildChildEnv", () => { ); }); }); + +// The three exported entry points validate the untrusted connector config +// BEFORE they ever spawn a subprocess or open a network transport. Every case +// below asserts a rejection that happens during that pre-flight validation, so +// none of them actually run an MCP server. This is the pure, spawn-free surface; +// the accept paths that follow validation (a well-formed command that is then +// executed, a reachable https URL that is then contacted) are intentionally left +// for integration tests and are documented in the report. + +/** + * A minimal valid tool operation so config-level validation passes and control + * reaches the transport-specific command/URL checks we want to exercise. + */ +const VALID_TOOL_OP = { name: "search", type: "tool" as const }; + +describe("executeMcpTool input validation", () => { + test("rejects a config with no transport before dispatching", async () => { + // A missing transport is the first guard; without it there is nothing safe + // to spawn or connect to, so execution must never proceed. + await expect(executeMcpTool({}, "search", {})).rejects.toThrow( + /requires a transport/u, + ); + }); + + test("rejects an operation name outside the allowed character set", async () => { + // Tool names are attacker-influenced (they come from config/model output); + // anything with shell/JSON-RPC metacharacters is refused before a call. + await expect( + executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp" } }, + "bad name; rm -rf /", + {}, + ), + ).rejects.toThrow(/Invalid MCP operation name/u); + }); + + test("rejects an argument key outside the allowed character set", async () => { + // A valid name here proves the name allowlist ACCEPTED it (validation only + // reaches the arg check after the name passes); the malformed arg key is + // what triggers the rejection, guarding against injected argument names. + await expect( + executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp" } }, + "search", + { "bad key!": "x" }, + ), + ).rejects.toThrow(/Invalid MCP tool argument name/u); + }); +}); + +describe("executeMcpReadOnlyOperations config validation", () => { + test("rejects when transport is missing", async () => { + await expect( + executeMcpReadOnlyOperations({ + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(/requires a transport/u); + }); + + test("rejects when readOnlyOperations is empty", async () => { + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "stdio", command: "notion-mcp" }, + readOnlyOperations: [], + }), + ).rejects.toThrow(/at least one readOnlyOperation/u); + }); + + test("rejects an unknown operation type", async () => { + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "stdio", command: "notion-mcp" }, + readOnlyOperations: [{ name: "search", type: "delete" as never }], + }), + ).rejects.toThrow(/Invalid MCP operation type/u); + }); + + test("rejects a resource operation with no resolvable URI", async () => { + // A resource op needs a scheme-qualified URI in name or args.uri; an empty + // name with no args.uri is rejected rather than guessed at. + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "stdio", command: "notion-mcp" }, + readOnlyOperations: [{ name: "", type: "resource" }], + }), + ).rejects.toThrow(/requires a resource URI/u); + }); + + test("rejects a resource URI without a valid scheme", async () => { + // Only scheme-qualified URIs (foo:...) are allowed; a bare/relative path is + // refused so a resource read cannot be pointed at arbitrary local content. + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "stdio", command: "notion-mcp" }, + readOnlyOperations: [{ name: "../../etc/passwd", type: "resource" }], + }), + ).rejects.toThrow(/Invalid MCP resource URI/u); + }); +}); + +describe("stdio command allowlist", () => { + test("rejects a command containing shell metacharacters", async () => { + // Even though the subprocess is spawned with shell:false, the command + // string is still validated against a strict allowlist regex so a + // config-supplied command cannot smuggle in spaces/operators. + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "stdio", command: "notion-mcp; rm -rf /" }, + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(/Invalid MCP stdio command/u); + }); + + test("accepts a well-formed command and rejects only on a control-char arg", async () => { + // The ACCEPT path of the command allowlist: "notion-mcp" is well-formed, so + // validation moves past the command check to the per-arg check. The newline + // in the arg is what fails here, which proves the command itself was + // accepted by the allowlist without the process ever being spawned. + await expect( + executeMcpReadOnlyOperations({ + transport: { + type: "stdio", + command: "notion-mcp", + args: ["--ok", "line1\nline2"], + }, + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(/must not contain control characters/u); + }); +}); + +describe("http URL allowlist", () => { + test("rejects an http URL that is not localhost", async () => { + // Remote MCP endpoints must be https; plain http to a non-loopback host is + // refused so credentials/headers are never sent over a cleartext channel. + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "http", url: "http://example.com/mcp" }, + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(/must use https/u); + }); + + test("rejects a non-http(s) protocol", async () => { + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "http", url: "ftp://example.com/mcp" }, + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(/must use https/u); + }); + + test("rejects a malformed URL", async () => { + // A string that is not a parseable URL fails in the URL constructor before + // any network attempt. + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "http", url: "not a url" }, + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(); + }); + + test("rejects an http transport with no URL", async () => { + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "http" }, + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(/requires a URL/u); + }); +}); + +describe("listMcpTools transport validation", () => { + test("rejects when no transport is configured", async () => { + await expect(listMcpTools({})).rejects.toThrow(/requires a transport/u); + }); + + test("rejects an http listing with no URL before connecting", async () => { + await expect(listMcpTools({ transport: { type: "http" } })).rejects.toThrow( + /requires a URL/u, + ); + }); + + test("rejects a stdio listing with an invalid command before spawning", async () => { + await expect( + listMcpTools({ transport: { type: "stdio", command: "bad command" } }), + ).rejects.toThrow(/Invalid MCP stdio command/u); + }); +}); diff --git a/test/connectors/sources/gmail.test.ts b/test/connectors/sources/gmail.test.ts new file mode 100644 index 00000000..83de9283 --- /dev/null +++ b/test/connectors/sources/gmail.test.ts @@ -0,0 +1,352 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +// The gmail connector's pure logic (query windowing/date-operator stripping, +// repeated label/header param construction, message-ref filtering, format +// normalization, and the 401 refresh-and-retry) is private and only observable +// by driving `ingest()` with a stubbed `fetch`. These tests point $HOME at a +// throwaway temp dir, feed controlled Gmail API responses, and assert on the +// exact request URLs and the raw dump written to disk. No real Gmail API call +// is made; the OAuth refresh in the 401 test is fully satisfied by the stub. + +const originalEnv: Record = {}; +const TRACKED_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "OPENWIKI_GMAIL_ACCESS_TOKEN", + "OPENWIKI_GMAIL_REFRESH_TOKEN", + "OPENWIKI_GMAIL_TOKEN_EXPIRES_AT", + "OPENWIKI_GMAIL_TOKEN_TYPE", + "OPENWIKI_GOOGLE_CLIENT_ID", + "OPENWIKI_GOOGLE_CLIENT_SECRET", +] as const; +const tempHomes: string[] = []; + +for (const key of TRACKED_ENV_KEYS) { + originalEnv[key] = process.env[key]; +} + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-gmail-source-")); + tempHomes.push(home); + return home; +} + +async function writeConnectorConfig( + home: string, + config: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", "google"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); +} + +async function loadGmailConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + // A fresh, non-expiring token so getOAuthAccessToken short-circuits without + // touching the OAuth refresh machinery at the start of the run. + process.env.OPENWIKI_GMAIL_ACCESS_TOKEN = "gmail-access-token"; + process.env.OPENWIKI_GMAIL_REFRESH_TOKEN = "gmail-refresh-token"; + delete process.env.OPENWIKI_GMAIL_TOKEN_EXPIRES_AT; + const { createGmailConnector } = + await import("../../../src/connectors/sources/gmail.ts"); + return createGmailConnector(); +} + +function getRequestUrl(input: string | URL | Request): string { + return input instanceof Request ? input.url : String(input); +} + +function getAuthorization(init: RequestInit | undefined): string { + const headers = new Headers(init?.headers ?? {}); + return headers.get("authorization") ?? ""; +} + +interface GmailRequest { + url: URL; + authorization: string; +} + +/** + * Stubs global fetch and records each request URL + Authorization header. The + * handler branches on the URL so tests can model the Gmail list/get endpoints + * and (for the 401 case) the Google OAuth token endpoint. A numeric return is + * treated as an HTTP status. + */ +function stubGmail( + handler: (url: URL, request: GmailRequest) => unknown, +): GmailRequest[] { + const requests: GmailRequest[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = new URL(getRequestUrl(input)); + const request = { authorization: getAuthorization(init), url }; + requests.push(request); + const body = handler(url, request); + + if (typeof body === "number") { + return Promise.resolve(new Response("{}", { status: body })); + } + + return Promise.resolve( + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + }), + ); + return requests; +} + +interface GmailMessagesDump { + format: string; + maxMessages: number; + messageCount: number; + query: string; + windowHours: number | null; +} + +async function readMessagesDump( + rawFiles: string[], +): Promise { + const file = rawFiles.find((entry) => entry.endsWith("gmail-messages.json")); + expect(file).toBeDefined(); + return JSON.parse( + await readFile(file as string, "utf8"), + ) as GmailMessagesDump; +} + +function isListRequest(url: URL): boolean { + return url.pathname.endsWith("/messages"); +} + +afterEach(async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + + for (const key of TRACKED_ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +describe("gmail connector gates", () => { + test("skips when config is disabled", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: false }); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + }); + + test("accepts a legacy MCP placeholder config and warns", async () => { + const home = await createTempHome(); + // A `transport` key marks the deprecated MCP config shape; the connector + // must treat that as enabled and emit a migration warning. + await writeConnectorConfig(home, { maxMessages: 1, transport: "stdio" }); + stubGmail(() => ({ messages: [] })); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect( + result.warnings.some((warning) => + warning.includes("Ignoring legacy Gmail MCP placeholder config"), + ), + ).toBe(true); + }); +}); + +describe("gmail request construction", () => { + test("skips message refs that have no id", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: true, maxMessages: 10 }); + const requests = stubGmail((url) => { + if (isListRequest(url)) { + // The second ref lacks an id and must be skipped, not fetched. + return { messages: [{ id: "m1" }, {}] }; + } + return { id: "m1" }; + }); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const messageGets = requests.filter( + (request) => !isListRequest(request.url), + ); + expect(messageGets).toHaveLength(1); + expect(messageGets[0]?.url.pathname.endsWith("/messages/m1")).toBe(true); + const dump = await readMessagesDump(result.rawFiles); + expect(dump.messageCount).toBe(1); + }); + + test("sends configured labelIds as repeated query params", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + labelIds: ["INBOX", "STARRED"], + maxMessages: 1, + }); + const requests = stubGmail(() => ({ messages: [] })); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const listRequest = requests.find((request) => isListRequest(request.url)); + expect(listRequest?.url.searchParams.getAll("labelIds")).toEqual([ + "INBOX", + "STARRED", + ]); + }); + + test("sends metadataHeaders as repeated params only for metadata format", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + format: "metadata", + maxMessages: 5, + metadataHeaders: ["Subject", "From"], + }); + const requests = stubGmail((url) => { + if (isListRequest(url)) { + return { messages: [{ id: "m1" }] }; + } + return { id: "m1" }; + }); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const messageGet = requests.find((request) => !isListRequest(request.url)); + expect(messageGet?.url.searchParams.get("format")).toBe("metadata"); + expect(messageGet?.url.searchParams.getAll("metadataHeaders")).toEqual([ + "Subject", + "From", + ]); + }); + + test("clamps a non-numeric maxMessages down to the minimum", async () => { + const home = await createTempHome(); + // A malformed maxMessages must clamp to 1 rather than propagate NaN. + await writeConnectorConfig(home, { enabled: true, maxMessages: "lots" }); + stubGmail(() => ({ messages: [] })); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const dump = await readMessagesDump(result.rawFiles); + expect(dump.maxMessages).toBe(1); + }); +}); + +describe("gmail query windowing", () => { + // getWindowedGmailQuery must strip any existing date operator from the base + // query and append a single newer_than window derived from windowHours + // (rounded up to whole days), leaving non-date operators intact. + test.each([ + ["in:inbox newer_than:7d", 48, "in:inbox newer_than:2d"], + ["newer_than:5d", 24, "newer_than:1d"], + ["from:me older_than:3d", 24, "from:me newer_than:1d"], + ])( + "rewrites query %s with a %ih window", + async (query, windowHours, expectedQuery) => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + maxMessages: 1, + query, + }); + const requests = stubGmail(() => ({ messages: [] })); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest({ windowHours }); + + expect(result.status).toBe("success"); + const listRequest = requests.find((request) => + isListRequest(request.url), + ); + expect(listRequest?.url.searchParams.get("q")).toBe(expectedQuery); + }, + ); + + test("throws when the Gmail API returns a non-ok status", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: true, maxMessages: 1 }); + stubGmail(() => 500); + const connector = await loadGmailConnector(home); + + await expect(connector.ingest()).rejects.toThrow( + "Gmail API request failed: 500", + ); + }); +}); + +describe("gmail 401 refresh retry", () => { + test("refreshes the access token and retries the request once", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: true, maxMessages: 1 }); + // The mid-flight refresh needs a client id/secret to build the token POST. + process.env.OPENWIKI_GOOGLE_CLIENT_ID = "google-client-id"; + process.env.OPENWIKI_GOOGLE_CLIENT_SECRET = "google-client-secret"; + let listAttempts = 0; + const requests = stubGmail((url) => { + if (url.host === "oauth2.googleapis.com") { + // The refresh grant returns a new access token used on the retry. + return { + access_token: "refreshed-token", + expires_in: 3600, + refresh_token: "gmail-refresh-token", + token_type: "Bearer", + }; + } + if (isListRequest(url)) { + listAttempts += 1; + // First attempt is a 401; the connector must refresh and retry. + return listAttempts === 1 ? 401 : { messages: [] }; + } + return { id: "m1" }; + }); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(listAttempts).toBe(2); + // A token exchange must have occurred against the Google token endpoint. + expect( + requests.some((request) => request.url.host === "oauth2.googleapis.com"), + ).toBe(true); + // The retried list request must carry the refreshed bearer token. + const listRequests = requests.filter((request) => + isListRequest(request.url), + ); + expect(listRequests[1]?.authorization).toBe("Bearer refreshed-token"); + }); +}); diff --git a/test/connectors/sources/slack.test.ts b/test/connectors/sources/slack.test.ts new file mode 100644 index 00000000..8b5f3f4d --- /dev/null +++ b/test/connectors/sources/slack.test.ts @@ -0,0 +1,568 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +// The slack connector's pure logic (self-search query building, timestamp and +// updated-time sorting of untrusted API payloads, conversation dedupe, stream +// normalization, and the error-to-warning downgrades) is private and only +// observable by driving `ingest()` with a stubbed `fetch`. These tests point +// $HOME at a throwaway temp dir, feed controlled Slack Web API responses, and +// assert on the request the connector builds and the normalized raw dump it +// writes to disk. No real Slack API call or OAuth token is involved. + +const originalEnv: Record = {}; +const TRACKED_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "OPENWIKI_SLACK_USER_TOKEN", + "OPENWIKI_SLACK_USER_REFRESH_TOKEN", + "OPENWIKI_SLACK_USER_TOKEN_EXPIRES_AT", + "OPENWIKI_SLACK_USER_TOKEN_TYPE", +] as const; +const tempHomes: string[] = []; + +for (const key of TRACKED_ENV_KEYS) { + originalEnv[key] = process.env[key]; +} + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-slack-source-")); + tempHomes.push(home); + return home; +} + +async function writeConnectorConfig( + home: string, + config: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", "slack"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); +} + +async function loadSlackConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + // A fresh, non-expiring token so getOAuthAccessToken short-circuits without + // touching the OAuth refresh machinery. + process.env.OPENWIKI_SLACK_USER_TOKEN = "slack-user-token"; + delete process.env.OPENWIKI_SLACK_USER_TOKEN_EXPIRES_AT; + const { createSlackConnector } = + await import("../../../src/connectors/sources/slack.ts"); + return createSlackConnector(); +} + +function getRequestUrl(input: string | URL | Request): string { + return input instanceof Request ? input.url : String(input); +} + +interface SlackCall { + method: string; + params: Record; +} + +/** + * Stubs global fetch for the Slack Web API. The handler is keyed on the API + * method (the last URL path segment) and receives the decoded form params, so + * tests can branch on e.g. the `channel` of a conversations.history call. A + * numeric return is treated as an HTTP status (to exercise the non-ok path). + */ +function stubSlack( + handler: (method: string, params: Record) => unknown, +): SlackCall[] { + const calls: SlackCall[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = new URL(getRequestUrl(input)); + const method = url.pathname.split("/").pop() ?? ""; + const params: Record = {}; + if (init?.body instanceof URLSearchParams) { + for (const [key, value] of init.body.entries()) { + params[key] = value; + } + } + calls.push({ method, params }); + const body = handler(method, params); + + if (typeof body === "number") { + return Promise.resolve(new Response("{}", { status: body })); + } + + return Promise.resolve( + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + }), + ); + return calls; +} + +async function readRaw(rawFiles: string[], suffix: string): Promise { + const file = rawFiles.find((entry) => entry.endsWith(suffix)); + expect(file, `expected a raw dump ending in ${suffix}`).toBeDefined(); + return JSON.parse(await readFile(file as string, "utf8")); +} + +const AUTH_OK = { + ok: true, + team: "Example", + team_id: "TABC123", + url: "https://example.slack.com", + user_id: "UABC123", +}; + +afterEach(async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + + for (const key of TRACKED_ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +describe("slack connector gates", () => { + test("skips when config is disabled", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: false }); + const connector = await loadSlackConnector(home); + delete process.env.OPENWIKI_SLACK_USER_TOKEN; + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + }); + + test("errors when the user token env is unset", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: true }); + const connector = await loadSlackConnector(home); + delete process.env.OPENWIKI_SLACK_USER_TOKEN; + + const result = await connector.ingest(); + + expect(result.status).toBe("error"); + expect(result.message).toContain("OPENWIKI_SLACK_USER_TOKEN"); + }); + + test("throws when a Slack API request returns a non-ok status", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + streams: ["my_messages_search"], + }); + stubSlack(() => 500); + const connector = await loadSlackConnector(home); + + await expect(connector.ingest()).rejects.toThrow( + "Slack API request failed: 500", + ); + }); +}); + +describe("slack self-message search normalization", () => { + test("sorts matches by timestamp descending and reports the total", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + myMessagesSearchLimit: 20, + streams: ["my_messages_search"], + }); + const calls = stubSlack((method) => { + if (method === "auth.test") { + return AUTH_OK; + } + if (method === "users.info") { + return { ok: true, user: { id: "UABC123", name: "angel" } }; + } + if (method === "search.messages") { + // Deliberately out of order to prove the connector re-sorts. + return { + ok: true, + messages: { + matches: [ + { + channel: { id: "C1", name: "general" }, + text: "older", + ts: "100.0", + }, + { + channel: { id: "C2", is_im: true }, + text: "newest", + ts: "300.0", + }, + ], + total: 2, + }, + }; + } + return { ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + // The self-search query must target the authenticated user id. + const searchCall = calls.find((call) => call.method === "search.messages"); + expect(searchCall?.params.query).toBe("from:<@UABC123>"); + expect(searchCall?.params.sort).toBe("timestamp"); + expect(searchCall?.params.sort_dir).toBe("desc"); + + const dump = (await readRaw( + result.rawFiles, + "my-messages-search.json", + )) as { + coverage: { query: string; resultCount: number; total?: number }; + userMessages: { message: { text: string } }[]; + }; + expect(dump.userMessages.map((entry) => entry.message.text)).toEqual([ + "newest", + "older", + ]); + expect(dump.coverage.total).toBe(2); + expect(dump.coverage.resultCount).toBe(2); + expect(dump.coverage.query).toBe("from:<@UABC123>"); + }); + + test("downgrades a search API error to a warning and continues", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + streams: ["my_messages_search"], + }); + stubSlack((method) => { + if (method === "auth.test") { + return AUTH_OK; + } + if (method === "users.info") { + return { ok: true, user: { id: "UABC123" } }; + } + if (method === "search.messages") { + // ok:false with a `needed` scope must surface in the thrown message, + // which is then caught and turned into a warning rather than aborting. + return { error: "missing_scope", needed: "search:read", ok: false }; + } + return { ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect( + result.warnings.some( + (warning) => + warning.includes("Slack self-message search failed") && + warning.includes("missing_scope") && + warning.includes("needed=search:read"), + ), + ).toBe(true); + }); + + test("skips self-search with a warning when auth.test omits user_id", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + streams: ["my_messages_search"], + }); + const calls = stubSlack((method) => { + if (method === "auth.test") { + // No user_id: the connector cannot build the self-search query. + return { ok: true, team: "Example", team_id: "TABC123" }; + } + return { ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + // Without a user id, users.info must not be queried either. + expect(calls.some((call) => call.method === "users.info")).toBe(false); + expect( + result.warnings.some((warning) => + warning.includes("auth.test did not return a user_id"), + ), + ).toBe(true); + }); + + test("fails self-search safely when auth.test returns an invalid user_id", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + streams: ["my_messages_search"], + }); + stubSlack((method) => { + if (method === "auth.test") { + // A user_id that does not match the U/W-prefixed Slack id shape must be + // rejected before it is interpolated into the search query. + return { ...AUTH_OK, user_id: "not-valid" }; + } + if (method === "users.info") { + return { ok: true, user: { id: "not-valid" } }; + } + return { ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect( + result.warnings.some( + (warning) => + warning.includes("Slack self-message search failed") && + warning.includes("invalid user_id"), + ), + ).toBe(true); + }); + + test("returns undefined identity user when users.info fails", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + streams: ["my_messages_search"], + }); + stubSlack((method) => { + if (method === "auth.test") { + return AUTH_OK; + } + if (method === "users.info") { + // A failed users.info must be swallowed, leaving identity.user unset + // rather than aborting the whole run. + return { error: "user_not_found", ok: false }; + } + if (method === "search.messages") { + return { ok: true, messages: { matches: [], total: 0 } }; + } + return { ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const identity = (await readRaw(result.rawFiles, "identity.json")) as { + user: unknown; + }; + expect(identity.user ?? null).toBeNull(); + }); +}); + +describe("slack recent-history normalization", () => { + test("collects the user's own messages, dedupes and sorts conversations", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + conversationScanLimit: 10, + enabled: true, + maxConversations: 5, + // A non-finite value must clamp to the minimum rather than crash. + messagesPerConversation: null, + // A bare ["recent_messages"] must be normalized to also run the + // self-search stream that recent-history depends on for the latest msg. + streams: ["recent_messages"], + }); + const calls = stubSlack((method, params) => { + if (method === "auth.test") { + return AUTH_OK; + } + if (method === "users.info") { + return { ok: true, user: { id: "UABC123" } }; + } + if (method === "search.messages") { + // An array (not an object) payload must yield an undefined total and an + // empty match set, forcing the conversations.history fallback path. + return { messages: [], ok: true }; + } + if (method === "conversations.list") { + return { + channels: [ + { id: "C1", name: "alpha", updated: 10 }, + { id: "C2", name: "beta", updated: 30 }, + { id: "C1", name: "alpha-dupe", updated: 10 }, + { id: "C3", name: "gamma" }, + ], + ok: true, + response_metadata: {}, + }; + } + if (method === "conversations.history") { + if (params.channel === "C2") { + // No ts on this self-message: it must sort as timestamp 0 (oldest) + // via the comparator without throwing. + return { + messages: [{ text: "m2", user: "UABC123" }], + ok: true, + }; + } + if (params.channel === "C1") { + return { + messages: [ + { text: "m1", ts: "900.0", user: "UABC123" }, + { text: "other", ts: "800.0", user: "OTHERUSER" }, + ], + ok: true, + }; + } + return { messages: [], ok: true }; + } + return { ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + // The normalization prepended my_messages_search before recent_messages. + const methods = calls.map((call) => call.method); + expect(methods).toContain("search.messages"); + expect( + methods.filter((method) => method === "conversations.history"), + ).toHaveLength(3); + + const dump = (await readRaw(result.rawFiles, "recent-messages.json")) as { + conversations: { conversation: { id: string } }[]; + userMessages: { message: { text: string } }[]; + }; + // Conversations are deduped (C1 once) and sorted by updated desc: C2, C1, C3. + expect(dump.conversations.map((entry) => entry.conversation.id)).toEqual([ + "C2", + "C1", + "C3", + ]); + // Only the authenticated user's messages, newest ts first, across channels. + expect(dump.userMessages.map((entry) => entry.message.text)).toEqual([ + "m1", + "m2", + ]); + }); + + test("warns and handles a missing timestamp when the user has one message", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + maxConversations: 5, + streams: ["recent_messages"], + }); + stubSlack((method, params) => { + if (method === "auth.test") { + return AUTH_OK; + } + if (method === "users.info") { + return { ok: true, user: { id: "UABC123" } }; + } + if (method === "search.messages") { + return { messages: [], ok: true }; + } + if (method === "conversations.list") { + return { + channels: [{ id: "C1", name: "alpha", updated: 10 }], + ok: true, + response_metadata: {}, + }; + } + if (method === "conversations.history" && params.channel === "C1") { + // A single self-message with no ts must sort as timestamp 0 without + // throwing, and trip the low-coverage warning. + return { messages: [{ text: "only", user: "UABC123" }], ok: true }; + } + return { messages: [], ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect( + result.warnings.some((warning) => + warning.includes("one or fewer authenticated-user messages"), + ), + ).toBe(true); + const dump = (await readRaw(result.rawFiles, "recent-messages.json")) as { + userMessages: { message: { text: string } }[]; + }; + expect(dump.userMessages).toHaveLength(1); + }); +}); + +describe("slack assistant search", () => { + test("warns when assistant_search is requested with no queries", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + assistantSearchQueries: [], + enabled: true, + streams: ["assistant_search"], + }); + stubSlack((method) => { + if (method === "auth.test") { + return AUTH_OK; + } + return { ok: true, user: { id: "UABC123" } }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(result.warnings).toContain( + "assistant_search requested but assistantSearchQueries is empty.", + ); + }); + + test("issues one context query per configured query", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + assistantSearchQueries: ["release notes", "roadmap"], + enabled: true, + streams: ["assistant_search"], + }); + const calls = stubSlack((method) => { + if (method === "auth.test") { + return AUTH_OK; + } + if (method === "users.info") { + return { ok: true, user: { id: "UABC123" } }; + } + return { ok: true, results: [] }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const contextCalls = calls.filter( + (call) => call.method === "assistant.search.context", + ); + expect(contextCalls.map((call) => call.params.query)).toEqual([ + "release notes", + "roadmap", + ]); + + const dump = (await readRaw(result.rawFiles, "assistant-search.json")) as { + searches: { query: string }[]; + }; + expect(dump.searches.map((entry) => entry.query)).toEqual([ + "release notes", + "roadmap", + ]); + }); +}); diff --git a/test/connectors/sources/x.test.ts b/test/connectors/sources/x.test.ts new file mode 100644 index 00000000..50e7c96e --- /dev/null +++ b/test/connectors/sources/x.test.ts @@ -0,0 +1,338 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +// The x connector's pure logic (stream/URL construction, pagination-token +// following, since_id carry-over from state, and window start_time derivation) +// is private and only observable by driving `ingest()` with a stubbed `fetch`. +// These tests point $HOME at a throwaway temp dir so the connector reads a +// controlled on-disk config/state, then assert on the exact request URLs the +// connector builds and on the raw dump / state it writes back. No real X API +// call or OAuth token is involved. + +const originalEnv: Record = {}; +const TRACKED_ENV_KEYS = [ + "HOME", + "USERPROFILE", + "OPENWIKI_X_ACCESS_TOKEN", + "OPENWIKI_X_CLIENT_ID", + "OPENWIKI_X_REFRESH_TOKEN", + "OPENWIKI_X_TOKEN_EXPIRES_AT", + "OPENWIKI_X_TOKEN_TYPE", +] as const; +const tempHomes: string[] = []; + +for (const key of TRACKED_ENV_KEYS) { + originalEnv[key] = process.env[key]; +} + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-x-source-")); + tempHomes.push(home); + return home; +} + +async function writeConnectorConfig( + home: string, + config: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", "x"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); +} + +async function writeConnectorState( + home: string, + state: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", "x"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "state.json"), + `${JSON.stringify(state, null, 2)}\n`, + "utf8", + ); +} + +async function readConnectorState(home: string): Promise<{ + latestIds?: Record; +}> { + const raw = await readFile( + path.join(home, ".openwiki", "connectors", "x", "state.json"), + "utf8", + ); + return JSON.parse(raw) as { latestIds?: Record }; +} + +async function loadXConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + // A fresh, non-expiring token so getOAuthAccessToken short-circuits without + // touching the OAuth refresh machinery. + process.env.OPENWIKI_X_ACCESS_TOKEN = "x-access-token"; + delete process.env.OPENWIKI_X_TOKEN_EXPIRES_AT; + const { createXConnector } = + await import("../../../src/connectors/sources/x.ts"); + return createXConnector(); +} + +function getRequestUrl(input: string | URL | Request): string { + return input instanceof Request ? input.url : String(input); +} + +/** + * Stubs global fetch with a handler keyed on the request URL pathname and + * records every requested URL so tests can assert on the built query strings. + */ +function stubFetchByPath( + handler: (pathname: string, url: URL) => unknown, +): URL[] { + const requests: URL[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = new URL(getRequestUrl(input)); + requests.push(url); + const body = handler(url.pathname, url); + + if (typeof body === "number") { + return Promise.resolve(new Response("{}", { status: body })); + } + + return Promise.resolve( + new Response(JSON.stringify(body), { + headers: { "Content-Type": "application/json" }, + status: 200, + }), + ); + }), + ); + return requests; +} + +afterEach(async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + + for (const key of TRACKED_ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +describe("x connector authenticated-user resolution", () => { + test("resolves the user id from /2/users/me when config omits userId", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + maxPagesPerStream: 1, + streams: ["user_posts"], + }); + const requests = stubFetchByPath((pathname) => { + if (pathname === "/2/users/me") { + return { data: { id: "U777" } }; + } + return { data: [{ id: "t1" }], meta: {} }; + }); + const connector = await loadXConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + // The id from /2/users/me must flow into the per-user stream path. + expect(requests.map((url) => url.pathname)).toEqual([ + "/2/users/me", + "/2/users/U777/tweets", + ]); + }); + + // getNestedString must reject every shape that is not a plain object chain + // ending in a string, otherwise a malformed /users/me payload would silently + // yield an empty user id and build a `/users//tweets` request. + test.each([ + ["data key missing", {}], + ["data is null", { data: null }], + ["id key missing", { data: {} }], + ["id is not a string", { data: { id: 5 } }], + ])("throws when /2/users/me payload has %s", async (_label, payload) => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + streams: ["user_posts"], + }); + stubFetchByPath((pathname) => (pathname === "/2/users/me" ? payload : {})); + const connector = await loadXConnector(home); + + await expect(connector.ingest()).rejects.toThrow( + "Could not resolve authenticated X user ID", + ); + }); +}); + +describe("x connector gates", () => { + test("skips when config is disabled", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: false }); + const connector = await loadXConnector(home); + delete process.env.OPENWIKI_X_ACCESS_TOKEN; + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + }); + + test("errors when the access token env is unset", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { enabled: true }); + const connector = await loadXConnector(home); + delete process.env.OPENWIKI_X_ACCESS_TOKEN; + + const result = await connector.ingest(); + + expect(result.status).toBe("error"); + expect(result.message).toContain("OPENWIKI_X_ACCESS_TOKEN"); + }); +}); + +describe("x connector request construction", () => { + test("builds the per-stream path for every non-list stream", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + maxPagesPerStream: 1, + streams: ["home_timeline", "mentions", "bookmarks"], + userId: "U1", + }); + const requests = stubFetchByPath(() => ({ data: [], meta: {} })); + const connector = await loadXConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + // getStreamPath must map each stream keyword to its X API v2 endpoint. + expect(requests.map((url) => url.pathname)).toEqual([ + "/2/users/U1/timelines/reverse_chronological", + "/2/users/U1/mentions", + "/2/users/U1/bookmarks", + ]); + // bookmarks intentionally omits since_id/start_time (see ingest branch). + const bookmarks = requests.find((url) => + url.pathname.endsWith("/bookmarks"), + ); + expect(bookmarks?.searchParams.get("start_time")).toBeNull(); + }); + + test("throws when a stream request returns a non-ok status", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + maxPagesPerStream: 1, + streams: ["user_posts"], + userId: "U1", + }); + stubFetchByPath(() => 500); + const connector = await loadXConnector(home); + + await expect(connector.ingest()).rejects.toThrow( + "X API request failed: 500", + ); + }); + + test("paginates each configured list and carries newest id into state", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + listIds: ["L1"], + maxPagesPerStream: 2, + streams: ["list_posts"], + userId: "U1", + }); + // A prior run's newest id must be sent as since_id on the first page. + await writeConnectorState(home, { + latestIds: { "list_posts:L1": "prev-id" }, + version: 1, + }); + const requests = stubFetchByPath((_pathname, url) => { + if (url.searchParams.get("pagination_token")) { + // Second page: no next_token stops the loop before maxPages is hit. + return { data: [{ id: "b" }], meta: { result_count: 1 } }; + } + return { + data: [{ id: "a" }], + meta: { newest_id: "n1", next_token: "NT" }, + }; + }); + const connector = await loadXConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const listRequests = requests.filter((url) => + url.pathname.startsWith("/2/lists/"), + ); + expect(listRequests).toHaveLength(2); + expect(listRequests[0]?.pathname).toBe("/2/lists/L1/tweets"); + // First page forwards the stored since_id and no pagination token. + expect(listRequests[0]?.searchParams.get("since_id")).toBe("prev-id"); + expect(listRequests[0]?.searchParams.get("pagination_token")).toBeNull(); + // Second page follows the returned next_token. + expect(listRequests[1]?.searchParams.get("pagination_token")).toBe("NT"); + + // The newest id from the first page must be persisted for the next run. + const state = await readConnectorState(home); + expect(state.latestIds?.["list_posts:L1"]).toBe("n1"); + + const listFile = result.rawFiles.find((file) => + file.endsWith("list-L1.json"), + ); + expect(listFile).toBeDefined(); + const dump = JSON.parse(await readFile(listFile as string, "utf8")) as { + pages: unknown[]; + }; + expect(dump.pages).toHaveLength(2); + }); + + test("adds a window start_time derived from windowHours", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + maxPagesPerStream: 1, + streams: ["user_posts"], + userId: "U1", + }); + const requests = stubFetchByPath(() => ({ data: [], meta: {} })); + const connector = await loadXConnector(home); + + const before = Date.now(); + const result = await connector.ingest({ windowHours: 24 }); + + expect(result.status).toBe("success"); + const startTime = requests[0]?.searchParams.get("start_time"); + expect(startTime).toBeTruthy(); + const startMs = Date.parse(startTime as string); + // 24h window => start_time is ~24h before now, and a valid ISO instant. + expect(Number.isFinite(startMs)).toBe(true); + expect(startMs).toBeLessThanOrEqual(before); + expect(startMs).toBeGreaterThan(before - 25 * 60 * 60 * 1000); + + const dump = JSON.parse(await readFile(result.rawFiles[0], "utf8")) as { + windowHours: number | null; + }; + expect(dump.windowHours).toBe(24); + }); +}); diff --git a/test/connectors/tools.test.ts b/test/connectors/tools.test.ts new file mode 100644 index 00000000..b4308329 --- /dev/null +++ b/test/connectors/tools.test.ts @@ -0,0 +1,465 @@ +import type { StructuredToolInterface } from "@langchain/core/tools"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempHomes: string[] = []; + +/** + * Records the ingest calls a mocked connector registry receives so tests can + * assert how the tool layer coerces raw JSON input into ingest options. + */ +interface IngestCall { + id: string; + options: unknown; +} + +afterEach(async () => { + vi.resetModules(); + vi.doUnmock("../../src/connectors/registry.ts"); + vi.doUnmock("../../src/connectors/mcp-runtime.ts"); + restoreEnv("HOME", originalHome); + restoreEnv("USERPROFILE", originalUserProfile); + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +describe("connector tool definitions", () => { + test("exposes exactly the expected tool surface", async () => { + const tools = await loadRealTools(); + + expect(tools.map((tool) => tool.name).sort()).toEqual([ + "openwiki_call_mcp_tool", + "openwiki_ingest_all_connectors", + "openwiki_ingest_connector", + "openwiki_list_connectors", + "openwiki_list_mcp_tools", + "openwiki_list_raw_items", + "openwiki_read_raw_item", + ]); + }); + + test("every tool has a description and a closed object schema", async () => { + const tools = await loadRealTools(); + + for (const tool of tools) { + expect(tool.description.length).toBeGreaterThan(0); + const schema = getRawSchema(tool); + expect(schema.type).toBe("object"); + // Tools that take structured args pin additionalProperties:false so the + // model cannot smuggle unexpected fields past the schema boundary. The + // args passthrough object on call_mcp_tool is the deliberate exception. + if (tool.name !== "openwiki_call_mcp_tool") { + expect(schema.additionalProperties).toBe(false); + } + } + }); + + test("ingest tool constrains connectorId to the built-in enum", async () => { + const tools = await loadRealTools(); + const schema = getRawSchema(getTool(tools, "openwiki_ingest_connector")); + const connectorId = ( + schema.properties as Record + ).connectorId; + + expect(connectorId.enum).toEqual([ + "git-repo", + "google", + "hackernews", + "notion", + "slack", + "web-search", + "x", + ]); + expect(schema.required).toEqual(["connectorId"]); + }); + + test("call_mcp_tool requires connectorId and toolName and allows free-form args", async () => { + const tools = await loadRealTools(); + const schema = getRawSchema(getTool(tools, "openwiki_call_mcp_tool")); + const properties = schema.properties as Record< + string, + { additionalProperties?: boolean; enum?: string[] } + >; + + expect(schema.required).toEqual(["connectorId", "toolName"]); + // Only MCP-backed connectors are callable, and arbitrary tool arguments are + // permitted because each MCP tool defines its own input schema downstream. + expect(properties.connectorId.enum).toEqual(["notion"]); + expect(properties.args.additionalProperties).toBe(true); + }); +}); + +describe("schema boundary for untrusted tool input", () => { + // The internal coercion guards (getConnectorId, getNumberInput, etc.) sit + // BEHIND the JSON-schema layer that .invoke enforces, so malformed input is + // rejected at the boundary before those guards run. These cases pin that the + // boundary is actually enforced for the arguments an LLM controls. + test("rejects a connectorId outside the enum", async () => { + const tools = await loadRealTools(); + + await expect( + getTool(tools, "openwiki_list_raw_items").invoke({ + connectorId: "not-a-real-connector", + }), + ).rejects.toThrow(/did not match expected schema/u); + }); + + test("rejects a non-numeric maxBytes", async () => { + const tools = await loadRealTools(); + + await expect( + getTool(tools, "openwiki_read_raw_item").invoke({ + connectorId: "x", + path: "run/file.json", + maxBytes: "lots" as never, + }), + ).rejects.toThrow(/did not match expected schema/u); + }); + + test("rejects a missing required toolName", async () => { + const tools = await loadRealTools(); + + await expect( + getTool(tools, "openwiki_call_mcp_tool").invoke({ + connectorId: "notion", + } as never), + ).rejects.toThrow(/did not match expected schema/u); + }); +}); + +describe("openwiki_list_connectors", () => { + test("reports env presence without ever returning secret values", async () => { + const home = await createTempHome(); + // A required-env value that must be reported as present-but-not-exposed. + process.env.TAVILY_API_KEY = "super-secret-tavily-value"; + const tools = await loadRealTools(home); + + const result = await invokeJson( + getTool(tools, "openwiki_list_connectors"), + {}, + ); + + expect(result.note).toMatch(/Secret values are never returned/u); + expect(result.connectors.length).toBeGreaterThan(0); + + const serialized = JSON.stringify(result); + // The presence-only invariant: the actual secret string must not appear in + // the tool output under any key. + expect(serialized).not.toContain("super-secret-tavily-value"); + + for (const connector of result.connectors) { + for (const status of connector.requiredEnvStatus) { + expect(typeof status.set).toBe("boolean"); + expect(status).not.toHaveProperty("value"); + } + // A fresh temp HOME has no connector config files written yet. + expect(connector.configExists).toBe(false); + expect(connector.readyForIngestion).toBe(false); + } + + delete process.env.TAVILY_API_KEY; + }); + + test("marks a connector auth-configured only when all required env is set", async () => { + const home = await createTempHome(); + const web = await readWebSearchConnector(home); + // web-search requires TAVILY_API_KEY; drive both branches of the presence + // check to confirm authConfigured tracks env, not config-file existence. + delete process.env.TAVILY_API_KEY; + + let tools = await loadRealTools(home); + let result = await invokeJson( + getTool(tools, "openwiki_list_connectors"), + {}, + ); + expect(findConnector(result, web.id).authConfigured).toBe(false); + + process.env.TAVILY_API_KEY = "present"; + tools = await loadRealTools(home); + result = await invokeJson( + getTool(tools, "openwiki_list_connectors"), + {}, + ); + expect(findConnector(result, web.id).authConfigured).toBe(true); + + delete process.env.TAVILY_API_KEY; + }); +}); + +describe("ingestion tool delegation", () => { + test("coerces raw input into ingest options and delegates to the registry", async () => { + const calls: IngestCall[] = []; + const tools = await loadToolsWithMockRegistry(calls); + + const result = await invokeJson<{ status: string }>( + getTool(tools, "openwiki_ingest_connector"), + { + connectorId: "git-repo", + limit: 5, + streams: ["commits", "branches"], + windowHours: 24, + }, + ); + + expect(calls).toHaveLength(1); + expect(calls[0]?.id).toBe("git-repo"); + // The tool must forward exactly the coerced options object; numbers and the + // string array pass through, and it is the only connector invoked. + expect(calls[0]?.options).toEqual({ + limit: 5, + streams: ["commits", "branches"], + windowHours: 24, + }); + expect(result.status).toBe("success"); + }); + + test("defaults absent optional ingest options to undefined", async () => { + const calls: IngestCall[] = []; + const tools = await loadToolsWithMockRegistry(calls); + + await invokeJson(getTool(tools, "openwiki_ingest_connector"), { + connectorId: "git-repo", + }); + + expect(calls[0]?.options).toEqual({ + limit: undefined, + streams: undefined, + windowHours: undefined, + }); + }); + + test("ingest_all runs every configured connector and wraps the results", async () => { + const calls: IngestCall[] = []; + const tools = await loadToolsWithMockRegistry(calls); + + const result = await invokeJson<{ results: { connectorId: string }[] }>( + getTool(tools, "openwiki_ingest_all_connectors"), + {}, + ); + + // ingest_all fans out to each registry entry with no per-call options. + expect(calls.map((call) => call.id)).toEqual(["git-repo", "notion"]); + expect(calls.every((call) => call.options === undefined)).toBe(true); + expect(result.results).toHaveLength(2); + }); +}); + +describe("mcp tool delegation", () => { + test("list_mcp_tools delegates discovery for an MCP-backed connector", async () => { + const tools = await loadToolsWithMockMcpRuntime({ isMcp: true }); + + const result = await invokeJson<{ tools: { name: string }[] }>( + getTool(tools, "openwiki_list_mcp_tools"), + { connectorId: "notion" }, + ); + + expect(result.tools).toEqual([{ name: "discovered_tool" }]); + }); + + test("list_mcp_tools rejects a connector that is not MCP-backed", async () => { + // Defense in depth beyond the schema enum: even a schema-valid connectorId + // is refused if the runtime does not classify it as MCP-backed. + const tools = await loadToolsWithMockMcpRuntime({ isMcp: false }); + + await expect( + getTool(tools, "openwiki_list_mcp_tools").invoke({ + connectorId: "notion", + }), + ).rejects.toThrow(/not MCP-backed/u); + }); + + test("call_mcp_tool forwards the exact tool name and args", async () => { + const calls: { name: string; args: unknown }[] = []; + const tools = await loadToolsWithMockMcpRuntime({ isMcp: true, calls }); + + await invokeJson(getTool(tools, "openwiki_call_mcp_tool"), { + connectorId: "notion", + toolName: "search_pages", + args: { query: "Applied AI" }, + }); + + expect(calls[0]?.name).toBe("search_pages"); + expect(calls[0]?.args).toEqual({ query: "Applied AI" }); + }); + + test("call_mcp_tool defaults missing args to an empty object", async () => { + const calls: { name: string; args: unknown }[] = []; + const tools = await loadToolsWithMockMcpRuntime({ isMcp: true, calls }); + + await invokeJson(getTool(tools, "openwiki_call_mcp_tool"), { + connectorId: "notion", + toolName: "search_pages", + }); + + expect(calls[0]?.args).toEqual({}); + }); +}); + +interface ListConnectorsResult { + connectors: { + authConfigured: boolean; + configExists: boolean; + id: string; + readyForIngestion: boolean; + requiredEnvStatus: { key: string; set: boolean }[]; + }[]; + note: string; +} + +async function loadRealTools( + home?: string, +): Promise { + vi.resetModules(); + if (home) { + process.env.HOME = home; + process.env.USERPROFILE = home; + } + const { createOpenWikiConnectorTools } = + await import("../../src/connectors/tools.ts"); + + return createOpenWikiConnectorTools(); +} + +/** + * Loads the tools with a stubbed registry so ingestion delegation can be + * observed without running any real connector I/O. + */ +async function loadToolsWithMockRegistry( + calls: IngestCall[], +): Promise { + vi.resetModules(); + vi.doMock("../../src/connectors/registry.ts", () => ({ + isConnectorId: (value: string) => ["git-repo", "notion"].includes(value), + createConnectorRegistry: () => ({ + "git-repo": makeFakeConnector("git-repo", calls), + notion: makeFakeConnector("notion", calls), + }), + })); + const { createOpenWikiConnectorTools } = + await import("../../src/connectors/tools.ts"); + + return createOpenWikiConnectorTools(); +} + +/** + * Loads the tools with a stubbed MCP runtime so tool discovery/calls are + * observed without spawning or contacting a live MCP server. + */ +async function loadToolsWithMockMcpRuntime(config: { + calls?: { name: string; args: unknown }[]; + isMcp: boolean; +}): Promise { + vi.resetModules(); + vi.doMock("../../src/connectors/mcp-runtime.ts", () => ({ + isMcpConnectorId: () => config.isMcp, + discoverMcpConnectorTools: () => + Promise.resolve({ tools: [{ name: "discovered_tool" }] }), + callMcpConnectorTool: ( + _id: string, + name: string, + args: Record, + ) => { + config.calls?.push({ name, args }); + return Promise.resolve({ ok: true }); + }, + })); + const { createOpenWikiConnectorTools } = + await import("../../src/connectors/tools.ts"); + + return createOpenWikiConnectorTools(); +} + +function makeFakeConnector(id: string, calls: IngestCall[]) { + return { + id, + ingest: (options?: unknown) => { + calls.push({ id, options }); + return Promise.resolve({ connectorId: id, status: "success" }); + }, + }; +} + +/** + * Reads the real web-search connector definition so tests can key off its + * actual id/requiredEnv rather than hard-coding assumptions. + */ +async function readWebSearchConnector(home: string) { + vi.resetModules(); + process.env.HOME = home; + process.env.USERPROFILE = home; + const { createConnectorRegistry } = + await import("../../src/connectors/registry.ts"); + const registry = createConnectorRegistry(); + + return registry["web-search"]; +} + +function getTool( + tools: StructuredToolInterface[], + name: string, +): StructuredToolInterface { + const tool = tools.find((candidate) => candidate.name === name); + + if (!tool) { + throw new Error(`Missing connector tool: ${name}`); + } + + return tool; +} + +interface RawJsonSchema { + additionalProperties?: boolean; + properties?: Record; + required?: string[]; + type?: string; +} + +function getRawSchema(tool: StructuredToolInterface): RawJsonSchema { + return (tool as unknown as { schema: RawJsonSchema }).schema; +} + +function findConnector(result: ListConnectorsResult, id: string) { + const connector = result.connectors.find((entry) => entry.id === id); + + if (!connector) { + throw new Error(`Connector not found in result: ${id}`); + } + + return connector; +} + +async function invokeJson( + tool: StructuredToolInterface, + input: Record, +): Promise { + const result: unknown = await tool.invoke(input); + + if (typeof result !== "string") { + throw new Error("Expected connector tool to return a JSON string."); + } + + return JSON.parse(result) as T; +} + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-tools-")); + tempHomes.push(home); + + return home; +} + +function restoreEnv(key: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } +} diff --git a/test/ingestion/code-mode.test.ts b/test/ingestion/code-mode.test.ts index 45b77c6c..89df9991 100644 --- a/test/ingestion/code-mode.test.ts +++ b/test/ingestion/code-mode.test.ts @@ -1,8 +1,12 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test } from "vitest"; -import { ensureCodeModeRepoSetup } from "../../src/ingestion/code-mode.ts"; +import { + ensureCodeModeRepoSetup, + runCodeModeConnectors, +} from "../../src/ingestion/code-mode.ts"; +import type { OpenWikiRunEvent } from "../../src/agent/types.ts"; const SNIPPET_START = ""; const SNIPPET_END = ""; @@ -248,3 +252,59 @@ jobs: expect(await readIfPresent(workflowPath)).toBe(customizedWorkflow); }); }); + +describe("runCodeModeConnectors", () => { + // The only code-mode connector is LangSmith, which reads committed repo config + // and cleanly skips (no network) when a repo has not configured it. That lets + // us exercise the loop, the fail-open skip, and the "nothing to append" merge + // without reaching a real API. Making a connector succeed needs live creds and + // is left to integration tests. + + test("returns the base message unchanged when no connector contributes", async () => { + const repo = await createTempRepo(); + const base = "Base agent instructions."; + + const result = await runCodeModeConnectors(repo, base); + + expect(result).toBe(base); + }); + + test("returns undefined when there is no base message and nothing contributes", async () => { + const repo = await createTempRepo(); + + expect(await runCodeModeConnectors(repo, undefined)).toBeUndefined(); + }); + + test("emits progress for the pull it attempts, then the skip reason", async () => { + const repo = await createTempRepo(); + const events: OpenWikiRunEvent[] = []; + + await runCodeModeConnectors(repo, "base", (event) => { + events.push(event); + }); + + const text = events + .filter((event) => event.type === "text") + .map((event) => event.text) + .join(""); + // The pull is announced so the pre-agent gap reads as progress, and the + // unconfigured repo reports the skip rather than silently doing nothing. + expect(text).toContain("Ingesting from"); + expect(text).toContain("LangSmith is not configured for this repository"); + }); + + test("tolerates a present last-update timestamp without failing", async () => { + const repo = await createTempRepo(); + // A valid openwiki/.last-update.json exercises the metadata-read and + // numeric-window branch; the unconfigured connector still skips, so the base + // message survives unchanged. + await mkdir(path.join(repo, "openwiki"), { recursive: true }); + await writeFile( + path.join(repo, "openwiki", ".last-update.json"), + JSON.stringify({ updatedAt: new Date().toISOString() }), + "utf8", + ); + + expect(await runCodeModeConnectors(repo, "keep me")).toBe("keep me"); + }); +}); diff --git a/test/ingestion/ingestion.test.ts b/test/ingestion/ingestion.test.ts new file mode 100644 index 00000000..110b266d --- /dev/null +++ b/test/ingestion/ingestion.test.ts @@ -0,0 +1,110 @@ +import { describe, expect, test } from "vitest"; +import { + CONNECTOR_IDS, + createConnectorRegistry, +} from "../../src/connectors/registry.ts"; +import { + createConnectorSynthesisGuidance, + parseIngestionTarget, +} from "../../src/ingestion/ingestion.ts"; + +// These cover the pure, dependency-free surface of ingestion.ts. The +// runOpenWikiIngestion orchestrator loads env, ensures the home dir, and drives +// the agent, so it is integration-test territory and is left out here. + +const registry = createConnectorRegistry(); + +describe("parseIngestionTarget", () => { + test('parses the literal "all" target', () => { + expect(parseIngestionTarget("all")).toBe("all"); + }); + + test("parses every known connector id as its bare string form", () => { + // A connector id is checked before the source-instance branch, so it round + // trips as a plain string rather than a { source-instance } target. + for (const id of CONNECTOR_IDS) { + expect(parseIngestionTarget(id)).toBe(id); + } + }); + + test("wraps a safe source-instance id in a source-instance target", () => { + for (const id of ["gmail-work", "notion_2", "a", "A.b-c_1"]) { + expect(parseIngestionTarget(id)).toEqual({ + kind: "source-instance", + id, + }); + } + }); + + test("rejects ids that attempt path traversal or contain separators", () => { + // isSafeSourceInstanceId is the containment gate for a value that later names + // a per-source path segment, so a traversal or separator must not parse. + for (const unsafe of [ + "../etc/passwd", + "..", + "foo/bar", + "foo\\bar", + "foo bar", + "sub/../thing", + ]) { + expect(parseIngestionTarget(unsafe)).toBeNull(); + } + }); + + test("rejects ids that do not start with an alphanumeric character", () => { + // The first character must be [A-Za-z0-9]; a leading dot/dash/underscore + // (including a bare dotfile-style name) is refused. + for (const unsafe of ["", "_leading", "-leading", ".hidden", " leading"]) { + expect(parseIngestionTarget(unsafe)).toBeNull(); + } + }); + + test("rejects an id longer than the 120-character bound", () => { + // The pattern allows a first char plus up to 119 more (120 total); one over + // that boundary must fail while exactly 120 passes. + const maxLength = `a${"b".repeat(119)}`; + expect(maxLength).toHaveLength(120); + expect(parseIngestionTarget(maxLength)).toEqual({ + kind: "source-instance", + id: maxLength, + }); + expect(parseIngestionTarget(`${maxLength}c`)).toBeNull(); + }); +}); + +describe("createConnectorSynthesisGuidance per connector", () => { + // Each connector id selects a distinct arm of the switch. Assert the arm by a + // marker unique to it, so a mis-wired case (or a dropped arm) is caught. + const markers: Record = { + "git-repo": "Use repository paths, branches, HEADs", + google: "For Gmail evidence, classify each candidate item", + hackernews: "Treat low-engagement Hacker News items as watchlist", + langsmith: "openwiki_read_raw_item", + notion: "Prefer Notion pages edited in the ingestion window", + slack: "Route direct work requests, mentions, deadlines", + "web-search": "Treat web search results as source-backed only", + x: "Treat bookmarks and liked/saved social content as saved-context", + }; + + test("returns non-empty guidance carrying the connector's own marker", () => { + for (const id of CONNECTOR_IDS) { + const guidance = createConnectorSynthesisGuidance(registry[id]); + expect(guidance, `${id} should have guidance`).toBeTruthy(); + expect(guidance).toContain(markers[id]); + } + }); + + test("does not leak one connector's marker into another's guidance", () => { + // The arms are mutually exclusive, so a marker unique to one connector must + // not appear in any other connector's guidance. + for (const id of CONNECTOR_IDS) { + const guidance = createConnectorSynthesisGuidance(registry[id]) ?? ""; + for (const otherId of CONNECTOR_IDS) { + if (otherId === id) { + continue; + } + expect(guidance).not.toContain(markers[otherId]); + } + } + }); +}); diff --git a/test/scheduling/schedule-operations.test.ts b/test/scheduling/schedule-operations.test.ts new file mode 100644 index 00000000..a8dad5d4 --- /dev/null +++ b/test/scheduling/schedule-operations.test.ts @@ -0,0 +1,397 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test } from "vitest"; +import { createEmptyOnboardingConfig } from "../../src/setup/onboarding.ts"; +import type { OpenWikiOnboardingConfig } from "../../src/setup/onboarding.ts"; +import { + deleteConnectorSchedules, + installConnectorSchedule, + installOpenWikiPowerSchedule, + listConnectorSchedules, + pauseConnectorSchedules, + resumeConnectorSchedules, +} from "../../src/scheduling/schedules.ts"; + +// These exercise the reachable, non-shelling surface of the schedule lifecycle +// helpers. Two techniques keep them off child_process: +// 1. Some branches (invalid/too-complex cron, "no representable power window") +// return before any platform check, so they are pure on macOS as-is. +// 2. The native paths guard on `process.platform === "darwin"`; on every other +// platform launchctl/pmset/unload/remove are documented no-ops. We stub the +// platform to a non-Darwin value to drive the graceful-degradation paths and +// the pure wake-window computation without spawning launchctl or osascript. +// The launchctl/crontab/osascript success paths themselves are left for +// integration tests, as the existing suite notes. + +const ORIGINAL_PLATFORM = process.platform; + +/** + * Reassigns `process.platform` for the duration of a test. schedules.ts reads + * it at call time, so this reliably selects the non-Darwin no-op behavior. + */ +function stubPlatform(value: NodeJS.Platform): void { + Object.defineProperty(process, "platform", { + configurable: true, + value, + }); +} + +let tempDirs: string[] = []; + +afterEach(async () => { + // Always restore the real platform so one test's stub can't leak into the + // next (or into the shared, macOS-real suite). + Object.defineProperty(process, "platform", { + configurable: true, + value: ORIGINAL_PLATFORM, + }); + + await Promise.all( + tempDirs.map((dir) => rm(dir, { force: true, recursive: true })), + ); + tempDirs = []; +}); + +/** + * Builds an onboarding config carrying a single ingestion schedule, overriding + * only the fields a test cares about. + */ +function configWithSchedule( + expression: string, + overrides: Partial = {}, +): OpenWikiOnboardingConfig { + return { + ...createEmptyOnboardingConfig(), + ingestionSchedule: { + description: "All ingestion", + expression, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }, + }; +} + +describe("installConnectorSchedule", () => { + test("throws the validation error for an unparseable cron before touching the system", async () => { + await expect( + installConnectorSchedule({ + connectorId: "git-repo", + cronExpression: "not a cron", + cwd: "/repo", + }), + ).rejects.toThrow(); + }); + + test("returns a 'too complex for launchd' warning for a valid-but-unrepresentable cron", async () => { + // `*/15 2 * * *` parses as cron but has no single-value launchd calendar + // interval, so install must degrade to a saved-only warning rather than + // writing a plist. This branch runs on macOS without shelling. + const result = await installConnectorSchedule({ + connectorId: "git-repo", + cronExpression: "*/15 2 * * *", + cwd: "/repo", + }); + + expect(result.expression).toBe("*/15 2 * * *"); + expect(result.launchAgentPath).toBeUndefined(); + expect(result.warning).toMatch(/too complex/i); + expect(result.description.length).toBeGreaterThan(0); + }); + + test("saves without native install on non-macOS platforms", async () => { + stubPlatform("linux"); + + const result = await installConnectorSchedule({ + connectorId: "git-repo", + cronExpression: "0 2 * * *", + cwd: "/repo", + }); + + expect(result.expression).toBe("0 2 * * *"); + expect(result.launchAgentPath).toBeUndefined(); + expect(result.warning).toMatch(/macOS-only/i); + }); +}); + +describe("installOpenWikiPowerSchedule", () => { + test("skips when no schedule is saved", async () => { + const result = await installOpenWikiPowerSchedule( + createEmptyOnboardingConfig(), + ); + + expect(result.enabled).toBe(false); + expect(result.wakeTime).toBe(""); + expect(result.warning).toMatch(/no saved schedules/i); + }); + + test("skips when the schedule cannot be a simple repeat window (day-of-month restricted)", async () => { + const result = await installOpenWikiPowerSchedule( + configWithSchedule("0 2 5 * *"), + ); + + expect(result.enabled).toBe(false); + expect(result.warning).toMatch(/no saved schedules/i); + }); + + test("skips when weekday is a range that maps to no single pmset day", async () => { + // `1-5` is not a single weekday number, so parsePmsetDays yields null and + // the whole window is rejected. + const result = await installOpenWikiPowerSchedule( + configWithSchedule("0 2 * * 1-5"), + ); + + expect(result.enabled).toBe(false); + expect(result.warning).toMatch(/no saved schedules/i); + }); + + test("skips when the computed wake time would fall before midnight", async () => { + // Midnight run: wake = 0 - 2 minutes < 0, which cannot be expressed as a + // same-day repeat wake, so the window is rejected. + const result = await installOpenWikiPowerSchedule( + configWithSchedule("0 0 * * *"), + ); + + expect(result.enabled).toBe(false); + expect(result.warning).toMatch(/no saved schedules/i); + }); + + test("skips when the computed sleep time would spill past midnight", async () => { + // 23:55 run: sleep = 1435 + 30 = 1465 >= 1440, past midnight, so rejected. + const result = await installOpenWikiPowerSchedule( + configWithSchedule("55 23 * * *"), + ); + + expect(result.enabled).toBe(false); + expect(result.warning).toMatch(/no saved schedules/i); + }); + + test("computes a wake/sleep window (with default days) but does not install off macOS", async () => { + stubPlatform("linux"); + + // 02:00 daily -> wake 2 min earlier, sleep 30 min later, all seven days. + const result = await installOpenWikiPowerSchedule( + configWithSchedule("0 2 * * *"), + ); + + expect(result).toEqual({ + days: "MTWRFSU", + enabled: false, + sleepTime: "02:30:00", + wakeTime: "01:58:00", + warning: "Wake setup is currently macOS-only.", + }); + }); + + test.each([ + ["0", "U"], + ["1", "M"], + ["2", "T"], + ["3", "W"], + ["4", "R"], + ["5", "F"], + ["6", "S"], + ["7", "U"], + ])( + "maps cron weekday %s to pmset day %s in the wake window", + async (weekday, pmsetDay) => { + stubPlatform("linux"); + + // 04:00 keeps wake (03:58) and sleep (04:30) inside the same day for every + // weekday, isolating the weekday-to-pmset-letter mapping (7 normalizes to + // Sunday just like 0). + const result = await installOpenWikiPowerSchedule( + configWithSchedule(`0 4 * * ${weekday}`), + ); + + expect(result.days).toBe(pmsetDay); + expect(result.wakeTime).toBe("03:58:00"); + expect(result.sleepTime).toBe("04:30:00"); + }, + ); +}); + +describe("listConnectorSchedules", () => { + test("returns an empty list when nothing is scheduled", async () => { + expect(await listConnectorSchedules(createEmptyOnboardingConfig())).toEqual( + [], + ); + }); + + test("reports a paused schedule as unloaded without probing launchctl", async () => { + // A paused schedule short-circuits the loaded check, so this holds on any + // platform and never shells out. + const config = configWithSchedule("0 2 * * *", { + pausedAt: "2026-01-02T00:00:00.000Z", + warning: "some warning", + }); + + const [status] = await listConnectorSchedules(config); + + expect(status).toMatchObject({ + displayName: "All ingestion", + expression: "0 2 * * *", + launchAgentLoaded: false, + launchAgentPlistExists: false, + pausedAt: "2026-01-02T00:00:00.000Z", + sourceInstanceId: "all", + warning: "some warning", + }); + expect(status.launchAgentPath).toBeUndefined(); + }); + + test("checks plist existence on disk and treats non-Darwin as never loaded", async () => { + stubPlatform("linux"); + + const dir = await mkdtemp(path.join(os.tmpdir(), "openwiki-sched-")); + tempDirs.push(dir); + const plistPath = path.join(dir, "agent.plist"); + await writeFile(plistPath, "", "utf8"); + + const present = await listConnectorSchedules( + configWithSchedule("0 2 * * *", { launchAgentPath: plistPath }), + ); + expect(present[0].launchAgentLoaded).toBe(false); + expect(present[0].launchAgentPlistExists).toBe(true); + + const absent = await listConnectorSchedules( + configWithSchedule("0 2 * * *", { + launchAgentPath: path.join(dir, "missing.plist"), + }), + ); + expect(absent[0].launchAgentPlistExists).toBe(false); + }); +}); + +describe("pauseConnectorSchedules", () => { + test("skips a non-'all' target and reports it as skipped", async () => { + const config = configWithSchedule("0 2 * * *"); + + expect(await pauseConnectorSchedules(config, "git-repo")).toEqual({ + config, + connectorIds: [], + skippedConnectorIds: ["git-repo"], + warnings: [], + }); + }); + + test("skips when there is no schedule to pause", async () => { + const config = createEmptyOnboardingConfig(); + + expect(await pauseConnectorSchedules(config, "all")).toEqual({ + config, + connectorIds: [], + skippedConnectorIds: ["all"], + warnings: [], + }); + }); + + test("skips when the schedule is already paused", async () => { + const config = configWithSchedule("0 2 * * *", { + pausedAt: "2026-01-02T00:00:00.000Z", + }); + + const result = await pauseConnectorSchedules(config, "all"); + expect(result.skippedConnectorIds).toEqual(["all"]); + expect(result.connectorIds).toEqual([]); + }); + + test("stamps pausedAt and skips native unload off macOS", async () => { + stubPlatform("linux"); + + const config = configWithSchedule("0 2 * * *"); + const result = await pauseConnectorSchedules(config, "all"); + + expect(result.connectorIds).toEqual(["all"]); + expect(result.skippedConnectorIds).toEqual([]); + expect(result.config.ingestionSchedule?.pausedAt).toEqual( + expect.any(String), + ); + // No saved pmset means power reconciliation is a no-op. + expect(result.powerSchedule).toBeUndefined(); + }); +}); + +describe("resumeConnectorSchedules", () => { + test("skips when the schedule is not paused", async () => { + const config = configWithSchedule("0 2 * * *"); + + const result = await resumeConnectorSchedules({ + config, + cwd: "/repo", + target: "all", + }); + expect(result.skippedConnectorIds).toEqual(["all"]); + expect(result.connectorIds).toEqual([]); + }); + + test("clears pausedAt and surfaces the non-macOS install warning", async () => { + stubPlatform("linux"); + + const config = configWithSchedule("0 2 * * *", { + pausedAt: "2026-01-02T00:00:00.000Z", + }); + const result = await resumeConnectorSchedules({ + config, + cwd: "/repo", + target: "all", + }); + + expect(result.connectorIds).toEqual(["all"]); + expect(result.config.ingestionSchedule?.pausedAt).toBeUndefined(); + expect(result.warnings).toContain( + "Schedule saved, but native installation is currently macOS-only.", + ); + }); +}); + +describe("deleteConnectorSchedules", () => { + test("skips when there is no schedule to delete", async () => { + const config = createEmptyOnboardingConfig(); + + expect(await deleteConnectorSchedules(config, "all")).toEqual({ + config, + connectorIds: [], + skippedConnectorIds: ["all"], + warnings: [], + }); + }); + + test("removes the schedule and skips native cleanup off macOS", async () => { + stubPlatform("linux"); + + const config = configWithSchedule("0 2 * * *"); + const result = await deleteConnectorSchedules(config, "all"); + + expect(result.connectorIds).toEqual(["all"]); + expect(result.config.ingestionSchedule).toBeUndefined(); + expect(result.powerSchedule).toBeUndefined(); + }); + + test("cancels a saved-and-enabled power schedule when the last schedule is deleted", async () => { + stubPlatform("linux"); + + // With a pmset schedule marked enabled and no ingestion schedule left, power + // reconciliation must flip it off. Off macOS the cancel is a no-op that only + // rewrites the saved state. + const config: OpenWikiOnboardingConfig = { + ...configWithSchedule("0 2 * * *"), + powerManagement: { + pmset: { + days: "MTWRFSU", + enabled: true, + sleepTime: "02:30:00", + updatedAt: "2026-01-01T00:00:00.000Z", + wakeTime: "01:58:00", + }, + }, + }; + + const result = await deleteConnectorSchedules(config, "all"); + + expect(result.config.ingestionSchedule).toBeUndefined(); + expect(result.powerSchedule?.enabled).toBe(false); + expect(result.config.powerManagement?.pmset?.enabled).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + }); +}); diff --git a/test/scheduling/schedules.test.ts b/test/scheduling/schedules.test.ts new file mode 100644 index 00000000..b97f83a2 --- /dev/null +++ b/test/scheduling/schedules.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, test } from "vitest"; +import { createEmptyOnboardingConfig } from "../../src/setup/onboarding.ts"; +import { + describeCronExpression, + getSavedPowerScheduleStatus, + getSuggestedCronExpression, + validateCronExpression, +} from "../../src/scheduling/schedules.ts"; + +// These cover the pure, dependency-free surface of schedules.ts. The +// install/list/pause/resume/delete helpers shell out to launchctl/crontab and +// belong in integration tests, not here. + +describe("validateCronExpression", () => { + test("accepts a valid five-field expression and describes it", () => { + const result = validateCronExpression("0 2 * * *"); + + expect(result.valid).toBe(true); + // Narrow the discriminated union so the description field is visible. + if (result.valid) { + expect(result.expression).toBe("0 2 * * *"); + expect(result.description).toMatch(/2:00 AM/i); + } + }); + + test("collapses surrounding and repeated whitespace before validating", () => { + const result = validateCronExpression(" 0 2 * * * "); + + expect(result.valid).toBe(true); + expect(result.expression).toBe("0 2 * * *"); + }); + + test("rejects an empty or whitespace-only expression with guidance", () => { + for (const input of ["", " "]) { + expect(validateCronExpression(input)).toEqual({ + error: "Enter a cron expression like 0 2 * * *.", + expression: "", + valid: false, + }); + } + }); + + test("rejects an unparseable expression and echoes back what was entered", () => { + const result = validateCronExpression("not a cron"); + + expect(result.valid).toBe(false); + if (!result.valid) { + expect(result.expression).toBe("not a cron"); + expect(result.error.length).toBeGreaterThan(0); + } + }); +}); + +describe("getSuggestedCronExpression", () => { + test("returns the saved ingestion expression when one exists", () => { + const config = { + ...createEmptyOnboardingConfig(), + ingestionSchedule: { + description: "every morning", + expression: "30 4 * * *", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }; + + expect(getSuggestedCronExpression(config)).toBe("30 4 * * *"); + }); + + test("falls back to a 2am daily default when no schedule is saved", () => { + expect(getSuggestedCronExpression(createEmptyOnboardingConfig())).toBe( + "0 2 * * *", + ); + }); +}); + +describe("describeCronExpression", () => { + test("renders a human-readable, twelve-hour description", () => { + expect(describeCronExpression("0 2 * * *")).toMatch(/2:00 AM/i); + }); + + test("throws on an unparseable expression rather than returning junk", () => { + expect(() => describeCronExpression("not a cron")).toThrow(); + }); +}); + +describe("getSavedPowerScheduleStatus", () => { + test("returns null when no pmset schedule has been saved", () => { + expect( + getSavedPowerScheduleStatus(createEmptyOnboardingConfig()), + ).toBeNull(); + }); + + test("mirrors the saved pmset fields into a status object", () => { + const config = { + ...createEmptyOnboardingConfig(), + powerManagement: { + pmset: { + days: "MTWRF", + enabled: true, + sleepTime: "23:30", + updatedAt: "2026-01-02T00:00:00.000Z", + wakeTime: "01:45", + warning: "scheduled by OpenWiki", + }, + }, + }; + + expect(getSavedPowerScheduleStatus(config)).toEqual({ + days: "MTWRF", + enabled: true, + sleepTime: "23:30", + updatedAt: "2026-01-02T00:00:00.000Z", + wakeTime: "01:45", + warning: "scheduled by OpenWiki", + }); + }); +}); diff --git a/test/setup/onboarding.test.ts b/test/setup/onboarding.test.ts index 1b30f5a6..d455568c 100644 --- a/test/setup/onboarding.test.ts +++ b/test/setup/onboarding.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, describe, expect, test, vi } from "vitest"; @@ -18,6 +18,23 @@ async function loadOnboardingModule(home: string) { return await import("../../src/setup/onboarding.ts"); } +// Writes a raw (un-normalized) onboarding.json so we can read it back through +// readOpenWikiOnboardingConfig and observe how normalizeOnboardingConfig +// sanitizes it. This is the public seam for the otherwise-private normalizer. +async function seedRawOnboardingJson( + onboarding: Awaited>, + value: unknown, +): Promise { + await mkdir(path.dirname(onboarding.openWikiOnboardingPath), { + recursive: true, + }); + await writeFile( + onboarding.openWikiOnboardingPath, + `${JSON.stringify(value)}\n`, + "utf8", + ); +} + afterEach(async () => { vi.resetModules(); @@ -188,3 +205,157 @@ describe("OpenWiki onboarding completion", () => { ).toBe(false); }); }); + +describe("normalizeOnboardingConfig (via readOpenWikiOnboardingConfig)", () => { + test("falls back to an empty config when the stored JSON is not an object", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + // Valid JSON, but an array rather than an object: it must not leak through. + await seedRawOnboardingJson(onboarding, ["not", "an", "object"]); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances).toEqual([]); + expect(config.sources).toEqual({}); + expect(config.version).toBe(1); + }); + + test("migrates a legacy sources map into sourceInstances", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [], + sources: { hackernews: { ingestionGoal: "top stories" } }, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances).toEqual([ + { + connectorId: "hackernews", + id: "hackernews", + ingestionGoal: "top stories", + }, + ]); + // sources is re-derived from the instances, so the goal round-trips back. + expect(config.sources.hackernews?.ingestionGoal).toBe("top stories"); + }); + + test("drops sources whose connector id is not recognized", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + // langsmith is a ConnectorId in the type but is intentionally absent from + // isKnownConnectorId, so it must be discarded during normalization. + await seedRawOnboardingJson(onboarding, { + sourceInstances: [], + sources: { + langsmith: { ingestionGoal: "traces" }, + notion: { ingestionGoal: "docs" }, + }, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + const ids = config.sourceInstances.map( + (sourceConfig) => sourceConfig.connectorId, + ); + + expect(ids).toContain("notion"); + expect(ids).not.toContain("langsmith"); + }); + + test("backfills modeId and modeName from templateId and templateName", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [], + sources: {}, + templateId: "code", + templateName: "Code repository", + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.modeId).toBe("code"); + expect(config.modeName).toBe("Code repository"); + expect(config.templateId).toBe("code"); + }); + + test("generates a source instance id when one is missing", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [{ connectorId: "slack" }], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.sourceInstances).toHaveLength(1); + expect(config.sourceInstances[0]?.id).toBe("slack-1"); + }); + + test("promotes a per-source schedule to the ingestion schedule and strips it from the instance", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + sourceInstances: [ + { + connectorId: "x", + id: "x-1", + schedule: { + description: "nightly", + expression: "0 3 * * *", + updatedAt: "2026-01-01T00:00:00.000Z", + }, + }, + ], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.ingestionSchedule?.expression).toBe("0 3 * * *"); + expect(config.sourceInstances[0]?.schedule).toBeUndefined(); + }); + + test("normalizes a pmset power schedule and fills defaults for missing fields", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + powerManagement: { pmset: { enabled: true } }, + sourceInstances: [], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.powerManagement?.pmset).toEqual({ + days: "", + enabled: true, + sleepTime: "", + updatedAt: new Date(0).toISOString(), + wakeTime: "", + }); + }); + + test("drops power management that has no pmset block", async () => { + const home = await createTempHome(); + const onboarding = await loadOnboardingModule(home); + await seedRawOnboardingJson(onboarding, { + powerManagement: { somethingElse: true }, + sourceInstances: [], + sources: {}, + version: 1, + }); + + const config = await onboarding.readOpenWikiOnboardingConfig(); + + expect(config.powerManagement).toBeUndefined(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..30b71af0 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from "vitest/config"; + +/** + * Vitest configuration. + * + * Test discovery is left on Vitest's defaults; this file only configures + * coverage. `all: true` plus an explicit `include` makes `pnpm coverage` report + * the entire `src` tree, so files that no test imports yet show up as 0% instead + * of being silently omitted. Without this, coverage flatters itself by counting + * only the files a test happens to touch. + */ +export default defineConfig({ + test: { + coverage: { + provider: "v8", + all: true, + include: ["src/**/*.{ts,tsx}"], + exclude: ["src/**/*.d.ts"], + reporter: ["text", "text-summary", "html", "json-summary", "lcov"], + }, + }, +}); From 5371516ff6e5e680f6ea89670fcb084a4cc16fd5 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 16:23:23 -0700 Subject: [PATCH 05/13] test coverage --- test/agent/environment-debug-value.test.ts | 87 +++ test/agent/model-resolution.test.ts | 63 ++- test/agent/openai-chatgpt-oauth.test.ts | 128 +++++ test/agent/parse-stream-event.test.ts | 409 ++++++++++++++ test/agent/prompt.test.ts | 100 ++++ test/agent/thread-id.test.ts | 50 ++ test/agent/translation-middleware.test.ts | 233 ++++++++ test/auth/external-cli-auth.test.ts | 167 +++++- test/auth/ngrok.test.ts | 26 + test/auth/oauth-url-validation.test.ts | 139 +++++ test/config/openwiki-home.test.ts | 175 ++++++ .../connector-config-overrides.test.ts | 26 + test/connectors/fetch-with-resilience.test.ts | 38 ++ test/connectors/hackernews.test.ts | 244 -------- .../connectors/{ => sources}/git-repo.test.ts | 2 +- test/connectors/sources/gmail.test.ts | 75 +++ test/connectors/sources/hackernews.test.ts | 520 ++++++++++++++++++ .../langsmith/api.test.ts} | 2 +- .../langsmith/index.test.ts} | 20 +- .../langsmith/repo-config.test.ts} | 2 +- .../langsmith/runs.test.ts} | 4 +- .../langsmith/setup.test.ts} | 6 +- test/connectors/{ => sources}/mcp.test.ts | 16 +- test/connectors/sources/slack.test.ts | 53 ++ .../{ => sources}/web-search.test.ts | 52 +- test/connectors/sources/x.test.ts | 87 +++ test/mermaid/dom-shim.test.ts | 16 + test/mermaid/mermaid-validate.test.ts | 14 + test/mermaid/mermaid-wiki.test.ts | 113 ++++ test/mermaid/validate-fallback.test.ts | 49 ++ test/okf/frontmatter.test.ts | 66 ++- test/okf/index-sync-errors.test.ts | 117 ++++ test/telemetry/client-no-key.test.ts | 30 + test/telemetry/telemetry-install-id.test.ts | 76 ++- test/telemetry/telemetry.test.ts | 36 +- vitest.config.ts | 6 +- 36 files changed, 2968 insertions(+), 279 deletions(-) create mode 100644 test/agent/environment-debug-value.test.ts create mode 100644 test/agent/parse-stream-event.test.ts create mode 100644 test/agent/thread-id.test.ts create mode 100644 test/config/openwiki-home.test.ts delete mode 100644 test/connectors/hackernews.test.ts rename test/connectors/{ => sources}/git-repo.test.ts (99%) create mode 100644 test/connectors/sources/hackernews.test.ts rename test/connectors/{langsmith-api.test.ts => sources/langsmith/api.test.ts} (98%) rename test/connectors/{langsmith-index.test.ts => sources/langsmith/index.test.ts} (94%) rename test/connectors/{langsmith-repo-config.test.ts => sources/langsmith/repo-config.test.ts} (99%) rename test/connectors/{langsmith-runs.test.ts => sources/langsmith/runs.test.ts} (97%) rename test/connectors/{langsmith-setup.test.ts => sources/langsmith/setup.test.ts} (95%) rename test/connectors/{ => sources}/mcp.test.ts (92%) rename test/connectors/{ => sources}/web-search.test.ts (81%) create mode 100644 test/mermaid/dom-shim.test.ts create mode 100644 test/mermaid/validate-fallback.test.ts create mode 100644 test/okf/index-sync-errors.test.ts create mode 100644 test/telemetry/client-no-key.test.ts diff --git a/test/agent/environment-debug-value.test.ts b/test/agent/environment-debug-value.test.ts new file mode 100644 index 00000000..31d3c447 --- /dev/null +++ b/test/agent/environment-debug-value.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from "vitest"; +import { formatEnvironmentDebugValue } from "../../src/agent/index.ts"; +import { + OPENWIKI_MODEL_ID_ENV_KEY, + OPENWIKI_PROVIDER_ENV_KEY, +} from "../../src/config/constants.ts"; + +// formatEnvironmentDebugValue feeds the --debug env dump, which can end up in +// pasted bug reports. redaction.test.ts covers the AWS/secret-key masking; the +// cases below cover the non-secret classification branches and the URL +// scrubbing helper, verifying that low-sensitivity values stay legible while a +// URL's credentials/query/fragment are stripped. + +describe("formatEnvironmentDebugValue – non-secret classification", () => { + test("reports an unset variable rather than printing undefined", () => { + expect(formatEnvironmentDebugValue("ANY_KEY", undefined)).toBe("unset"); + }); + + test("prints low-cardinality config values verbatim for debuggability", () => { + // Model/provider selectors are not secret and are the most useful thing to + // see in a debug dump, so they are echoed as an exact quoted value. + expect( + formatEnvironmentDebugValue( + OPENWIKI_MODEL_ID_ENV_KEY, + "claude-haiku-4-5", + ), + ).toBe('set(value="claude-haiku-4-5")'); + expect( + formatEnvironmentDebugValue(OPENWIKI_PROVIDER_ENV_KEY, "anthropic"), + ).toBe('set(value="anthropic")'); + }); + + test("a short generic value is reported by length only", () => { + // Unknown keys with a <=10 char value get length only (no preview) so a + // short secret in an unrecognized key is not partially leaked. + expect(formatEnvironmentDebugValue("SOME_UNKNOWN_KEY", "short")).toBe( + "set(length=5)", + ); + }); + + test("a long generic value is previewed with head and tail only", () => { + const value = "abcdefghijklmnop"; // 16 chars, over the 10-char threshold + const result = formatEnvironmentDebugValue("SOME_UNKNOWN_KEY", value); + + expect(result).toBe('set(length=16, preview="abcdef...mnop")'); + // The middle of the value must never appear in full. + expect(result).not.toContain(value); + }); +}); + +describe("formatEnvironmentDebugValue – URL-typed keys", () => { + test("a plain URL is echoed with no redaction suffix", () => { + // LANGCHAIN_ENDPOINT routes through the URL formatter; a clean URL has + // nothing to redact. + expect( + formatEnvironmentDebugValue( + "LANGCHAIN_ENDPOINT", + "https://api.example.com/v1", + ), + ).toBe('set(url="https://api.example.com/v1")'); + }); + + test("credentials, query, and fragment are stripped from a URL value", () => { + const result = formatEnvironmentDebugValue( + "LANGCHAIN_ENDPOINT", + "https://user:pass@api.example.com/v1?token=abc#frag", + ); + + expect(result).toContain("redacted=auth+query+hash"); + // None of the sensitive URL parts survive the scrub. + for (const leaked of ["user", "pass", "token=abc", "frag"]) { + expect(result).not.toContain(leaked); + } + }); + + test("a URL-typed key holding a non-URL value falls back to a length preview", () => { + // If a base-URL env var is misconfigured with a non-URL string, new URL() + // throws and the formatter degrades to the generic preview shape instead + // of crashing the debug dump. + const result = formatEnvironmentDebugValue( + "LANGCHAIN_ENDPOINT", + "not a url value at all", + ); + + expect(result).toMatch(/^set\(length=\d+, preview=/u); + }); +}); diff --git a/test/agent/model-resolution.test.ts b/test/agent/model-resolution.test.ts index 673f0523..747e9f81 100644 --- a/test/agent/model-resolution.test.ts +++ b/test/agent/model-resolution.test.ts @@ -1,6 +1,7 @@ -import { afterEach, describe, expect, test } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import { resolveModelId } from "../../src/agent/index.ts"; import { OPENWIKI_MODEL_ID_ENV_KEY } from "../../src/config/constants.ts"; +import type { OpenWikiRunEvent } from "../../src/agent/types.ts"; const originalModelId = process.env[OPENWIKI_MODEL_ID_ENV_KEY]; @@ -53,3 +54,63 @@ describe("resolveModelId", () => { ).toThrow(/Invalid model ID/u); }); }); + +describe("resolveModelId – provider/model mismatch warning", () => { + test("warns (without failing) when the model belongs to a different provider", () => { + // A known Anthropic model left configured while the provider is Gemini is a + // likely misconfiguration. resolveModelId still returns the model (a gateway + // may serve it) but must surface an actionable warning via onEvent and + // stderr so a later opaque provider 400 is pre-empted. + const events: OpenWikiRunEvent[] = []; + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + try { + const modelId = resolveModelId( + { + modelId: "claude-haiku-4-5", + debug: true, + onEvent: (event) => events.push(event), + }, + "gemini", + ); + + // The run is not blocked: the mismatched model is returned as-is. + expect(modelId).toBe("claude-haiku-4-5"); + + const warning = events.find( + (event): event is Extract => + event.type === "text", + ); + expect(warning?.text).toContain("claude-haiku-4-5"); + expect(warning?.text).toMatch(/not a known/u); + + // The debug breadcrumb records the mismatch classification. + expect(events.some((event) => event.type === "debug")).toBe(true); + // The warning is mirrored to stderr so it survives a later failure. + expect(stderr).toHaveBeenCalled(); + } finally { + stderr.mockRestore(); + } + }); + + test("does not warn when the model is valid for the configured provider", () => { + const events: OpenWikiRunEvent[] = []; + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + try { + resolveModelId( + { modelId: "claude-haiku-4-5", onEvent: (event) => events.push(event) }, + "anthropic", + ); + + expect(events).toHaveLength(0); + expect(stderr).not.toHaveBeenCalled(); + } finally { + stderr.mockRestore(); + } + }); +}); diff --git a/test/agent/openai-chatgpt-oauth.test.ts b/test/agent/openai-chatgpt-oauth.test.ts index 2785e4e0..38daa363 100644 --- a/test/agent/openai-chatgpt-oauth.test.ts +++ b/test/agent/openai-chatgpt-oauth.test.ts @@ -2,12 +2,15 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { CHATGPT_TOKEN_REFRESH_THRESHOLD_MS, CODEX_RESPONSES_LITE_HEADER, + type ChatGptLoginHandle, type CodexTokens, codexTokensToEnv, createCodexFetch, decodeChatGptIdentity, formatChatGptAccount, + formatChatGptAccountFromEnv, isChatGptTokenExpired, + loginWithChatGPT, parseManualCallbackInput, readCodexTokensFromEnv, refreshChatGptTokens, @@ -191,6 +194,27 @@ describe("Codex Responses requests", () => { expect(init.headers.get(CODEX_RESPONSES_LITE_HEADER)).toBe("true"); }); + test("treats an unparseable request URL as not-a-Codex-request", async () => { + // isCodexResponsesRequest must swallow a malformed URL rather than throw: + // a body it can't classify is forwarded untouched, so Luna framing is never + // applied off the Codex endpoint. + const fetchMock = vi.fn(() => Promise.resolve(new Response())); + const codexFetch = createCodexFetch("gpt-5.6-luna", fetchMock); + + await codexFetch("::://not-a-url", { + body: JSON.stringify({ input: [{ role: "system", content: "x" }] }), + headers: { originator: "openwiki" }, + method: "POST", + }); + + const [, init] = fetchMock.mock.calls[0] as [ + string, + { headers: Record }, + ]; + // No Luna headers were added; only the system->developer rewrite applies. + expect(init.headers).toEqual({ originator: "openwiki" }); + }); + test("passes non-object JSON bodies through unchanged", async () => { const fetchMock = vi.fn(() => Promise.resolve(new Response())); const codexFetch = createCodexFetch("gpt-5.6-luna", fetchMock); @@ -325,6 +349,19 @@ describe("decodeChatGptIdentity", () => { planType: null, }); }); + + test("returns nulls when a well-formed JWT carries an undecodable payload", () => { + // Three segments but a middle segment that is not valid base64url JSON: the + // decode must fail closed to empty claims rather than throw, since these are + // untrusted token bytes we never signature-verify. + const badPayload = Buffer.from("not json", "utf8").toString("base64url"); + + expect(decodeChatGptIdentity(`header.${badPayload}.sig`)).toEqual({ + accountId: null, + email: null, + planType: null, + }); + }); }); describe("codex token env contract", () => { @@ -432,4 +469,95 @@ describe("parseManualCallbackInput", () => { parseManualCallbackInput("http://localhost:1455/auth/callback?state=abc"), ).toEqual({ code: null, state: "abc" }); }); + + test("returns nulls when an http-looking value is not a parseable URL", () => { + // A value that trips the http:// branch but fails URL parsing must degrade to + // empty rather than throw, so a fumbled paste is reported inline, not crashed. + expect(parseManualCallbackInput("https://")).toEqual({ + code: null, + state: null, + }); + }); +}); + +describe("formatChatGptAccountFromEnv", () => { + test("formats the identity persisted in the environment", () => { + expect( + formatChatGptAccountFromEnv({ + OPENAI_CHATGPT_EMAIL: "dev@example.com", + OPENAI_CHATGPT_PLAN: "team", + }), + ).toBe("dev@example.com (Team)"); + }); + + test("returns null when neither identity claim is persisted", () => { + expect(formatChatGptAccountFromEnv({})).toBeNull(); + }); +}); + +describe("loginWithChatGPT", () => { + test("builds the PKCE authorize URL and completes via a manual paste", async () => { + // Exercises the browser Authorization Code + PKCE flow end to end without a + // real browser: the token exchange fetch is stubbed and the auth code is fed + // through the manual-paste handle instead of the loopback redirect. The + // loopback callback server still binds (localhost only), but no external + // network is touched. + const access = makeAccessToken("acct_login", { + email: "u@example.com", + planType: "pro", + }); + stubTokenResponse({ + access_token: access, + refresh_token: "refresh-login", + expires_in: 3600, + }); + + let openedUrl = ""; + let loginResult: Promise | undefined; + const handle = await new Promise((resolve) => { + // The executor runs synchronously, so loginResult is assigned before the + // handle (delivered via onReady) is ever used below. + loginResult = loginWithChatGPT( + (url) => { + openedUrl = url; + }, + (h) => resolve(h), + ); + }); + + // The authorize URL carries the fixed Codex client id and an S256 challenge, + // proving generatePkce/base64url ran and the params were assembled. + const parsed = new URL(openedUrl); + expect(parsed.origin + parsed.pathname).toBe( + "https://auth.openai.com/oauth/authorize", + ); + expect(parsed.searchParams.get("client_id")).toBe( + "app_EMoamEEZ73f0CkXaXp7hrann", + ); + expect(parsed.searchParams.get("code_challenge_method")).toBe("S256"); + expect(parsed.searchParams.get("code_challenge")).toBeTruthy(); + const state = parsed.searchParams.get("state"); + expect(state).toBeTruthy(); + + // An empty paste and a state-mismatched paste are reported inline without + // resolving the login, so a fumbled paste can be retried. + expect(handle.submitManual(" ")).toBe( + "Could not find an authorization code in that input.", + ); + expect( + handle.submitManual( + `http://localhost:1455/auth/callback?code=ac_x&state=${state}-wrong`, + ), + ).toBe("State mismatch — paste the URL from this login attempt."); + + // A bare code (no state) is accepted and completes the exchange. + expect(handle.submitManual("ac_good")).toBeNull(); + + const tokens = await loginResult!; + expect(tokens.access).toBe(access); + expect(tokens.refresh).toBe("refresh-login"); + expect(tokens.accountId).toBe("acct_login"); + expect(tokens.email).toBe("u@example.com"); + expect(tokens.planType).toBe("pro"); + }); }); diff --git a/test/agent/parse-stream-event.test.ts b/test/agent/parse-stream-event.test.ts new file mode 100644 index 00000000..7728ad28 --- /dev/null +++ b/test/agent/parse-stream-event.test.ts @@ -0,0 +1,409 @@ +import { describe, expect, test } from "vitest"; +import { parseStreamEvent } from "../../src/agent/index.ts"; +import type { OpenWikiRunEvent } from "../../src/agent/types.ts"; + +// parseStreamEvent is the untrusted-input boundary between the deepagents +// stream and OpenWiki's terminal renderer: every chunk it sees originates from +// a third-party model/runtime, so the discrimination between "text to show", +// "tool activity", and "ignore" must hold up against malformed and adversarial +// shapes. stream-redaction.test.ts already covers content-block suppression on +// the `messages` tuple path; these cases exercise the remaining discrimination +// branches (protocol guard, subgraph tagging, nested/serialized message +// shapes, delta variants, and the whole `tools` branch) that path never hits. + +/** + * Wraps a `messages` payload in the normalized protocol-event envelope that + * isProtocolStreamEvent() accepts. `namespace` length > 1 marks a subgraph. + */ +function messagesChunk(data: unknown, namespace: unknown[] = []): unknown { + return { + type: "event", + method: "messages", + params: { data, namespace }, + }; +} + +/** + * Wraps a `tools` payload (the tool lifecycle record) in the protocol-event + * envelope. The tools branch never reads `namespace`, so it is omitted here. + */ +function toolsChunk(data: unknown): unknown { + return { + type: "event", + method: "tools", + params: { data }, + }; +} + +/** Narrows a non-null text event so tests can read `.text` without casts. */ +function expectText(event: OpenWikiRunEvent | null): string { + expect(event).not.toBeNull(); + expect(event?.type).toBe("text"); + return (event as { text: string }).text; +} + +describe("parseStreamEvent – protocol guard", () => { + test.each([ + ["non-object", 42], + ["null", null], + ["missing method", { type: "event", params: { data: "x" } }], + ["non-event type", { type: "values", method: "messages", params: {} }], + ["params without data", { type: "event", method: "messages", params: {} }], + ])( + "returns null for a chunk that is not a protocol event (%s)", + (_label, chunk) => { + // Anything failing the isProtocolStreamEvent shape check must be dropped + // rather than misinterpreted as renderable content. + expect(parseStreamEvent(chunk)).toBeNull(); + }, + ); + + test("returns null for a protocol event with an unhandled method", () => { + // Only `messages` and `tools` are actionable; other well-formed methods + // (e.g. `values`, `updates`) are silently ignored. + expect(parseStreamEvent(toolsChunkWithMethod("values"))).toBeNull(); + }); +}); + +/** A well-formed protocol event whose method is neither messages nor tools. */ +function toolsChunkWithMethod(method: string): unknown { + return { type: "event", method, params: { data: {}, namespace: [] } }; +} + +describe("parseStreamEvent – messages source tagging", () => { + test("top-level namespace tags the event as coming from the main graph", () => { + const event = parseStreamEvent(messagesChunk("hello from main", [])); + + expect(event).toMatchObject({ source: "main", type: "text" }); + expect(expectText(event)).toBe("hello from main"); + }); + + test("a nested namespace tags the event as coming from a subgraph", () => { + // isSubgraphProtocolEvent keys off namespace.length > 1, so a two-segment + // namespace routes streamed text through the subgraph label. + const event = parseStreamEvent( + messagesChunk("hello from sub", ["parent", "child"]), + ); + + expect(event).toMatchObject({ source: "subgraph", type: "text" }); + }); +}); + +describe("parseStreamEvent – message text extraction shapes", () => { + test("a bare string payload streams through", () => { + expect(expectText(parseStreamEvent(messagesChunk("plain")))).toBe("plain"); + }); + + test("an array of content blocks (no tuple metadata) is concatenated", () => { + const event = parseStreamEvent( + messagesChunk([ + { type: "text", text: "one " }, + { type: "text", text: "two" }, + ]), + ); + + // A 2-element array that is NOT a [message, metadata] tuple is walked as a + // list of blocks; the first block with text wins the item scan. + expect(expectText(event)).toBe("one "); + }); + + test("a human-role message is suppressed (only ai/assistant is rendered)", () => { + // shouldReadMessageRecord must not echo the user's own turn back to the + // terminal; only assistant output should stream. + const event = parseStreamEvent( + messagesChunk({ role: "human", content: "my prompt" }), + ); + + expect(event).toBeNull(); + }); + + test("text is read out of a nested `chunk` field", () => { + const event = parseStreamEvent( + messagesChunk({ chunk: { role: "assistant", content: "from chunk" } }), + ); + + expect(expectText(event)).toBe("from chunk"); + }); + + test("text is read out of a nested `message` field", () => { + const event = parseStreamEvent( + messagesChunk({ message: { role: "ai", content: "from message" } }), + ); + + expect(expectText(event)).toBe("from message"); + }); + + test("text is recovered from a serialized LangChain message via kwargs", () => { + // A serialized AIMessageChunk identifies its role through the trailing + // segment of its `id` tuple, and its content lives under `kwargs`. + const event = parseStreamEvent( + messagesChunk({ + id: ["langchain", "schema", "messages", "AIMessageChunk"], + kwargs: { content: "serialized body" }, + }), + ); + + expect(expectText(event)).toBe("serialized body"); + }); + + test("role is honored through a `_getType` method", () => { + const event = parseStreamEvent( + messagesChunk({ _getType: () => "ai", content: "typed ai" }), + ); + + expect(expectText(event)).toBe("typed ai"); + }); + + test("a throwing `_getType` does not crash extraction", () => { + // A hostile message object whose _getType throws must be treated as + // unknown-role, not propagate the exception up through the stream loop. + const event = parseStreamEvent( + messagesChunk({ + _getType: () => { + throw new Error("boom"); + }, + content: "still readable", + }), + ); + + // role resolves to null -> record is still read -> content streams. + expect(expectText(event)).toBe("still readable"); + }); + + test("falls back to the `output` key when content yields nothing", () => { + const event = parseStreamEvent( + messagesChunk({ role: "assistant", content: [], output: "fallback" }), + ); + + expect(expectText(event)).toBe("fallback"); + }); +}); + +describe("parseStreamEvent – protocol streaming sub-events", () => { + test("content-block-delta text-delta streams the delta text", () => { + const event = parseStreamEvent( + messagesChunk({ + event: "content-block-delta", + delta: { type: "text-delta", text: "streamed" }, + }), + ); + + expect(expectText(event)).toBe("streamed"); + }); + + test("content-block-delta block-delta reads text out of `fields`", () => { + const event = parseStreamEvent( + messagesChunk({ + event: "content-block-delta", + delta: { type: "block-delta", fields: { text: "block body" } }, + }), + ); + + expect(expectText(event)).toBe("block body"); + }); + + test("content-block-delta falls back to a bare `text` on the delta", () => { + const event = parseStreamEvent( + messagesChunk({ + event: "content-block-delta", + delta: { text: "bare delta text" }, + }), + ); + + expect(expectText(event)).toBe("bare delta text"); + }); + + test("content-block-delta falls back to a bare `delta` string", () => { + const event = parseStreamEvent( + messagesChunk({ + event: "content-block-delta", + delta: { delta: "nested delta" }, + }), + ); + + expect(expectText(event)).toBe("nested delta"); + }); + + test("content-block-start reads text from the block content", () => { + const event = parseStreamEvent( + messagesChunk({ + event: "content-block-start", + content: { type: "text", text: "started" }, + }), + ); + + expect(expectText(event)).toBe("started"); + }); + + test.each([ + "message-start", + "message-finish", + "content-block-finish", + "error", + ])("the %s lifecycle event produces no renderable text", (event) => { + // These framing events carry no user-visible text; they must resolve to + // null so they do not emit empty terminal lines. + expect(parseStreamEvent(messagesChunk({ event }))).toBeNull(); + }); + + test("reasoning and tool content blocks are suppressed", () => { + // Chain-of-thought and tool-call blocks must never be surfaced as prose, + // even though they may carry a `text` field. + const event = parseStreamEvent( + messagesChunk({ + role: "assistant", + content: [ + { type: "reasoning", text: "internal thought" }, + { type: "tool_use", text: "call args" }, + { type: "text", text: "visible answer" }, + ], + }), + ); + + const text = expectText(event); + expect(text).toContain("visible answer"); + expect(text).not.toContain("internal thought"); + expect(text).not.toContain("call args"); + }); + + test("output_text content blocks are surfaced", () => { + const event = parseStreamEvent( + messagesChunk({ + role: "assistant", + content: [{ type: "output_text", output_text: "responses api text" }], + }), + ); + + expect(expectText(event)).toBe("responses api text"); + }); + + test("a content block nesting text under `delta` is unwrapped", () => { + const event = parseStreamEvent( + messagesChunk({ + role: "assistant", + content: [{ delta: { type: "text-delta", text: "deep" } }], + }), + ); + + expect(expectText(event)).toBe("deep"); + }); +}); + +describe("parseStreamEvent – tools branch", () => { + test("returns null when the tools payload is not a record", () => { + expect(parseStreamEvent(toolsChunk("not-a-record"))).toBeNull(); + }); + + test("returns null for an unrecognized tool lifecycle event", () => { + expect( + parseStreamEvent(toolsChunk({ event: "on_tool_middle" })), + ).toBeNull(); + }); + + test("on_tool_start yields a tool_start event with a formatted call line", () => { + const event = parseStreamEvent( + toolsChunk({ + event: "on_tool_start", + name: "write_file", + toolCallId: "call-1", + input: { file_path: "/a.md", contents: "x" }, + }), + ); + + expect(event).toMatchObject({ + type: "tool_start", + id: "call-1", + name: "write_file", + }); + // formatToolArgs renders record inputs as key=value with JSON-quoted strings. + expect((event as { call: string }).call).toBe( + 'write_file(file_path="/a.md", contents="x")', + ); + }); + + test("the `execute` tool name is capitalized in the call line", () => { + const event = parseStreamEvent( + toolsChunk({ event: "tool-started", tool_name: "execute", input: "ls" }), + ); + + // formatToolCallName maps execute -> Execute; a bare non-JSON string input + // is not a record, so it is rendered as a single JSON-quoted scalar. + expect((event as { call: string }).call).toBe('Execute("ls")'); + expect(event).toMatchObject({ type: "tool_start", name: "execute" }); + }); + + test("a missing tool name and call id fall back to synthetic values", () => { + const event = parseStreamEvent( + toolsChunk({ event: "on_tool_start", input: { q: 1 } }), + ); + + // Absent name -> "tool"; absent id -> `${name}:${formatToolValue(input)}`. + expect(event).toMatchObject({ type: "tool_start", name: "tool" }); + expect((event as { id: string }).id).toBe('tool:{"q":1}'); + }); + + test("a stringified-JSON input is parsed before formatting", () => { + const event = parseStreamEvent( + toolsChunk({ + event: "on_tool_start", + name: "search", + tool_call_id: "c2", + input: '{"query":"hi"}', + }), + ); + + expect((event as { call: string }).call).toBe('search(query="hi")'); + expect(event).toMatchObject({ id: "c2" }); + }); + + test("on_tool_end yields a finished tool_end event", () => { + const event = parseStreamEvent( + toolsChunk({ + event: "on_tool_end", + name: "write_file", + toolCallId: "c3", + }), + ); + + expect(event).toEqual({ + type: "tool_end", + id: "c3", + name: "write_file", + status: "finished", + }); + }); + + test.each(["on_tool_error", "tool-error"])( + "%s yields a tool_end event with error status", + (event) => { + const parsed = parseStreamEvent( + toolsChunk({ event, name: "write_file", tool_call_id: "c4" }), + ); + + expect(parsed).toMatchObject({ type: "tool_end", status: "error" }); + }, + ); + + test("an array tool input is rendered via its indexed entries", () => { + const event = parseStreamEvent( + toolsChunk({ + event: "on_tool_start", + name: "batch", + toolCallId: "c5", + input: ["a", 2], + }), + ); + + // An array is an object, so formatToolArgs takes the record branch first + // and keys by array index rather than positionally. + expect((event as { call: string }).call).toBe('batch(0="a", 1=2)'); + }); + + test("an absent tool input renders an empty argument list", () => { + const event = parseStreamEvent( + toolsChunk({ event: "on_tool_start", name: "noop", toolCallId: "c6" }), + ); + + expect((event as { call: string }).call).toBe("noop()"); + }); +}); diff --git a/test/agent/prompt.test.ts b/test/agent/prompt.test.ts index 4dff59cc..751d1a7d 100644 --- a/test/agent/prompt.test.ts +++ b/test/agent/prompt.test.ts @@ -3,7 +3,22 @@ import { createDiagramInstructions, createLinkIntegrityInstructions, createSystemPrompt, + createUserPrompt, } from "../../src/agent/prompt.ts"; +import type { RunContext } from "../../src/agent/types.ts"; + +/** + * A RunContext with every optional field absent, so a test can opt fields in one + * at a time and confirm each fallback (the "(not provided)" wiki goal, the "no + * metadata" line) independently. + */ +function emptyContext(overrides: Partial = {}): RunContext { + return { + lastUpdate: null, + gitSummary: "no git changes", + ...overrides, + }; +} describe("createSystemPrompt output language", () => { test("instructs the agent to write wiki documentation in the selected language", () => { @@ -183,6 +198,91 @@ describe("createLinkIntegrityInstructions", () => { }); }); +describe("createUserPrompt", () => { + test("chat returns the user message verbatim, trimmed", () => { + expect(createUserPrompt("chat", emptyContext(), " what changed? ")).toBe( + "what changed?", + ); + }); + + test("chat falls back to a default opener when no message is given", () => { + // A null or blank chat message must still yield a usable turn rather than an + // empty prompt. + expect(createUserPrompt("chat", emptyContext(), null)).toBe( + "Start an OpenWiki chat.", + ); + expect(createUserPrompt("chat", emptyContext(), " ")).toBe( + "Start an OpenWiki chat.", + ); + }); + + test("init embeds the wiki goal and git summary for the resolved subject", () => { + const prompt = createUserPrompt( + "init", + emptyContext({ wikiGoal: "Explain the CLI", gitSummary: "3 files" }), + null, + "repository", + ); + + expect(prompt).toContain("Initialize OpenWiki documentation for"); + // Repository mode resolves the subject to the repo, not the personal brain. + expect(prompt).toContain("this repository"); + expect(prompt).toContain("Explain the CLI"); + expect(prompt).toContain("3 files"); + // No user message means no appended instruction block. + expect(prompt).not.toContain("Additional user instruction:"); + }); + + test("init uses the personal-brain subject label in local-wiki mode", () => { + const prompt = createUserPrompt("init", emptyContext(), null, "local-wiki"); + + expect(prompt).toContain("the local knowledge wiki"); + // With no wiki goal supplied the brief falls back to the placeholder. + expect(prompt).toContain("(not provided)"); + }); + + test("update renders the previous-run metadata as pretty JSON", () => { + const prompt = createUserPrompt( + "update", + emptyContext({ + lastUpdate: { + updatedAt: "2026-07-28T00:00:00Z", + command: "update", + model: "gpt-5", + }, + }), + null, + "repository", + ); + + expect(prompt).toContain("Update the existing OpenWiki documentation"); + // formatLastUpdate serializes the metadata object so the agent can diff + // against the recorded run state. + expect(prompt).toContain('"model": "gpt-5"'); + expect(prompt).toContain('"command": "update"'); + }); + + test("update states when no previous metadata exists", () => { + const prompt = createUserPrompt("update", emptyContext(), null); + + expect(prompt).toContain("No previous OpenWiki update metadata was found."); + }); + + test("appends a trimmed user instruction block when a message is supplied", () => { + const prompt = createUserPrompt( + "init", + emptyContext(), + " focus on auth ", + "repository", + ); + + expect(prompt).toContain("Additional user instruction:"); + expect(prompt).toContain("focus on auth"); + // The block is trimmed, so no leading/trailing whitespace leaks through. + expect(prompt).not.toContain(" focus on auth "); + }); +}); + describe("createSystemPrompt diagram guidance", () => { test("is always present for init and update runs", () => { for (const command of ["init", "update"] as const) { diff --git a/test/agent/thread-id.test.ts b/test/agent/thread-id.test.ts new file mode 100644 index 00000000..cc89e305 --- /dev/null +++ b/test/agent/thread-id.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { createOpenWikiThreadId } from "../../src/agent/index.ts"; + +// createOpenWikiThreadId derives the checkpointer thread key. The directory +// component must be a stable hash of the resolved cwd (so repeated runs against +// the same wiki share a thread namespace) while the per-run suffix must be +// unique (so concurrent runs never collide on one checkpoint row). + +const THREAD_ID_PATTERN = /^openwiki-([0-9a-f]{32})-(.+)$/u; + +describe("createOpenWikiThreadId", () => { + test("produces the openwiki-<32hex>- shape", () => { + expect(createOpenWikiThreadId("/tmp/wiki-a")).toMatch(THREAD_ID_PATTERN); + }); + + test("the directory hash is stable for the same cwd but the run suffix is unique", () => { + const first = createOpenWikiThreadId("/tmp/wiki-a"); + const second = createOpenWikiThreadId("/tmp/wiki-a"); + + const firstDigest = first.match(THREAD_ID_PATTERN)?.[1]; + const secondDigest = second.match(THREAD_ID_PATTERN)?.[1]; + + // Same directory -> identical hash segment... + expect(firstDigest).toBe(secondDigest); + // ...but the full IDs differ because the random run suffix rotates. + expect(first).not.toBe(second); + }); + + test("distinct working directories hash to distinct thread namespaces", () => { + const a = + createOpenWikiThreadId("/tmp/wiki-a").match(THREAD_ID_PATTERN)?.[1]; + const b = + createOpenWikiThreadId("/tmp/wiki-b").match(THREAD_ID_PATTERN)?.[1]; + + expect(a).not.toBe(b); + }); + + test("relative and absolute forms of the same path share a hash", () => { + // createThreadId resolves the path before hashing, so an already-absolute + // path and its unresolved twin normalize to the same namespace. + const resolved = + createOpenWikiThreadId("/tmp/wiki-a/sub/..").match( + THREAD_ID_PATTERN, + )?.[1]; + const direct = + createOpenWikiThreadId("/tmp/wiki-a").match(THREAD_ID_PATTERN)?.[1]; + + expect(resolved).toBe(direct); + }); +}); diff --git a/test/agent/translation-middleware.test.ts b/test/agent/translation-middleware.test.ts index 54f788d1..9a6305a3 100644 --- a/test/agent/translation-middleware.test.ts +++ b/test/agent/translation-middleware.test.ts @@ -121,6 +121,16 @@ describe("resolveTranslationPlan", () => { expect(resolveTranslationPlan("init", "zh-CN", "en")).toBeUndefined(); expect(resolveTranslationPlan("chat", "zh-CN", "en")).toBeUndefined(); }); + + test("compares malformed language tags by their literal value", () => { + // A tag Intl.Locale cannot parse must not crash plan resolution: primarySubtag + // falls back to the raw tag, and the switch decision still resolves. + expect(resolveTranslationPlan("update", "@@bad", "en")).toEqual({ + target: "@@bad", + source: "en", + translateAll: true, + }); + }); }); describe("createWikiTranslationMiddleware beforeAgent", () => { @@ -444,4 +454,227 @@ describe("createWikiTranslationMiddleware beforeAgent", () => { ).resolves.toBeUndefined(); expect(calls).toHaveLength(0); }); + + test("treats a directory listing error as an empty tree", async () => { + const { backend } = await setup(); + await backend.write("/openwiki/page.md", "# Page\n\nBody.\n"); + // A backend that cannot enumerate the root yields no files rather than + // throwing, so the run degrades to translating nothing. + vi.spyOn(backend, "ls").mockResolvedValue({ error: "no such dir" }); + + const { model, calls } = fakeModel((content) => `T\n${content}`); + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + ), + ); + + expect(calls).toHaveLength(0); + }); + + test("skips a page whose content is only whitespace", async () => { + const { backend } = await setup(); + await backend.write("/openwiki/blank.md", " \n"); + + const { model, calls } = fakeModel((content) => `T\n${content}`); + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + ), + ); + + // An empty page has no prose to translate, so the model is never called. + expect(calls).toHaveLength(0); + }); + + test("stamps a page for retry when the model returns an empty translation", async () => { + const { backend, rootDir } = await setup(); + await backend.write("/openwiki/page.md", "# Page\n\nBody.\n"); + + const warnings: string[] = []; + // A blank model response is a failure, not a valid translation: writing it + // would erase the page, so the page must be kept and flagged for retry. + const { model } = fakeModel(() => " "); + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + (message) => warnings.push(message), + ), + ); + + const after = await readFile( + path.join(rootDir, "openwiki/page.md"), + "utf8", + ); + expect(after).toContain('openwiki_translation_pending: "zh-CN"'); + expect(after).toContain("# Page"); + expect(warnings[0]).toContain("empty translation"); + }); + + test("does not rewrite the pending marker when it already matches", async () => { + const { backend, rootDir } = await setup(); + // The page is already stamped for exactly this target, so a failed retry must + // not rewrite an identical marker or report a bogus stamp failure. + await backend.write( + "/openwiki/page.md", + '---\nopenwiki_translation_pending: "zh-CN"\n---\n\n# Body\n', + ); + + const warnings: string[] = []; + const model = { + invoke: () => Promise.reject(new Error("model down")), + } as unknown as BaseChatModel; + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + (message) => warnings.push(message), + ), + ); + + const after = await readFile( + path.join(rootDir, "openwiki/page.md"), + "utf8", + ); + expect(after).toContain('openwiki_translation_pending: "zh-CN"'); + // markPending was a no-op, so the warning names only the model failure, not a + // retry-stamp failure. + expect(warnings[0]).toContain("model down"); + expect(warnings[0]).not.toContain("could not mark it for retry"); + }); + + test("reports an unreadable page through the default stderr warning", async () => { + const { backend } = await setup(); + await backend.write("/openwiki/page.md", "# Page\n\nBody.\n"); + // A read failure is caught per page; with no onWarning supplied the default + // sink writes the (secret-redacted) summary to stderr. + vi.spyOn(backend, "readRaw").mockResolvedValue({ + error: "permission denied", + }); + const stderr = vi + .spyOn(process.stderr, "write") + .mockImplementation(() => true); + + const { model, calls } = fakeModel((content) => `T\n${content}`); + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + ), + ); + + expect(calls).toHaveLength(0); + const written = stderr.mock.calls.map((call) => String(call[0])).join(""); + expect(written).toContain("page.md"); + expect(written).toContain("permission denied"); + }); + + test("stamps a page whose backend content is not text", async () => { + const { backend } = await setup(); + await backend.write("/openwiki/page.md", "# Page\n\nBody.\n"); + // A non-string, non-array payload is not translatable prose; the page is + // reported and skipped rather than coerced. + vi.spyOn(backend, "readRaw").mockResolvedValue({ + data: { content: 42 as unknown as string }, + }); + + const warnings: string[] = []; + const { model } = fakeModel((content) => `T\n${content}`); + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + (message) => warnings.push(message), + ), + ); + + expect(warnings[0]).toContain("not a text file"); + }); + + test("joins array-shaped file content into text before translating", async () => { + const { backend, rootDir } = await setup(); + await backend.write("/openwiki/page.md", "# A\n\nB\n"); + // A backend that returns line arrays must be flattened to a single string so + // the join round-trips the original file content and the edit applies. + vi.spyOn(backend, "readRaw").mockResolvedValue({ + data: { content: ["# A", "", "B", ""] }, + }); + + const { model, calls } = fakeModel((content) => `T\n${content}`); + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + ), + ); + + expect(calls[0].human).toBe("# A\n\nB\n"); + await expect( + readFile(path.join(rootDir, "openwiki/page.md"), "utf8"), + ).resolves.toBe("T\n# A\n\nB\n"); + }); + + test("flattens array model output, keeping text blocks and dropping the rest", async () => { + const { backend, rootDir } = await setup(); + await backend.write("/openwiki/page.md", "# Page\n\nBody.\n"); + // The model may answer with structured content blocks; only text blocks (and + // bare strings) contribute, non-text blocks are ignored. + const model = { + invoke: () => + Promise.resolve({ + content: [ + "raw ", + { type: "text", text: "translated" }, + { type: "image_url", image_url: "ignored" }, + ], + }), + } as unknown as BaseChatModel; + + await runBeforeAgent( + createWikiTranslationMiddleware( + backend, + "repository", + model, + switchTo("zh-CN"), + ), + ); + + await expect( + readFile(path.join(rootDir, "openwiki/page.md"), "utf8"), + ).resolves.toBe("raw translated"); + }); + + test("renders a language with no display name as its bare tag in the prompt", async () => { + const { backend } = await setup(); + await backend.write("/openwiki/page.md", "# Page\n\nBody.\n"); + // describeLanguage must not throw on a tag Intl cannot name; it falls back to + // the raw tag so the translation prompt is still well-formed. + const { model, calls } = fakeModel((content) => `T\n${content}`); + await runBeforeAgent( + createWikiTranslationMiddleware(backend, "repository", model, { + target: "@@bad", + source: "en", + translateAll: true, + }), + ); + + expect(calls[0].system).toContain("@@bad"); + }); }); diff --git a/test/auth/external-cli-auth.test.ts b/test/auth/external-cli-auth.test.ts index b6477d94..b7a6930e 100644 --- a/test/auth/external-cli-auth.test.ts +++ b/test/auth/external-cli-auth.test.ts @@ -1,20 +1,55 @@ +import { EventEmitter } from "node:events"; import { afterEach, describe, expect, test, vi } from "vitest"; const execFileMock = vi.hoisted(() => vi.fn()); +const spawnMock = vi.hoisted(() => vi.fn()); vi.mock("node:child_process", () => ({ execFile: execFileMock, - spawn: vi.fn(), + spawn: spawnMock, })); import { + detectExternalCliCredential, getExternalCliAuthAdapter, + isExternalCliAvailable, resolveExternalCliCredential, + runExternalCliLogin, validateExternalCliCredential, } from "../../src/auth/external-cli-auth.ts"; +/** + * Drives `execFile`'s Node callback to resolve with `stdout`. The production + * `execFileAsync` is `promisify(execFile)`; with a plain mock (no + * `promisify.custom`) it resolves with whatever is passed as the second + * callback argument, so the fake mirrors the `{ stdout }` shape the code + * destructures. + */ +function execFileResolves(stdout: string): void { + execFileMock.mockImplementation((...args: unknown[]) => { + const done = args.at(-1) as + | ((error: Error | null, stdout: string, stderr: string) => void) + | undefined; + done?.(null, { stdout, stderr: "" } as unknown as string, ""); + }); +} + +/** + * Drives `execFile`'s Node callback to fail, standing in for a missing CLI or a + * `gh auth token` invocation on a machine with no active session. + */ +function execFileRejects(): void { + execFileMock.mockImplementation((...args: unknown[]) => { + const done = args.at(-1) as + | ((error: Error | null, stdout: string, stderr: string) => void) + | undefined; + done?.(new Error("command failed"), "", ""); + }); +} + afterEach(() => { execFileMock.mockReset(); + spawnMock.mockReset(); }); describe("external CLI provider credentials", () => { @@ -95,3 +130,133 @@ describe("external CLI provider credentials", () => { ).toThrow(/does not accept Personal Access Tokens/u); }); }); + +describe("isExternalCliAvailable", () => { + test("reports true when the CLI answers `--version`", async () => { + execFileResolves("gh version 2.0.0\n"); + + await expect(isExternalCliAvailable("copilot")).resolves.toBe(true); + expect(execFileMock).toHaveBeenCalledWith( + "gh", + ["--version"], + expect.objectContaining({ timeout: 5_000 }), + expect.any(Function), + ); + }); + + test("reports false when the CLI probe fails", async () => { + // A missing or broken `gh` binary must degrade to "not available" instead + // of surfacing the spawn error, so the caller can fall back to a manual + // credential prompt. + execFileRejects(); + + await expect(isExternalCliAvailable("copilot")).resolves.toBe(false); + }); + + test("reports false for a provider that has no external CLI adapter", async () => { + // `anthropic` authenticates by API key, so there is no adapter to probe and + // the CLI must never be spawned. + await expect(isExternalCliAvailable("anthropic")).resolves.toBe(false); + expect(execFileMock).not.toHaveBeenCalled(); + }); +}); + +describe("detectExternalCliCredential", () => { + test("returns null for a provider without an external CLI adapter", async () => { + await expect(detectExternalCliCredential("anthropic")).resolves.toBeNull(); + expect(execFileMock).not.toHaveBeenCalled(); + }); + + test("returns null when the token command fails", async () => { + // No active `gh` session means `gh auth token` exits non-zero; the failure + // is swallowed so detection reports "no credential" rather than throwing. + execFileRejects(); + + await expect(detectExternalCliCredential("copilot")).resolves.toBeNull(); + }); + + test("returns null when the token command emits only whitespace", async () => { + execFileResolves(" \n"); + + await expect(detectExternalCliCredential("copilot")).resolves.toBeNull(); + }); +}); + +describe("resolveExternalCliCredential", () => { + test("returns false for a provider that does not use external CLI auth", async () => { + // `anthropic` is API-key authenticated, so the external-CLI path is a + // no-op and must not shell out or mutate the environment. + const env: NodeJS.ProcessEnv = {}; + + await expect(resolveExternalCliCredential("anthropic", env)).resolves.toBe( + false, + ); + expect(execFileMock).not.toHaveBeenCalled(); + expect(env.ANTHROPIC_API_KEY).toBeUndefined(); + }); + + test("returns false when no credential can be detected", async () => { + // An empty `gh auth token` result leaves the env untouched so a later step + // can prompt for an interactive login instead of exporting a blank key. + execFileResolves("\n"); + const env: NodeJS.ProcessEnv = {}; + + await expect(resolveExternalCliCredential("copilot", env)).resolves.toBe( + false, + ); + expect(env.COPILOT_API_KEY).toBeUndefined(); + }); +}); + +describe("runExternalCliLogin", () => { + /** + * Returns a fake child process whose `error`/`exit` events can be emitted by + * the test, standing in for the interactive `gh auth login` subprocess so no + * real login is spawned. + */ + function fakeChild(): EventEmitter { + const child = new EventEmitter(); + spawnMock.mockReturnValue(child); + return child; + } + + test("resolves true when the login subprocess exits cleanly", async () => { + const child = fakeChild(); + + const pending = runExternalCliLogin("copilot"); + child.emit("exit", 0); + + await expect(pending).resolves.toBe(true); + expect(spawnMock).toHaveBeenCalledWith( + "gh", + ["auth", "login", "--hostname", "github.com"], + expect.objectContaining({ stdio: "inherit" }), + ); + }); + + test("resolves false when the login subprocess exits non-zero", async () => { + const child = fakeChild(); + + const pending = runExternalCliLogin("copilot"); + child.emit("exit", 1); + + await expect(pending).resolves.toBe(false); + }); + + test("resolves false when the login subprocess cannot be spawned", async () => { + // A missing `gh` binary surfaces as an `error` event; the login reports + // failure rather than rejecting so the caller can guide the user to install + // the CLI. + const child = fakeChild(); + + const pending = runExternalCliLogin("copilot"); + child.emit("error", new Error("ENOENT")); + + await expect(pending).resolves.toBe(false); + }); + + test("resolves false without spawning for a provider with no adapter", async () => { + await expect(runExternalCliLogin("anthropic")).resolves.toBe(false); + expect(spawnMock).not.toHaveBeenCalled(); + }); +}); diff --git a/test/auth/ngrok.test.ts b/test/auth/ngrok.test.ts index 8119f3ac..82d6d0cf 100644 --- a/test/auth/ngrok.test.ts +++ b/test/auth/ngrok.test.ts @@ -128,6 +128,32 @@ describe("getRedirectUriFromNgrokTunnels", () => { ); }); + test("treats a tunnel with no addr as non-matching but still usable as the sole fallback", () => { + // A tunnel config lacking an `addr` yields an empty string; the port match + // short-circuits to false rather than throwing, and the sole https tunnel + // is still returned as the fallback. + const payload = tunnels([ + { addr: undefined, public_url: "https://noaddr.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://noaddr.ngrok.app/callback", + ); + }); + + test("treats an unparseable addr as non-matching without throwing", () => { + // A non-empty addr that is neither a bare port, a `:port` suffix, nor a + // parseable URL must fail the port match via the caught URL parse error, + // leaving the sole https tunnel as the fallback. + const payload = tunnels([ + { addr: "garbage", public_url: "https://weirdaddr.ngrok.app" }, + ]); + + expect(getRedirectUriFromNgrokTunnels(payload, PORT)).toBe( + "https://weirdaddr.ngrok.app/callback", + ); + }); + test("matches a full-url addr by parsing out its port", () => { // The addr is a full URL that does not literally end in `:53682`, so the // match must come from URL parsing rather than the suffix shortcut. diff --git a/test/auth/oauth-url-validation.test.ts b/test/auth/oauth-url-validation.test.ts index debed8d8..c383431c 100644 --- a/test/auth/oauth-url-validation.test.ts +++ b/test/auth/oauth-url-validation.test.ts @@ -1,9 +1,22 @@ import { afterEach, describe, expect, test, vi } from "vitest"; import { discoverAuthorizationServerMetadata, + discoverProtectedResourceMetadata, validateOAuthEndpointUrl, } from "../../src/auth/oauth-discovery.ts"; +/** + * Builds a `fetch` double that replays queued `Response`s in order, so a test + * can model the candidate-path fallback (first .well-known miss, second hit) + * without touching the network. + */ +function fetchReturning(...responses: Response[]): ReturnType { + const queue = [...responses]; + return vi.fn(() => + Promise.resolve(queue.shift() ?? new Response(null, { status: 404 })), + ); +} + describe("validateOAuthEndpointUrl", () => { test("allows HTTPS URLs on explicitly allowed hosts", () => { expect( @@ -28,6 +41,14 @@ describe("validateOAuthEndpointUrl", () => { "https://[::1]/token", "https://[fe80::1]/token", "https://[fd00::1]/token", + "https://[fc00::1]/token", + // IPv4-mapped IPv6 is a classic SSRF bypass: the loopback and cloud + // metadata address must stay blocked when smuggled through ::ffff:. + "https://[::ffff:127.0.0.1]/token", + "https://[::ffff:169.254.169.254]/token", + // A globally-routable IPv6 is not a private range, so it clears the SSRF + // guard, but it is still off the allowlist and must be refused. + "https://[2606:4700::1]/token", "https://user:pass@api.notion.com/token", "https://attacker.example/token", ])("rejects unsafe OAuth endpoint URL %s", (value) => { @@ -63,4 +84,122 @@ describe("OAuth discovery fetches", () => { expect(call[1]).toMatchObject({ redirect: "manual" }); } }); + + test("returns the parsed authorization-server metadata on the first hit", async () => { + // The discovery document is attacker-influenceable, so this only asserts + // that a 200 body is surfaced verbatim; shape validation (required + // endpoints) is the caller's responsibility, exercised in the OAuth flow. + const fetchMock = fetchReturning( + new Response( + JSON.stringify({ + authorization_endpoint: "https://auth.notion.com/authorize", + token_endpoint: "https://auth.notion.com/token", + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + discoverAuthorizationServerMetadata("https://auth.notion.com/oauth", { + allowedHosts: ["notion.com"], + }), + ).resolves.toMatchObject({ + authorization_endpoint: "https://auth.notion.com/authorize", + }); + }); +}); + +describe("discoverProtectedResourceMetadata", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + test("returns the advertised authorization servers from the metadata", async () => { + const fetchMock = fetchReturning( + new Response( + JSON.stringify({ + authorization_servers: ["https://auth.notion.com"], + }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + discoverProtectedResourceMetadata("https://mcp.notion.com/mcp", { + allowedHosts: ["notion.com"], + }), + ).resolves.toMatchObject({ + authorization_servers: ["https://auth.notion.com"], + }); + }); + + test("falls back to the bare .well-known path when the first candidate misses", async () => { + // A 404 on the path-scoped document must not abort discovery; the origin + // .well-known document is tried next before giving up. + const fetchMock = fetchReturning( + new Response(null, { status: 404 }), + new Response( + JSON.stringify({ authorization_servers: ["https://auth.notion.com"] }), + { status: 200 }, + ), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + discoverProtectedResourceMetadata("https://mcp.notion.com/mcp", { + allowedHosts: ["notion.com"], + }), + ).resolves.toMatchObject({ + authorization_servers: ["https://auth.notion.com"], + }); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + test("throws when no candidate returns metadata", async () => { + const fetchMock = fetchReturning( + new Response(null, { status: 404 }), + new Response(null, { status: 500 }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + discoverProtectedResourceMetadata("https://mcp.notion.com/mcp", { + allowedHosts: ["notion.com"], + }), + ).rejects.toThrow("Could not discover MCP protected resource metadata."); + }); + + test("refuses to fetch when the resource URL fails the SSRF guard", async () => { + // The resource URL is untrusted input; a loopback target must be rejected + // by validateOAuthEndpointUrl before any fetch, closing the SSRF path + // toward internal/metadata services. + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + discoverProtectedResourceMetadata("https://127.0.0.1/mcp", { + allowedHosts: ["notion.com"], + }), + ).rejects.toThrow(/localhost or private networks/u); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("does not follow protected-resource metadata redirects", async () => { + const fetchMock = fetchReturning( + new Response(null, { status: 302 }), + new Response(null, { status: 302 }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect( + discoverProtectedResourceMetadata("https://mcp.notion.com/mcp", { + allowedHosts: ["notion.com"], + }), + ).rejects.toThrow("Could not discover MCP protected resource metadata."); + for (const call of fetchMock.mock.calls) { + expect(call[1]).toMatchObject({ redirect: "manual" }); + } + }); }); diff --git a/test/config/openwiki-home.test.ts b/test/config/openwiki-home.test.ts new file mode 100644 index 00000000..504fdd2e --- /dev/null +++ b/test/config/openwiki-home.test.ts @@ -0,0 +1,175 @@ +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// Delegate every fs/promises call to the real implementation so the home layout +// and its permission modes are exercised for real, but keep `chmod` swappable so +// the TOCTOU catch inside `chmodIfExists` (unreachable on the happy path, since +// the directory always exists right after mkdir) can be driven deterministically. +vi.mock("node:fs/promises", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + chmod: vi.fn((...args: Parameters) => + actual.chmod(...args), + ), + }; +}); + +const OWNER_ONLY_DIR = 0o700; + +function errno(code: string): NodeJS.ErrnoException { + return Object.assign(new Error(code), { code }); +} + +let savedHome: string | undefined; +let tempHome: string; +let fsp: typeof import("node:fs/promises"); +let home: typeof import("../../src/config/openwiki-home.ts"); + +beforeEach(async () => { + savedHome = process.env.HOME; + // `os.homedir()` re-reads $HOME at runtime, so pointing it at a throwaway + // directory and re-importing the module reroutes the whole ~/.openwiki tree + // away from the developer's real home. + const base = await ( + await import("node:fs/promises") + ).mkdtemp(path.join(os.tmpdir(), "openwiki-home-")); + tempHome = base; + process.env.HOME = tempHome; + + vi.resetModules(); + fsp = await import("node:fs/promises"); + vi.mocked(fsp.chmod).mockClear(); + home = await import("../../src/config/openwiki-home.ts"); +}); + +afterEach(async () => { + if (savedHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = savedHome; + } + await fsp.rm(tempHome, { recursive: true, force: true }); +}); + +async function mode(dirPath: string): Promise { + return (await fsp.stat(dirPath)).mode & 0o777; +} + +describe("ensureOpenWikiHome", () => { + test("creates the home tree owner-only (0700) and hardens the root", async () => { + await home.ensureOpenWikiHome(); + + // Permission-mode enforcement: the id/credential store must not be group or + // world readable, so every directory lands at 0700. + for (const dir of [ + home.openWikiHomeDir, + home.openWikiConnectorsDir, + home.openWikiLocalWikiDir, + home.openWikiSkillsDir, + ]) { + expect(await mode(dir)).toBe(OWNER_ONLY_DIR); + } + // The root is explicitly re-chmodded so a pre-existing loose directory is + // tightened rather than left at whatever mode it had. + expect(vi.mocked(fsp.chmod)).toHaveBeenCalledWith( + home.openWikiHomeDir, + OWNER_ONLY_DIR, + ); + }); + + test("is idempotent when the home already exists", async () => { + await home.ensureOpenWikiHome(); + // The already-exists branch: a second run must not throw and must leave the + // owner-only mode intact. + await expect(home.ensureOpenWikiHome()).resolves.toBeUndefined(); + expect(await mode(home.openWikiHomeDir)).toBe(OWNER_ONLY_DIR); + }); + + test("rethrows a non-ENOENT chmod failure", async () => { + // A permission error on chmod is a real problem, not the tolerated race, so + // it must surface rather than be swallowed. + vi.mocked(fsp.chmod).mockRejectedValueOnce(errno("EACCES")); + + await expect(home.ensureOpenWikiHome()).rejects.toThrow(/EACCES/u); + }); + + test("tolerates an ENOENT chmod race and keeps building the tree", async () => { + // If the root vanished between mkdir and chmod, the chmod ENOENT is ignored + // and the remaining subdirectories are still created. + vi.mocked(fsp.chmod).mockRejectedValueOnce(errno("ENOENT")); + + await expect(home.ensureOpenWikiHome()).resolves.toBeUndefined(); + expect(await mode(home.openWikiConnectorsDir)).toBe(OWNER_ONLY_DIR); + }); +}); + +describe("ensureConnectorHome", () => { + test("creates the connector dir, raw, and logs owner-only", async () => { + await home.ensureConnectorHome("notion"); + + for (const dir of [ + home.getConnectorDir("notion"), + home.getConnectorRawDir("notion"), + home.getConnectorLogsDir("notion"), + ]) { + expect(await mode(dir)).toBe(OWNER_ONLY_DIR); + } + }); + + test("rejects an unsafe connector id before touching the filesystem", async () => { + await expect(home.ensureConnectorHome("../escape")).rejects.toThrow( + /Invalid connector ID/u, + ); + }); +}); + +describe("path helpers", () => { + test("derive connector file paths under the connector directory", () => { + const dir = home.getConnectorDir("notion"); + expect(home.getConnectorConfigPath("notion")).toBe( + path.join(dir, "config.json"), + ); + expect(home.getConnectorStatePath("notion")).toBe( + path.join(dir, "state.json"), + ); + }); +}); + +describe("assertSafeConnectorId", () => { + test("accepts a well-formed id and rejects malformed ones", () => { + expect(() => home.assertSafeConnectorId("web-search")).not.toThrow(); + // Uppercase, leading digit/dash, path separators, and over-length ids are + // all rejected so a connector id can never escape its directory. + for (const bad of [ + "Notion", + "-lead", + "1lead", + "../escape", + "a".repeat(65), + "", + ]) { + expect(() => home.assertSafeConnectorId(bad)).toThrow( + /Invalid connector ID/u, + ); + } + }); +}); + +describe("resolveConnectorRawPath", () => { + test("resolves a path that stays inside the raw directory", () => { + const resolved = home.resolveConnectorRawPath("notion", "items/a.json"); + expect(resolved).toBe( + path.join(home.getConnectorRawDir("notion"), "items", "a.json"), + ); + }); + + test("rejects a relative path that escapes the raw directory", () => { + // Path-traversal guard: a raw item path must never resolve outside the + // connector's own raw directory. + expect(() => + home.resolveConnectorRawPath("notion", "../../etc/passwd"), + ).toThrow(/must stay inside/u); + }); +}); diff --git a/test/connectors/connector-config-overrides.test.ts b/test/connectors/connector-config-overrides.test.ts index 7415fc54..e8304970 100644 --- a/test/connectors/connector-config-overrides.test.ts +++ b/test/connectors/connector-config-overrides.test.ts @@ -132,6 +132,32 @@ function getRequestUrl(input: string | URL | Request): string { return input instanceof Request ? input.url : String(input); } +describe("connector io surfaces malformed config and state json", () => { + // The io layer swallows only ENOENT (missing file) and rethrows every other + // read/parse error, so a corrupt on-disk config or state must abort the run + // rather than be silently treated as absent. + test("rethrows when config.json is not valid json", async () => { + const home = await createTempHome(); + const dir = path.join(home, ".openwiki", "connectors", "google"); + await mkdir(dir, { recursive: true }); + await writeFile(path.join(dir, "config.json"), "{ not valid json", "utf8"); + const connector = await loadGmailConnector(home); + + await expect(connector.ingest()).rejects.toThrow(); + }); + + test("rethrows when state.json is not valid json", async () => { + const home = await createTempHome(); + const dir = path.join(home, ".openwiki", "connectors", "google"); + await mkdir(dir, { recursive: true }); + await writeConnectorConfig(home, "google", { enabled: true }); + await writeFile(path.join(dir, "state.json"), "{ not valid json", "utf8"); + const connector = await loadGmailConnector(home); + + await expect(connector.ingest()).rejects.toThrow(); + }); +}); + describe("x connector honors options.connectorConfig", () => { test("skips when on-disk config is disabled and no override is given", async () => { const home = await createTempHome(); diff --git a/test/connectors/fetch-with-resilience.test.ts b/test/connectors/fetch-with-resilience.test.ts index 591b22f1..bbde4434 100644 --- a/test/connectors/fetch-with-resilience.test.ts +++ b/test/connectors/fetch-with-resilience.test.ts @@ -296,6 +296,44 @@ describe("fetchWithResilience", () => { expect(delays).toEqual([250]); }); + test("combines a caller-provided AbortSignal with the internal timeout", async () => { + // When the caller passes its own signal, the helper must merge it with the + // per-attempt timeout signal (AbortSignal.any) instead of dropping either. + const controller = new AbortController(); + const stub = vi.fn((_input: unknown, init?: { signal?: AbortSignal }) => { + expect(init?.signal).toBeInstanceOf(AbortSignal); + return Promise.resolve(new Response("ok", { status: 200 })); + }); + vi.stubGlobal("fetch", stub); + + const response = await fetchWithResilience( + "https://api.example.com/x", + { signal: controller.signal }, + { sleep: noSleep, random: fixedRandom }, + ); + + expect(response.status).toBe(200); + expect(stub).toHaveBeenCalledTimes(1); + }); + + test("wraps a non-Error rejection in an Error when retries are exhausted", async () => { + // fetch can reject with a non-Error (e.g. a string); the helper must still + // surface a real Error so callers can rely on `.message`. The non-Error + // rejection is the exact contract under test here. + // eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors + const stub = vi.fn(() => Promise.reject("string failure")); + vi.stubGlobal("fetch", stub); + + await expect( + fetchWithResilience( + "https://api.example.com/x", + {}, + { maxRetries: 0, sleep: noSleep, random: fixedRandom }, + ), + ).rejects.toThrow("string failure"); + expect(stub).toHaveBeenCalledTimes(1); + }); + test("passes an AbortSignal to fetch so a hung request can time out", async () => { const stub = vi.fn((_input: unknown, init?: { signal?: AbortSignal }) => { expect(init?.signal).toBeInstanceOf(AbortSignal); diff --git a/test/connectors/hackernews.test.ts b/test/connectors/hackernews.test.ts deleted file mode 100644 index dc2bd3b3..00000000 --- a/test/connectors/hackernews.test.ts +++ /dev/null @@ -1,244 +0,0 @@ -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, test, vi } from "vitest"; - -const originalHome = process.env.HOME; -const originalUserProfile = process.env.USERPROFILE; -const tempHomes: string[] = []; - -type HackerNewsDump = { - feeds: { feed: string }[]; - queryResults: unknown[]; -}; - -type ConnectorStateDump = { - runs: { - rawFiles: string[]; - runId: string; - status: string; - warnings: string[]; - }[]; -}; - -async function createTempHome(): Promise { - const home = await mkdtemp(path.join(tmpdir(), "openwiki-hackernews-")); - tempHomes.push(home); - return home; -} - -async function writeHackerNewsConfig( - home: string, - config: unknown, -): Promise { - const dir = path.join(home, ".openwiki", "connectors", "hackernews"); - await mkdir(dir, { recursive: true }); - await writeFile( - path.join(dir, "config.json"), - `${JSON.stringify(config, null, 2)}\n`, - "utf8", - ); -} - -function setConnectorTestHome(home: string): void { - process.env.HOME = home; - process.env.USERPROFILE = home; -} - -async function loadHackerNewsConnector(home: string) { - vi.resetModules(); - setConnectorTestHome(home); - const { createHackerNewsConnector } = - await import("../../src/connectors/sources/hackernews.ts"); - return createHackerNewsConnector(); -} - -function getRequestUrl(input: string | URL | Request): string { - return input instanceof Request ? input.url : String(input); -} - -afterEach(async () => { - vi.resetModules(); - vi.unstubAllGlobals(); - - if (originalHome === undefined) { - delete process.env.HOME; - } else { - process.env.HOME = originalHome; - } - if (originalUserProfile === undefined) { - delete process.env.USERPROFILE; - } else { - process.env.USERPROFILE = originalUserProfile; - } - - await Promise.all( - tempHomes - .splice(0) - .map((home) => rm(home, { force: true, recursive: true })), - ); -}); - -describe("hackernews connector feed configuration", () => { - test("uses the default feeds when no feeds are configured", async () => { - const home = await createTempHome(); - const paths: string[] = []; - vi.stubGlobal( - "fetch", - vi.fn((input: string | URL | Request) => { - const requestPath = new URL(getRequestUrl(input)).pathname; - paths.push(requestPath); - - if (requestPath === "/v0/topstories.json") { - return Promise.resolve(jsonResponse([101])); - } - if (requestPath === "/v0/newstories.json") { - return Promise.resolve(jsonResponse([202])); - } - if (requestPath === "/v0/item/101.json") { - return Promise.resolve( - jsonResponse({ id: 101, time: 1, title: "Top story" }), - ); - } - if (requestPath === "/v0/item/202.json") { - return Promise.resolve( - jsonResponse({ id: 202, time: 1, title: "New story" }), - ); - } - - return Promise.resolve(jsonResponse({})); - }), - ); - const connector = await loadHackerNewsConnector(home); - - const result = await connector.ingest({ limit: 1 }); - - expect(result.status).toBe("success"); - expect(result.warnings).toEqual([]); - expect(paths).toEqual([ - "/v0/topstories.json", - "/v0/item/101.json", - "/v0/newstories.json", - "/v0/item/202.json", - ]); - - const dump = JSON.parse( - await readFile(result.rawFiles[0] ?? "", "utf8"), - ) as HackerNewsDump; - expect(dump.feeds.map((feed) => feed.feed)).toEqual(["top", "new"]); - }); - - test("errors without writing raw results when configured feeds are invalid and there are no queries", async () => { - const home = await createTempHome(); - await writeHackerNewsConfig(home, { - enabled: true, - feeds: ["frontpage", "popular"], - queries: [], - }); - const fetchMock = vi.fn(() => { - throw new Error("fetch should not be called"); - }); - vi.stubGlobal("fetch", fetchMock); - const connector = await loadHackerNewsConnector(home); - - const result = await connector.ingest(); - - expect(result.status).toBe("error"); - expect(result.rawFiles).toEqual([]); - expect(result.message).toContain("No valid Hacker News feeds"); - expect(result.warnings).toEqual([ - "Ignored invalid Hacker News configured feed(s): frontpage, popular. Valid feeds are: ask, best, job, new, show, top.", - ]); - expect(fetchMock).not.toHaveBeenCalled(); - - const statePath = path.join( - home, - ".openwiki", - "connectors", - "hackernews", - "state.json", - ); - const state = JSON.parse( - await readFile(statePath, "utf8"), - ) as ConnectorStateDump; - expect(state.runs[0]).toMatchObject({ - rawFiles: [], - runId: result.runId, - status: "error", - warnings: result.warnings, - }); - }); - - test("errors without writing raw results when requested feeds are invalid and there are no queries", async () => { - const home = await createTempHome(); - const fetchMock = vi.fn(() => { - throw new Error("fetch should not be called"); - }); - vi.stubGlobal("fetch", fetchMock); - const connector = await loadHackerNewsConnector(home); - - const result = await connector.ingest({ - connectorConfig: { queries: [] }, - streams: ["frontpage"], - }); - - expect(result.status).toBe("error"); - expect(result.rawFiles).toEqual([]); - expect(result.warnings).toEqual([ - "Ignored invalid Hacker News requested feed(s): frontpage. Valid feeds are: ask, best, job, new, show, top.", - ]); - expect(fetchMock).not.toHaveBeenCalled(); - }); - - test("preserves search failure warnings when invalid feeds leave query work", async () => { - const home = await createTempHome(); - await writeHackerNewsConfig(home, { - enabled: true, - feeds: ["frontpage"], - queries: ["openwiki"], - }); - const urls: string[] = []; - vi.stubGlobal( - "fetch", - vi.fn((input: string | URL | Request) => { - const url = getRequestUrl(input); - urls.push(url); - - return Promise.resolve( - new Response(JSON.stringify({ error: "unavailable" }), { - headers: { "Content-Type": "application/json" }, - status: 503, - statusText: "Service Unavailable", - }), - ); - }), - ); - const connector = await loadHackerNewsConnector(home); - - const result = await connector.ingest(); - - expect(result.status).toBe("success"); - expect(result.rawFiles).toHaveLength(1); - expect(result.warnings).toEqual([ - "Ignored invalid Hacker News configured feed(s): frontpage. Valid feeds are: ask, best, job, new, show, top.", - "openwiki: Hacker News search request failed: 503 Service Unavailable", - ]); - expect(urls.length).toBeGreaterThan(0); - expect( - urls.every((url) => new URL(url).pathname === "/api/v1/search_by_date"), - ).toBe(true); - - const dump = JSON.parse( - await readFile(result.rawFiles[0] ?? "", "utf8"), - ) as HackerNewsDump; - expect(dump.feeds).toEqual([]); - expect(dump.queryResults).toEqual([]); - }); -}); - -function jsonResponse(value: unknown): Response { - return new Response(JSON.stringify(value), { - headers: { "Content-Type": "application/json" }, - status: 200, - }); -} diff --git a/test/connectors/git-repo.test.ts b/test/connectors/sources/git-repo.test.ts similarity index 99% rename from test/connectors/git-repo.test.ts rename to test/connectors/sources/git-repo.test.ts index 6a77e337..210dc22c 100644 --- a/test/connectors/git-repo.test.ts +++ b/test/connectors/sources/git-repo.test.ts @@ -78,7 +78,7 @@ async function loadGitRepoConnector(home: string) { process.env.HOME = home; process.env.USERPROFILE = home; const { createGitRepoConnector } = - await import("../../src/connectors/sources/git-repo.ts"); + await import("../../../src/connectors/sources/git-repo.ts"); return createGitRepoConnector(); } diff --git a/test/connectors/sources/gmail.test.ts b/test/connectors/sources/gmail.test.ts index 83de9283..b0e1f29a 100644 --- a/test/connectors/sources/gmail.test.ts +++ b/test/connectors/sources/gmail.test.ts @@ -306,6 +306,81 @@ describe("gmail query windowing", () => { "Gmail API request failed: 500", ); }); + + test("falls back to the default query when the configured query is blank", async () => { + const home = await createTempHome(); + // An empty query must normalize to the "newer_than:1d" default rather than + // send an empty `q` (which Gmail would reject); windowHours is absent so the + // base query passes through getWindowedGmailQuery unmodified. + await writeConnectorConfig(home, { + enabled: true, + maxMessages: 1, + query: "", + }); + const requests = stubGmail(() => ({ messages: [] })); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const listRequest = requests.find((request) => isListRequest(request.url)); + expect(listRequest?.url.searchParams.get("q")).toBe("newer_than:1d"); + }); +}); + +describe("gmail response normalization edge cases", () => { + test("follows nextPageToken and tolerates a page missing its messages array", async () => { + const home = await createTempHome(); + // maxMessages exceeds a single page's yield so the connector must page a + // second time. The second page omits `messages` entirely: an untrusted + // payload shape that must coerce to an empty list, not throw. + await writeConnectorConfig(home, { + enabled: true, + maxMessages: 5, + pageSize: 2, + }); + const requests = stubGmail((url) => { + if (isListRequest(url)) { + if (url.searchParams.get("pageToken") === "PAGE2") { + return {}; + } + return { messages: [{ id: "m1" }], nextPageToken: "PAGE2" }; + } + return { id: "m1" }; + }); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const listRequests = requests.filter((request) => + isListRequest(request.url), + ); + expect(listRequests).toHaveLength(2); + // The second list page must carry the token returned by the first. + expect(listRequests[1]?.url.searchParams.get("pageToken")).toBe("PAGE2"); + const dump = await readMessagesDump(result.rawFiles); + expect(dump.messageCount).toBe(1); + }); + + test("normalizes an unknown message format to full", async () => { + const home = await createTempHome(); + // A null format (malformed on-disk config) must fall back to "full" rather + // than propagate an invalid value into the message-get request. + await writeConnectorConfig(home, { + enabled: true, + format: null, + maxMessages: 1, + }); + stubGmail(() => ({ messages: [] })); + const connector = await loadGmailConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const dump = await readMessagesDump(result.rawFiles); + expect(dump.format).toBe("full"); + }); }); describe("gmail 401 refresh retry", () => { diff --git a/test/connectors/sources/hackernews.test.ts b/test/connectors/sources/hackernews.test.ts new file mode 100644 index 00000000..90753227 --- /dev/null +++ b/test/connectors/sources/hackernews.test.ts @@ -0,0 +1,520 @@ +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +const originalHome = process.env.HOME; +const originalUserProfile = process.env.USERPROFILE; +const tempHomes: string[] = []; + +type HackerNewsDump = { + feeds: { feed: string }[]; + queryResults: unknown[]; +}; + +type ConnectorStateDump = { + runs: { + rawFiles: string[]; + runId: string; + status: string; + warnings: string[]; + }[]; +}; + +async function createTempHome(): Promise { + const home = await mkdtemp(path.join(tmpdir(), "openwiki-hackernews-")); + tempHomes.push(home); + return home; +} + +async function writeHackerNewsConfig( + home: string, + config: unknown, +): Promise { + const dir = path.join(home, ".openwiki", "connectors", "hackernews"); + await mkdir(dir, { recursive: true }); + await writeFile( + path.join(dir, "config.json"), + `${JSON.stringify(config, null, 2)}\n`, + "utf8", + ); +} + +function setConnectorTestHome(home: string): void { + process.env.HOME = home; + process.env.USERPROFILE = home; +} + +async function loadHackerNewsConnector(home: string) { + vi.resetModules(); + setConnectorTestHome(home); + const { createHackerNewsConnector } = + await import("../../../src/connectors/sources/hackernews.ts"); + return createHackerNewsConnector(); +} + +function getRequestUrl(input: string | URL | Request): string { + return input instanceof Request ? input.url : String(input); +} + +afterEach(async () => { + vi.resetModules(); + vi.unstubAllGlobals(); + + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + + await Promise.all( + tempHomes + .splice(0) + .map((home) => rm(home, { force: true, recursive: true })), + ); +}); + +describe("hackernews connector feed configuration", () => { + test("uses the default feeds when no feeds are configured", async () => { + const home = await createTempHome(); + const paths: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const requestPath = new URL(getRequestUrl(input)).pathname; + paths.push(requestPath); + + if (requestPath === "/v0/topstories.json") { + return Promise.resolve(jsonResponse([101])); + } + if (requestPath === "/v0/newstories.json") { + return Promise.resolve(jsonResponse([202])); + } + if (requestPath === "/v0/item/101.json") { + return Promise.resolve( + jsonResponse({ id: 101, time: 1, title: "Top story" }), + ); + } + if (requestPath === "/v0/item/202.json") { + return Promise.resolve( + jsonResponse({ id: 202, time: 1, title: "New story" }), + ); + } + + return Promise.resolve(jsonResponse({})); + }), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest({ limit: 1 }); + + expect(result.status).toBe("success"); + expect(result.warnings).toEqual([]); + expect(paths).toEqual([ + "/v0/topstories.json", + "/v0/item/101.json", + "/v0/newstories.json", + "/v0/item/202.json", + ]); + + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as HackerNewsDump; + expect(dump.feeds.map((feed) => feed.feed)).toEqual(["top", "new"]); + }); + + test("errors without writing raw results when configured feeds are invalid and there are no queries", async () => { + const home = await createTempHome(); + await writeHackerNewsConfig(home, { + enabled: true, + feeds: ["frontpage", "popular"], + queries: [], + }); + const fetchMock = vi.fn(() => { + throw new Error("fetch should not be called"); + }); + vi.stubGlobal("fetch", fetchMock); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("error"); + expect(result.rawFiles).toEqual([]); + expect(result.message).toContain("No valid Hacker News feeds"); + expect(result.warnings).toEqual([ + "Ignored invalid Hacker News configured feed(s): frontpage, popular. Valid feeds are: ask, best, job, new, show, top.", + ]); + expect(fetchMock).not.toHaveBeenCalled(); + + const statePath = path.join( + home, + ".openwiki", + "connectors", + "hackernews", + "state.json", + ); + const state = JSON.parse( + await readFile(statePath, "utf8"), + ) as ConnectorStateDump; + expect(state.runs[0]).toMatchObject({ + rawFiles: [], + runId: result.runId, + status: "error", + warnings: result.warnings, + }); + }); + + test("errors without writing raw results when requested feeds are invalid and there are no queries", async () => { + const home = await createTempHome(); + const fetchMock = vi.fn(() => { + throw new Error("fetch should not be called"); + }); + vi.stubGlobal("fetch", fetchMock); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest({ + connectorConfig: { queries: [] }, + streams: ["frontpage"], + }); + + expect(result.status).toBe("error"); + expect(result.rawFiles).toEqual([]); + expect(result.warnings).toEqual([ + "Ignored invalid Hacker News requested feed(s): frontpage. Valid feeds are: ask, best, job, new, show, top.", + ]); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("preserves search failure warnings when invalid feeds leave query work", async () => { + const home = await createTempHome(); + await writeHackerNewsConfig(home, { + enabled: true, + feeds: ["frontpage"], + queries: ["openwiki"], + }); + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = getRequestUrl(input); + urls.push(url); + + return Promise.resolve( + new Response(JSON.stringify({ error: "unavailable" }), { + headers: { "Content-Type": "application/json" }, + status: 503, + statusText: "Service Unavailable", + }), + ); + }), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(result.rawFiles).toHaveLength(1); + expect(result.warnings).toEqual([ + "Ignored invalid Hacker News configured feed(s): frontpage. Valid feeds are: ask, best, job, new, show, top.", + "openwiki: Hacker News search request failed: 503 Service Unavailable", + ]); + expect(urls.length).toBeGreaterThan(0); + expect( + urls.every((url) => new URL(url).pathname === "/api/v1/search_by_date"), + ).toBe(true); + + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as HackerNewsDump; + expect(dump.feeds).toEqual([]); + expect(dump.queryResults).toEqual([]); + }); +}); + +describe("hackernews connector gating and limits", () => { + test("skips without any fetch when the connector is disabled", async () => { + const home = await createTempHome(); + await writeHackerNewsConfig(home, { enabled: false }); + const fetchMock = vi.fn(() => { + throw new Error("fetch should not be called"); + }); + vi.stubGlobal("fetch", fetchMock); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.message).toContain("not enabled"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + test("defaults the per-feed and per-query limits when config sets them null", async () => { + const home = await createTempHome(); + // Null limits (malformed config) must fall back to the built-in maximum via + // the `?? max` arm of getOptionLimit rather than become NaN. + await writeHackerNewsConfig(home, { + enabled: true, + feeds: ["top"], + maxItemsPerFeed: null, + maxResultsPerQuery: null, + queries: [], + }); + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const requestPath = new URL(getRequestUrl(input)).pathname; + if (requestPath === "/v0/topstories.json") { + return Promise.resolve(jsonResponse([10])); + } + if (requestPath === "/v0/item/10.json") { + return Promise.resolve(jsonResponse({ id: 10, time: 1 })); + } + return Promise.resolve(jsonResponse({})); + }), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + }); +}); + +describe("hackernews feed and query resilience", () => { + test("downgrades a feed fetch failure to a warning and still writes results", async () => { + const home = await createTempHome(); + await writeHackerNewsConfig(home, { + enabled: true, + feeds: ["top"], + queries: [], + }); + // Every Firebase call returns a 500 so hnFirebaseApi throws; the per-feed + // catch must record a warning instead of aborting the run. + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve( + new Response("nope", { status: 500, statusText: "Server Error" }), + ), + ), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(result.rawFiles).toHaveLength(1); + expect( + result.warnings.some((warning) => + warning.includes("top: Hacker News API request failed: 500"), + ), + ).toBe(true); + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as HackerNewsDump; + expect(dump.feeds).toEqual([]); + }); + + test("applies the time window to feed items and to the search numeric filter", async () => { + const home = await createTempHome(); + await writeHackerNewsConfig(home, { + enabled: true, + feeds: ["top"], + maxItemsPerFeed: 5, + queries: ["openwiki"], + queryTags: ["story"], + }); + const nowSec = Math.floor(Date.now() / 1000); + const urls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const url = new URL(getRequestUrl(input)); + urls.push(url.toString()); + const requestPath = url.pathname; + if (requestPath === "/v0/topstories.json") { + return Promise.resolve(jsonResponse([1, 2, 3, 4])); + } + if (requestPath === "/v0/item/1.json") { + // Fresh item: within the window, kept. + return Promise.resolve(jsonResponse({ id: 1, time: nowSec })); + } + if (requestPath === "/v0/item/2.json") { + // Old item: outside the window, dropped. + return Promise.resolve(jsonResponse({ id: 2, time: 100 })); + } + if (requestPath === "/v0/item/3.json") { + // A null item (deleted story) must be skipped by the `item &&` guard. + return Promise.resolve(jsonResponse(null)); + } + if (requestPath === "/v0/item/4.json") { + // Missing `time` coerces to 0, which is outside any real window. + return Promise.resolve(jsonResponse({ id: 4 })); + } + if (requestPath === "/api/v1/search_by_date") { + return Promise.resolve( + jsonResponse({ hits: [{ objectID: "h1" }], nbHits: 1, page: 0 }), + ); + } + return Promise.resolve(jsonResponse({})); + }), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest({ windowHours: 24 }); + + expect(result.status).toBe("success"); + const searchUrl = new URL( + urls.find((url) => url.includes("search_by_date")) ?? "", + ); + expect(searchUrl.searchParams.get("numericFilters")).toMatch( + /^created_at_i>\d+$/u, + ); + expect(searchUrl.searchParams.get("tags")).toBe("story"); + + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as HackerNewsDump & { + feeds: { items: { id: number }[] }[]; + windowHours: number | null; + }; + // Only the fresh item survives the window filter. + expect(dump.feeds[0]?.items.map((item) => item.id)).toEqual([1]); + expect(dump.queryResults).toHaveLength(1); + expect(dump.windowHours).toBe(24); + }); +}); + +describe("hackernews feed selection normalization", () => { + test("falls back to the default feeds when configured feeds is an empty array", async () => { + const home = await createTempHome(); + // An empty feeds array is treated as "unspecified", not "no feeds", so the + // default top/new selection runs; a query keeps the run from erroring. + await writeHackerNewsConfig(home, { + enabled: true, + feeds: [], + maxItemsPerFeed: 1, + queries: ["openwiki"], + }); + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const requestPath = new URL(getRequestUrl(input)).pathname; + if (requestPath === "/v0/topstories.json") { + return Promise.resolve(jsonResponse([10])); + } + if (requestPath === "/v0/newstories.json") { + return Promise.resolve(jsonResponse([20])); + } + if ( + requestPath === "/v0/item/10.json" || + requestPath === "/v0/item/20.json" + ) { + return Promise.resolve(jsonResponse({ id: 10, time: 1 })); + } + if (requestPath === "/api/v1/search_by_date") { + return Promise.resolve(jsonResponse({ hits: [], nbHits: 0 })); + } + return Promise.resolve(jsonResponse({})); + }), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(result.warnings).toEqual([]); + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as HackerNewsDump; + expect(dump.feeds.map((feed) => feed.feed)).toEqual(["top", "new"]); + }); + + test("warns about a non-array feeds config and runs no feeds", async () => { + const home = await createTempHome(); + // A scalar `feeds` must be reported as a non-array and yield no feeds; the + // query keeps the run alive so the warning is observable in a success run. + await writeHackerNewsConfig(home, { + enabled: true, + feeds: "top", + queries: ["openwiki"], + }); + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const requestPath = new URL(getRequestUrl(input)).pathname; + if (requestPath === "/api/v1/search_by_date") { + return Promise.resolve(jsonResponse({ hits: [], nbHits: 0 })); + } + return Promise.resolve(jsonResponse({})); + }), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect( + result.warnings.some( + (warning) => + warning.includes("Ignored invalid Hacker News configured feed(s)") && + warning.includes("non-array string"), + ), + ).toBe(true); + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as HackerNewsDump; + expect(dump.feeds).toEqual([]); + }); + + test("warns about blank or non-string feed entries but keeps the valid ones", async () => { + const home = await createTempHome(); + // A mixed array (valid feed plus a number and a blank string) must keep the + // valid feed and report that some entries were dropped. + await writeHackerNewsConfig(home, { + enabled: true, + feeds: ["top", 5, ""], + maxItemsPerFeed: 1, + queries: [], + }); + vi.stubGlobal( + "fetch", + vi.fn((input: string | URL | Request) => { + const requestPath = new URL(getRequestUrl(input)).pathname; + if (requestPath === "/v0/topstories.json") { + return Promise.resolve(jsonResponse([10])); + } + if (requestPath === "/v0/item/10.json") { + return Promise.resolve(jsonResponse({ id: 10, time: 1 })); + } + return Promise.resolve(jsonResponse({})); + }), + ); + const connector = await loadHackerNewsConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect( + result.warnings.some((warning) => + warning.includes("non-string or blank value"), + ), + ).toBe(true); + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as HackerNewsDump; + expect(dump.feeds.map((feed) => feed.feed)).toEqual(["top"]); + }); +}); + +function jsonResponse(value: unknown): Response { + return new Response(JSON.stringify(value), { + headers: { "Content-Type": "application/json" }, + status: 200, + }); +} diff --git a/test/connectors/langsmith-api.test.ts b/test/connectors/sources/langsmith/api.test.ts similarity index 98% rename from test/connectors/langsmith-api.test.ts rename to test/connectors/sources/langsmith/api.test.ts index fe0396fc..99b47e49 100644 --- a/test/connectors/langsmith-api.test.ts +++ b/test/connectors/sources/langsmith/api.test.ts @@ -28,7 +28,7 @@ vi.mock("langsmith", () => { }); const { createLangSmithApi, isRateLimitError } = - await import("../../src/connectors/sources/langsmith/api.ts"); + await import("../../../../src/connectors/sources/langsmith/api.ts"); beforeEach(() => { sdk.runs = []; diff --git a/test/connectors/langsmith-index.test.ts b/test/connectors/sources/langsmith/index.test.ts similarity index 94% rename from test/connectors/langsmith-index.test.ts rename to test/connectors/sources/langsmith/index.test.ts index f2700c10..94f26083 100644 --- a/test/connectors/langsmith-index.test.ts +++ b/test/connectors/sources/langsmith/index.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -vi.mock("../../src/connectors/io.ts", () => ({ +vi.mock("../../../../src/connectors/io.ts", () => ({ createRunId: () => "run-1", readConnectorState: () => Promise.resolve({ version: 1 }), updateStateWithRun: (state: Record, entry: unknown) => ({ @@ -14,27 +14,27 @@ vi.mock("../../src/connectors/io.ts", () => ({ ), })); -vi.mock("../../src/connectors/sources/langsmith/api.ts", () => ({ +vi.mock("../../../../src/connectors/sources/langsmith/api.ts", () => ({ createLangSmithApi: vi.fn(), })); // Keep the real sanitizers (the validation under test) and mock only the reader. vi.mock( - "../../src/connectors/sources/langsmith/repo-config.ts", + "../../../../src/connectors/sources/langsmith/repo-config.ts", async (importOriginal) => ({ ...(await importOriginal< - typeof import("../../src/connectors/sources/langsmith/repo-config.ts") + typeof import("../../../../src/connectors/sources/langsmith/repo-config.ts") >()), readLangSmithRepoConfig: vi.fn(), }), ); -import { writeRawJson } from "../../src/connectors/io.ts"; -import type { LangSmithApi } from "../../src/connectors/sources/langsmith/api.ts"; -import { createLangSmithApi } from "../../src/connectors/sources/langsmith/api.ts"; -import { createLangSmithConnector } from "../../src/connectors/sources/langsmith/index.ts"; -import type { LangSmithRepoConfig } from "../../src/connectors/sources/langsmith/repo-config.ts"; -import { readLangSmithRepoConfig } from "../../src/connectors/sources/langsmith/repo-config.ts"; +import { writeRawJson } from "../../../../src/connectors/io.ts"; +import type { LangSmithApi } from "../../../../src/connectors/sources/langsmith/api.ts"; +import { createLangSmithApi } from "../../../../src/connectors/sources/langsmith/api.ts"; +import { createLangSmithConnector } from "../../../../src/connectors/sources/langsmith/index.ts"; +import type { LangSmithRepoConfig } from "../../../../src/connectors/sources/langsmith/repo-config.ts"; +import { readLangSmithRepoConfig } from "../../../../src/connectors/sources/langsmith/repo-config.ts"; import type { Run } from "langsmith"; const KEY = "OPENWIKI_LANGSMITH_API_KEY"; diff --git a/test/connectors/langsmith-repo-config.test.ts b/test/connectors/sources/langsmith/repo-config.test.ts similarity index 99% rename from test/connectors/langsmith-repo-config.test.ts rename to test/connectors/sources/langsmith/repo-config.test.ts index c62815a3..9133f734 100644 --- a/test/connectors/langsmith-repo-config.test.ts +++ b/test/connectors/sources/langsmith/repo-config.test.ts @@ -9,7 +9,7 @@ import { sanitizeLangSmithApiBaseUrl, sanitizeLangSmithApiKeyEnv, writeLangSmithRepoConfig, -} from "../../src/connectors/sources/langsmith/repo-config.ts"; +} from "../../../../src/connectors/sources/langsmith/repo-config.ts"; const tempRoots: string[] = []; diff --git a/test/connectors/langsmith-runs.test.ts b/test/connectors/sources/langsmith/runs.test.ts similarity index 97% rename from test/connectors/langsmith-runs.test.ts rename to test/connectors/sources/langsmith/runs.test.ts index d6506922..26c9e732 100644 --- a/test/connectors/langsmith-runs.test.ts +++ b/test/connectors/sources/langsmith/runs.test.ts @@ -4,8 +4,8 @@ import { isErrorRun, selectSampleBuckets, summarizeSample, -} from "../../src/connectors/sources/langsmith/runs.ts"; -import type { BucketedRoot } from "../../src/connectors/sources/langsmith/runs.ts"; +} from "../../../../src/connectors/sources/langsmith/runs.ts"; +import type { BucketedRoot } from "../../../../src/connectors/sources/langsmith/runs.ts"; import type { Run } from "langsmith"; function run(fields: Record): Run { diff --git a/test/connectors/langsmith-setup.test.ts b/test/connectors/sources/langsmith/setup.test.ts similarity index 95% rename from test/connectors/langsmith-setup.test.ts rename to test/connectors/sources/langsmith/setup.test.ts index b8a9a0fc..8963320e 100644 --- a/test/connectors/langsmith-setup.test.ts +++ b/test/connectors/sources/langsmith/setup.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -vi.mock("../../src/connectors/sources/langsmith/repo-config.ts", () => ({ +vi.mock("../../../../src/connectors/sources/langsmith/repo-config.ts", () => ({ readLangSmithRepoConfig: vi.fn(), writeLangSmithRepoConfig: vi.fn(() => Promise.resolve()), })); @@ -8,12 +8,12 @@ vi.mock("../../src/connectors/sources/langsmith/repo-config.ts", () => ({ import { readLangSmithRepoConfig, writeLangSmithRepoConfig, -} from "../../src/connectors/sources/langsmith/repo-config.ts"; +} from "../../../../src/connectors/sources/langsmith/repo-config.ts"; import { loadLangSmithSetup, nextLangSmithApiKeyEnv, saveLangSmithSetup, -} from "../../src/connectors/sources/langsmith/setup.ts"; +} from "../../../../src/connectors/sources/langsmith/setup.ts"; const REPO = "/repo"; const EU = "https://eu.api.smith.langchain.com"; diff --git a/test/connectors/mcp.test.ts b/test/connectors/sources/mcp.test.ts similarity index 92% rename from test/connectors/mcp.test.ts rename to test/connectors/sources/mcp.test.ts index 9f0bc48e..52b73036 100644 --- a/test/connectors/mcp.test.ts +++ b/test/connectors/sources/mcp.test.ts @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; // the io layer, the MCP client (tool listing / execution), and the transport // sanitizer. All three are mocked so we assert only the branching and the // shape of what gets written. -vi.mock("../../src/connectors/io.ts", () => ({ +vi.mock("../../../src/connectors/io.ts", () => ({ createRunId: () => "run-1", readConnectorConfig: vi.fn(), readConnectorState: () => Promise.resolve({ version: 1 }), @@ -17,12 +17,12 @@ vi.mock("../../src/connectors/io.ts", () => ({ writeRawJson: vi.fn(() => Promise.resolve("/raw/mcp/run-1/output.json")), })); -vi.mock("../../src/connectors/mcp-client.ts", () => ({ +vi.mock("../../../src/connectors/mcp-client.ts", () => ({ executeMcpReadOnlyOperations: vi.fn(), listMcpTools: vi.fn(), })); -vi.mock("../../src/connectors/mcp-runtime.ts", () => ({ +vi.mock("../../../src/connectors/mcp-runtime.ts", () => ({ sanitizeMcpTransport: vi.fn(() => ({ redacted: true })), })); @@ -30,14 +30,14 @@ import { readConnectorConfig, writeConnectorState, writeRawJson, -} from "../../src/connectors/io.ts"; +} from "../../../src/connectors/io.ts"; import { executeMcpReadOnlyOperations, listMcpTools, -} from "../../src/connectors/mcp-client.ts"; -import { sanitizeMcpTransport } from "../../src/connectors/mcp-runtime.ts"; -import { createMcpConnector } from "../../src/connectors/sources/mcp.ts"; -import type { McpConnectorConfig } from "../../src/connectors/types.ts"; +} from "../../../src/connectors/mcp-client.ts"; +import { sanitizeMcpTransport } from "../../../src/connectors/mcp-runtime.ts"; +import { createMcpConnector } from "../../../src/connectors/sources/mcp.ts"; +import type { McpConnectorConfig } from "../../../src/connectors/types.ts"; const INPUT = { description: "Notion MCP", diff --git a/test/connectors/sources/slack.test.ts b/test/connectors/sources/slack.test.ts index 8b5f3f4d..bfbce5ed 100644 --- a/test/connectors/sources/slack.test.ts +++ b/test/connectors/sources/slack.test.ts @@ -456,6 +456,59 @@ describe("slack recent-history normalization", () => { ]); }); + test("treats a non-numeric message timestamp as zero when sorting", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + maxConversations: 5, + messagesPerConversation: 10, + streams: ["recent_messages"], + }); + stubSlack((method, params) => { + if (method === "auth.test") { + return AUTH_OK; + } + if (method === "users.info") { + return { ok: true, user: { id: "UABC123" } }; + } + if (method === "search.messages") { + return { messages: [], ok: true }; + } + if (method === "conversations.list") { + return { + channels: [{ id: "C1", name: "alpha", updated: 10 }], + ok: true, + response_metadata: {}, + }; + } + if (method === "conversations.history" && params.channel === "C1") { + // A garbage (non-numeric) ts must be treated as timestamp 0 by the + // comparator, sorting below the well-formed message rather than + // producing NaN and an unstable order. + return { + messages: [ + { text: "bad", ts: "not-a-number", user: "UABC123" }, + { text: "good", ts: "900.0", user: "UABC123" }, + ], + ok: true, + }; + } + return { messages: [], ok: true }; + }); + const connector = await loadSlackConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const dump = (await readRaw(result.rawFiles, "recent-messages.json")) as { + userMessages: { message: { text: string } }[]; + }; + expect(dump.userMessages.map((entry) => entry.message.text)).toEqual([ + "good", + "bad", + ]); + }); + test("warns and handles a missing timestamp when the user has one message", async () => { const home = await createTempHome(); await writeConnectorConfig(home, { diff --git a/test/connectors/web-search.test.ts b/test/connectors/sources/web-search.test.ts similarity index 81% rename from test/connectors/web-search.test.ts rename to test/connectors/sources/web-search.test.ts index ab255954..8e70233c 100644 --- a/test/connectors/web-search.test.ts +++ b/test/connectors/sources/web-search.test.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; -import { OPENWIKI_TAVILY_API_KEY_ENV_KEY } from "../../src/config/constants.ts"; +import { OPENWIKI_TAVILY_API_KEY_ENV_KEY } from "../../../src/config/constants.ts"; // Tavily is the one network boundary; the constructor options and per-query // invoke calls are captured so we can assert query building without hitting the @@ -65,7 +65,7 @@ async function loadWebSearchConnector(home: string) { process.env.HOME = home; process.env.USERPROFILE = home; const { createWebSearchConnector } = - await import("../../src/connectors/sources/web-search.ts"); + await import("../../../src/connectors/sources/web-search.ts"); return createWebSearchConnector(); } @@ -225,6 +225,54 @@ describe("web-search connector query execution", () => { expect(tavily.constructed[0]?.maxResults).toBe(20); }); + test("uses a configured time range verbatim and ignores the window", async () => { + const home = await createTempHome(); + // An explicit, valid timeRange must win over any window-derived range. + await writeWebSearchConfig(home, { + enabled: true, + queries: ["openwiki"], + timeRange: "week", + }); + process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY] = "tvly-key"; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest({ windowHours: 6 }); + + expect(result.status).toBe("success"); + expect(tavily.constructed[0]?.timeRange).toBe("week"); + + const dump = JSON.parse( + await readFile(result.rawFiles[0] ?? "", "utf8"), + ) as WebSearchDump; + expect(dump.timeRange).toBe("week"); + }); + + test("normalizes null include flags and result limit to their defaults", async () => { + const home = await createTempHome(); + // Null-valued flags and limit (malformed config) must coerce to the coded + // defaults rather than reach the Tavily client as null. + await writeWebSearchConfig(home, { + enabled: true, + includeAnswer: null, + includeImages: null, + includeRawContent: null, + maxResults: null, + queries: ["openwiki"], + }); + process.env[OPENWIKI_TAVILY_API_KEY_ENV_KEY] = "tvly-key"; + const connector = await loadWebSearchConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + expect(tavily.constructed[0]).toMatchObject({ + includeAnswer: true, + includeImages: false, + includeRawContent: false, + maxResults: 5, + }); + }); + test("derives a day time range from a short window when none is configured", async () => { const home = await createTempHome(); await writeWebSearchConfig(home, { enabled: true, queries: ["openwiki"] }); diff --git a/test/connectors/sources/x.test.ts b/test/connectors/sources/x.test.ts index 50e7c96e..ec186776 100644 --- a/test/connectors/sources/x.test.ts +++ b/test/connectors/sources/x.test.ts @@ -307,6 +307,93 @@ describe("x connector request construction", () => { expect(dump.pages).toHaveLength(2); }); + test("prefers explicitly requested streams over the configured streams", async () => { + const home = await createTempHome(); + // options.streams (a non-empty array) must override config.streams so a + // caller can scope a run to a single stream. + await writeConnectorConfig(home, { + enabled: true, + maxPagesPerStream: 1, + streams: ["mentions"], + userId: "U1", + }); + const requests = stubFetchByPath(() => ({ data: [], meta: {} })); + const connector = await loadXConnector(home); + + const result = await connector.ingest({ streams: ["user_posts"] }); + + expect(result.status).toBe("success"); + expect(requests.map((url) => url.pathname)).toEqual(["/2/users/U1/tweets"]); + }); + + test("skips writing when a list_posts stream has no configured list ids", async () => { + const home = await createTempHome(); + // With only list_posts selected and no list ids, the stream loop produces + // no raw files, so the run reports "skipped" and issues no request. + await writeConnectorConfig(home, { + enabled: true, + listIds: [], + maxPagesPerStream: 1, + streams: ["list_posts"], + userId: "U1", + }); + const requests = stubFetchByPath(() => ({ data: [], meta: {} })); + const connector = await loadXConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("skipped"); + expect(result.rawFiles).toEqual([]); + expect(requests).toHaveLength(0); + }); + + test("keeps the prior since_id when a list page returns no newest_id", async () => { + const home = await createTempHome(); + await writeConnectorConfig(home, { + enabled: true, + listIds: ["L1"], + maxPagesPerStream: 1, + streams: ["list_posts"], + userId: "U1", + }); + // A prior cursor exists; a page with no newest_id must leave it untouched + // rather than clobber the persisted position. + await writeConnectorState(home, { + latestIds: { "list_posts:L1": "prev" }, + version: 1, + }); + stubFetchByPath(() => ({ data: [{ id: "a" }], meta: { result_count: 1 } })); + const connector = await loadXConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const state = await readConnectorState(home); + expect(state.latestIds?.["list_posts:L1"]).toBe("prev"); + }); + + test("records an empty cursor when a list has no prior id and no newest_id", async () => { + const home = await createTempHome(); + // No prior state and a page without newest_id must resolve the cursor to the + // empty-string fallback, which is then pruned from persisted state. + await writeConnectorConfig(home, { + enabled: true, + listIds: ["L1"], + maxPagesPerStream: 1, + streams: ["list_posts"], + userId: "U1", + }); + stubFetchByPath(() => ({ data: [{ id: "a" }], meta: { result_count: 1 } })); + const connector = await loadXConnector(home); + + const result = await connector.ingest(); + + expect(result.status).toBe("success"); + const state = await readConnectorState(home); + // The empty cursor is stripped by removeEmptyValues, so the key is absent. + expect(state.latestIds?.["list_posts:L1"]).toBeUndefined(); + }); + test("adds a window start_time derived from windowHours", async () => { const home = await createTempHome(); await writeConnectorConfig(home, { diff --git a/test/mermaid/dom-shim.test.ts b/test/mermaid/dom-shim.test.ts new file mode 100644 index 00000000..6041ba2a --- /dev/null +++ b/test/mermaid/dom-shim.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, test } from "vitest"; +import { ensureDomGlobals } from "../../src/mermaid/dom-shim.ts"; + +describe("ensureDomGlobals", () => { + test("installs a window/document and is idempotent on a second call", async () => { + await ensureDomGlobals(); + const installed = globalThis.window; + expect(installed).toBeDefined(); + expect(globalThis.document).toBeDefined(); + + // The second call must early-return once `window` exists rather than build a + // fresh jsdom, so the globals stay identical. + await ensureDomGlobals(); + expect(globalThis.window).toBe(installed); + }); +}); diff --git a/test/mermaid/mermaid-validate.test.ts b/test/mermaid/mermaid-validate.test.ts index f85f40f8..4de05d10 100644 --- a/test/mermaid/mermaid-validate.test.ts +++ b/test/mermaid/mermaid-validate.test.ts @@ -135,6 +135,20 @@ describe("sanitizeMermaidError", () => { expect(sanitizeMermaidError(new Error(""))).toBe("unknown error"); }); + test("stringifies non-Error thrown values, falling back on unserializable ones", () => { + // A parser can throw a non-Error; the message is derived without itself + // throwing so sanitization never crashes the wiki run. + expect(sanitizeMermaidError("plain string reason")).toBe( + "plain string reason", + ); + expect(sanitizeMermaidError({ detail: "as json" })).toContain("as json"); + + // A circular object cannot be JSON-serialized, so String() is the fallback. + const circular: Record = {}; + circular.self = circular; + expect(sanitizeMermaidError(circular)).toBe("[object Object]"); + }); + test("keeps the parser diagnosis and drops caret-underline noise", () => { const err = new Error( "Parse error on line 20:\n... Svc->>Note: notify\n----------^\nExpecting 'ACTOR', got 'note'", diff --git a/test/mermaid/mermaid-wiki.test.ts b/test/mermaid/mermaid-wiki.test.ts index ae970f0f..e8b27e58 100644 --- a/test/mermaid/mermaid-wiki.test.ts +++ b/test/mermaid/mermaid-wiki.test.ts @@ -1,3 +1,9 @@ +import type { + BackendProtocolV2, + EditResult, + LsResult, + ReadRawResult, +} from "deepagents"; import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; @@ -5,6 +11,25 @@ import { describe, expect, test, vi } from "vitest"; import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; import { validateWikiMermaid } from "../../src/mermaid/wiki.ts"; +/** + * Builds a minimal stub backend so the read/write error branches, which the + * real disk-backed backend does not surface deterministically, can be driven. + */ +function stubBackend(handlers: { + ls?: (p: string) => LsResult; + readRaw?: (p: string) => ReadRawResult; + edit?: (p: string) => EditResult; +}): BackendProtocolV2 { + return { + ls: vi.fn(handlers.ls ?? (() => ({ files: [] }))), + readRaw: vi.fn(handlers.readRaw ?? (() => ({ data: undefined }))), + edit: vi.fn(handlers.edit ?? (() => ({}))), + } as unknown as BackendProtocolV2; +} + +const BROKEN_BODY = ["flowchart TD", " A[Start] --> end[The End]"].join("\n"); +const BROKEN_DOC = ["```mermaid", BROKEN_BODY, "```"].join("\n"); + const VALID = [ "```mermaid", "sequenceDiagram", @@ -136,3 +161,91 @@ describe("validateWikiMermaid", () => { }); }); }); + +describe("validateWikiMermaid backend error handling", () => { + test("treats a subdirectory that cannot be listed as empty", async () => { + // A directory whose listing fails is skipped rather than aborting the scan, + // matching the index middleware's tolerance. + const backend = stubBackend({ + ls: (p) => + p === "/openwiki" + ? { files: [{ path: "/openwiki/sub/", is_dir: true }] } + : { error: "listing denied" }, + }); + + const report = await validateWikiMermaid(backend, "repository"); + expect(report.filesScanned).toBe(0); + }); + + test("joins array-shaped file content before scanning", async () => { + // Some backends return content as a line array; it must be joined to text, + // and a clean file leaves no diff. + const backend = stubBackend({ + ls: () => ({ files: [{ path: "/openwiki/a.md", is_dir: false }] }), + readRaw: () => ({ + data: { + content: ["# Title", "", "Prose only, no fences."], + mimeType: "text/markdown", + created_at: "2026-07-13T00:00:00.000Z", + modified_at: "2026-07-13T00:00:00.000Z", + }, + }), + }); + + const report = await validateWikiMermaid(backend, "repository"); + expect(report.filesScanned).toBe(1); + expect(report.fencesDegraded).toBe(0); + }); + + test("throws an actionable error when a file cannot be read", async () => { + const backend = stubBackend({ + ls: () => ({ files: [{ path: "/openwiki/a.md", is_dir: false }] }), + readRaw: () => ({ error: "read denied" }), + }); + + await expect(validateWikiMermaid(backend, "repository")).rejects.toThrow( + /Unable to read \/openwiki\/a\.md/u, + ); + }); + + test("rejects a non-text file rather than mangling it", async () => { + // Binary content has no text form to scan, so the scan fails loudly instead + // of silently corrupting the file. + const backend = stubBackend({ + ls: () => ({ files: [{ path: "/openwiki/a.md", is_dir: false }] }), + readRaw: () => ({ + data: { + content: new Uint8Array([1, 2, 3]), + mimeType: "application/octet-stream", + created_at: "2026-07-13T00:00:00.000Z", + modified_at: "2026-07-13T00:00:00.000Z", + }, + }), + }); + + await expect(validateWikiMermaid(backend, "repository")).rejects.toThrow( + /is not a text file/u, + ); + }); + + test("surfaces a rewrite failure for a degraded file", async () => { + // When the degraded rewrite cannot be persisted the run fails rather than + // leaving a broken diagram unrepaired and unreported. + const backend = stubBackend({ + ls: () => ({ files: [{ path: "/openwiki/a.md", is_dir: false }] }), + readRaw: () => ({ + data: { + content: `# Page\n\n${BROKEN_DOC}\n`, + mimeType: "text/markdown", + created_at: "2026-07-13T00:00:00.000Z", + modified_at: "2026-07-13T00:00:00.000Z", + }, + }), + edit: () => ({ error: "write denied" }), + }); + + await expect(validateWikiMermaid(backend, "repository")).rejects.toThrow( + /Unable to rewrite \/openwiki\/a\.md/u, + ); + }); +}); diff --git a/test/mermaid/validate-fallback.test.ts b/test/mermaid/validate-fallback.test.ts new file mode 100644 index 00000000..a295c2e3 --- /dev/null +++ b/test/mermaid/validate-fallback.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test, vi } from "vitest"; + +// Force the optional `mermaid` peer dependency to look uninstalled: the dynamic +// import rejects, so `loadMermaid()` must swallow the failure, resolve to +// undefined, and callers must fall back to the conservative heuristic instead of +// crashing the wiki run. This is the "no authoritative parser" path that cannot +// be reached while mermaid is actually installed in the test env. +vi.mock("mermaid", () => { + throw Object.assign(new Error("Cannot find package 'mermaid'"), { + code: "ERR_MODULE_NOT_FOUND", + }); +}); + +import { + findInvalidMermaidFences, + loadMermaid, +} from "../../src/mermaid/validate.ts"; + +/** Wraps a diagram body in a ```mermaid fenced block. */ +function fence(body: string): string { + return ["```mermaid", body, "```"].join("\n"); +} + +describe("mermaid parser unavailable", () => { + test("loadMermaid resolves to undefined rather than throwing", async () => { + await expect(loadMermaid()).resolves.toBeUndefined(); + }); + + test("falls back to the heuristic and still flags a near-certain breakage", async () => { + // `end` as a flowchart node id is caught by the heuristic even with no real + // parser, so an obviously broken diagram is not silently accepted. + const errors = await findInvalidMermaidFences( + fence("flowchart TD\n A[Start] --> end[The End]"), + ); + + expect(errors).toHaveLength(1); + expect(errors[0].error).toMatch(/reserved word/u); + }); + + test("passes a diagram the heuristic cannot fault", async () => { + // The heuristic is deliberately conservative, so a clean flowchart is left + // alone even though no authoritative parser ran. + const errors = await findInvalidMermaidFences( + fence("flowchart TD\n A[Start] --> B[Done]"), + ); + + expect(errors).toHaveLength(0); + }); +}); diff --git a/test/okf/frontmatter.test.ts b/test/okf/frontmatter.test.ts index 7359e146..179478b1 100644 --- a/test/okf/frontmatter.test.ts +++ b/test/okf/frontmatter.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, test } from "vitest"; +import type { BackendProtocolV2 } from "deepagents"; +import { describe, expect, test, vi } from "vitest"; import { deriveMinimalFrontmatter, normalizeConceptContent, @@ -8,6 +9,8 @@ import { renderFrontmatter, setFrontmatterField, splitFrontmatter, + validateOkfFrontmatter, + validatePersistedFile, } from "../../src/okf/frontmatter.ts"; const PATH = "/openwiki/architecture/overview.md"; @@ -297,6 +300,67 @@ describe("parseFrontmatterFields", () => { }); }); +describe("validateOkfFrontmatter non-mapping root", () => { + test("rejects front matter whose YAML root is not a mapping", () => { + // A scalar or list parses cleanly but is not an OKF field map, so it is + // reported distinctly from a YAML syntax error. + for (const block of ["just a scalar", "- one\n- two"]) { + expect(validateOkfFrontmatter(`---\n${block}\n---\n`)).toMatchObject({ + issues: [{ code: "invalid_yaml_root" }], + valid: false, + }); + } + }); +}); + +describe("validatePersistedFile", () => { + function backend(read: { + error?: string; + content?: string | string[] | Uint8Array; + }): BackendProtocolV2 { + return { + readRaw: vi.fn(() => ({ + error: read.error, + data: + read.content === undefined + ? undefined + : { + content: read.content, + mimeType: "text/markdown", + created_at: "2026-07-13T00:00:00.000Z", + modified_at: "2026-07-13T00:00:00.000Z", + }, + })), + } as unknown as BackendProtocolV2; + } + + test("validates the joined text of a persisted file", async () => { + await expect( + validatePersistedFile( + backend({ content: ["---", "type: Reference", "---", ""] }), + "/openwiki/page.md", + ), + ).resolves.toEqual({ valid: true }); + }); + + test("reports a read failure instead of validating missing text", async () => { + // A read error, absent content, or binary data all mean there is no final + // Markdown to validate, which is surfaced as a single structured issue. + for (const read of [ + { error: "boom" }, + { content: undefined }, + { content: new Uint8Array([1, 2, 3]) }, + ]) { + await expect( + validatePersistedFile(backend(read), "/openwiki/page.md"), + ).resolves.toMatchObject({ + issues: [{ code: "file_read_failed" }], + valid: false, + }); + } + }); +}); + describe("renderFrontmatter", () => { test("renders type, title, and the generated mark", () => { expect( diff --git a/test/okf/index-sync-errors.test.ts b/test/okf/index-sync-errors.test.ts new file mode 100644 index 00000000..69ce2549 --- /dev/null +++ b/test/okf/index-sync-errors.test.ts @@ -0,0 +1,117 @@ +import type { + BackendProtocolV2, + EditResult, + LsResult, + ReadRawResult, + WriteResult, +} from "deepagents"; +import { describe, expect, test, vi } from "vitest"; +import { + migrateWikiToOkf, + synchronizeWikiIndexes, +} from "../../src/okf/index-sync.ts"; + +/** + * Builds a minimal stub backend so the read/list/write failure branches, which + * the real disk-backed backend does not surface deterministically, can be + * driven directly. + */ +function stubBackend(handlers: { + ls?: (p: string) => LsResult; + readRaw?: (p: string) => ReadRawResult; + edit?: (p: string) => EditResult; + write?: (p: string) => WriteResult; +}): BackendProtocolV2 { + return { + ls: vi.fn(handlers.ls ?? (() => ({ files: [] }))), + readRaw: vi.fn(handlers.readRaw ?? (() => ({ data: undefined }))), + edit: vi.fn(handlers.edit ?? (() => ({}))), + write: vi.fn(handlers.write ?? (() => ({}))), + } as unknown as BackendProtocolV2; +} + +function textData(content: string | string[] | Uint8Array): ReadRawResult { + return { + data: { + content, + mimeType: "text/markdown", + created_at: "2026-07-13T00:00:00.000Z", + modified_at: "2026-07-13T00:00:00.000Z", + }, + }; +} + +const rootListing: LsResult = { + files: [{ path: "/openwiki/page.md", is_dir: false }], +}; + +describe("collectDirectories error propagation", () => { + test("rethrows when a non-root subdirectory cannot be listed", async () => { + // The root list is allowed to be missing, but a subdirectory that fails to + // list is a real error and must abort the sync. + const backend = stubBackend({ + ls: (p) => + p === "/openwiki" + ? { files: [{ path: "/openwiki/sub/", is_dir: true }] } + : { error: "listing denied" }, + }); + + await expect(synchronizeWikiIndexes(backend, "repository")).rejects.toThrow( + /Unable to list \/openwiki\/sub/u, + ); + }); +}); + +describe("readText error handling", () => { + test("throws an actionable error when a concept file cannot be read", async () => { + const backend = stubBackend({ + ls: () => rootListing, + readRaw: () => ({ error: "read denied" }), + }); + + await expect(migrateWikiToOkf(backend, "repository")).rejects.toThrow( + /Unable to read \/openwiki\/page\.md/u, + ); + }); + + test("rejects a non-text concept file rather than mangling it", async () => { + const backend = stubBackend({ + ls: () => rootListing, + readRaw: () => textData(new Uint8Array([1, 2, 3])), + }); + + await expect(migrateWikiToOkf(backend, "repository")).rejects.toThrow( + /is not a text file/u, + ); + }); +}); + +describe("write error handling", () => { + test("surfaces a normalization write failure", async () => { + // A legacy page (no `type`) must be rewritten with a derived block; if that + // edit fails the migration fails loudly. + const backend = stubBackend({ + ls: () => rootListing, + readRaw: () => textData("# Legacy\n\nBody.\n"), + edit: () => ({ error: "edit denied" }), + }); + + await expect(migrateWikiToOkf(backend, "repository")).rejects.toThrow( + /Unable to normalize \/openwiki\/page\.md/u, + ); + }); + + test("surfaces an index write failure", async () => { + // A conformant page needs no rewrite, so the sync proceeds to write the + // directory index; a failed index write aborts the run. + const backend = stubBackend({ + ls: () => rootListing, + readRaw: () => textData("---\ntype: Reference\ntitle: Page\n---\n"), + write: () => ({ error: "index write denied" }), + }); + + await expect(synchronizeWikiIndexes(backend, "repository")).rejects.toThrow( + /Unable to write \/openwiki\/index\.md/u, + ); + }); +}); diff --git a/test/telemetry/client-no-key.test.ts b/test/telemetry/client-no-key.test.ts new file mode 100644 index 00000000..c12d67b2 --- /dev/null +++ b/test/telemetry/client-no-key.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test, vi } from "vitest"; + +// Blank the publishable PostHog key so the "no key configured" guard is +// exercised; the real key is a hardcoded constant, so this is the only way to +// reach the early bail-out that must never construct a client or send anything. +vi.mock("../../src/telemetry/config.ts", () => ({ + DEFAULT_POSTHOG_KEY: "", + DEFAULT_POSTHOG_HOST: "https://us.i.posthog.com", + FLUSH_TIMEOUT_MS: 3000, +})); + +const posthog = vi.hoisted(() => ({ + PostHog: vi.fn(), +})); +vi.mock("posthog-node", () => ({ PostHog: posthog.PostHog })); + +import { capture } from "../../src/telemetry/client.ts"; + +describe("client.capture without a configured key", () => { + test("reports not-sent and never constructs a client", async () => { + const sent = await capture({ + distinctId: "id-1", + event: "openwiki_run", + properties: { command: "init" }, + }); + + expect(sent).toBe(false); + expect(posthog.PostHog).not.toHaveBeenCalled(); + }); +}); diff --git a/test/telemetry/telemetry-install-id.test.ts b/test/telemetry/telemetry-install-id.test.ts index cc1156e4..7588a44d 100644 --- a/test/telemetry/telemetry-install-id.test.ts +++ b/test/telemetry/telemetry-install-id.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // Mock the filesystem so the install-id lifecycle is tested in isolation, // without touching the developer's real ~/.openwiki. @@ -10,10 +10,26 @@ const fsMock = vi.hoisted(() => ({ })); vi.mock("node:fs/promises", () => fsMock); -import { getOrCreateInstallId } from "../../src/telemetry/install-id.ts"; +// Pin CI detection off so `noticeSuppressed()` is driven only by the env vars +// this test controls; otherwise a run under GitHub Actions would suppress the +// notice and flip the "pending" assertions. +vi.mock("ci-info", () => ({ default: { isCI: false, name: null } })); + +import { + firstRunNoticePending, + getOrCreateInstallId, +} from "../../src/telemetry/install-id.ts"; const UUID = /^[0-9a-f-]{36}$/i; +const OPT_OUT_KEYS = [ + "OPENWIKI_TELEMETRY_DISABLED", + "DO_NOT_TRACK", + "OPENWIKI_SCHEDULED", +] as const; + +let savedEnv: Record; + function enoent(): NodeJS.ErrnoException { return Object.assign(new Error("missing"), { code: "ENOENT" }); } @@ -23,6 +39,22 @@ beforeEach(() => { fsMock.mkdir.mockClear(); fsMock.readFile.mockReset(); fsMock.writeFile.mockClear(); + + savedEnv = {}; + for (const key of OPT_OUT_KEYS) { + savedEnv[key] = process.env[key]; + delete process.env[key]; + } +}); + +afterEach(() => { + for (const key of OPT_OUT_KEYS) { + if (savedEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = savedEnv[key]; + } + } }); describe("getOrCreateInstallId", () => { @@ -58,4 +90,44 @@ describe("getOrCreateInstallId", () => { expect(isNew).toBe(true); expect(fsMock.writeFile).toHaveBeenCalledOnce(); }); + + test("rethrows a read failure that is not a missing file", async () => { + // A permission error means the id may actually exist; minting a fresh one + // and overwriting would be wrong, so the error propagates instead. + fsMock.readFile.mockRejectedValueOnce( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + + await expect(getOrCreateInstallId()).rejects.toThrow(/denied/u); + expect(fsMock.writeFile).not.toHaveBeenCalled(); + }); +}); + +describe("firstRunNoticePending", () => { + test("returns false and mints nothing when the notice is suppressed", async () => { + process.env.OPENWIKI_TELEMETRY_DISABLED = "1"; + + expect(await firstRunNoticePending()).toBe(false); + // Suppression short-circuits before any id lookup, so the store is untouched. + expect(fsMock.readFile).not.toHaveBeenCalled(); + expect(fsMock.writeFile).not.toHaveBeenCalled(); + }); + + test("is pending only on the run that mints the id", async () => { + fsMock.readFile.mockRejectedValueOnce(enoent()); + expect(await firstRunNoticePending()).toBe(true); + + fsMock.readFile.mockResolvedValueOnce("existing-id\n"); + expect(await firstRunNoticePending()).toBe(false); + }); + + test("never throws even if the id store read fails hard", async () => { + // Telemetry must never break a run: a non-ENOENT read failure is swallowed + // and reported as "no notice" rather than propagating. + fsMock.readFile.mockRejectedValueOnce( + Object.assign(new Error("denied"), { code: "EACCES" }), + ); + + await expect(firstRunNoticePending()).resolves.toBe(false); + }); }); diff --git a/test/telemetry/telemetry.test.ts b/test/telemetry/telemetry.test.ts index 4db948b6..1d4817fa 100644 --- a/test/telemetry/telemetry.test.ts +++ b/test/telemetry/telemetry.test.ts @@ -1,4 +1,4 @@ -import { readFile, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -859,6 +859,27 @@ describe("senders.recordRun", () => { sent: false, }); await rm(file, { force: true }); + test("reports, without throwing, when the tee file cannot be written", async () => { + // A tee target under a regular file cannot have its parent directory + // created; recordRun must log the failure and carry on, never breaking the + // run over a diagnostics file. + process.env.OPENWIKI_TELEMETRY_DISABLED = "1"; + const dir = await mkdtemp(path.join(tmpdir(), "ow-tel-badtee-")); + const blocker = path.join(dir, "blocker"); + await writeFile(blocker, "not a directory"); + // `blocker` is a file, so mkdir of it as a parent directory fails. + const file = path.join(blocker, "out.json"); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + + await expect( + recordRun(runDetails({ telemetryFile: file })), + ).resolves.toBeUndefined(); + + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining("could not write telemetry file"), + ); + errorSpy.mockRestore(); + await rm(dir, { recursive: true, force: true }); }); }); @@ -914,6 +935,19 @@ describe("recordRun connector properties", () => { expect(runEvent().properties.production).toBe(false); }); + test("carries the error class onto a failed run's event", async () => { + // A failure outcome attaches its classified error category so failures can + // be split by kind without ever sending the raw message. + await recordRun( + runDetails({ outcome: "failure", errorClass: "provider_auth" }), + ); + + expect(runEvent().properties).toMatchObject({ + outcome: "failure", + error_class: "provider_auth", + }); + }); + test("update runs omit the init-only setup fields", async () => { // The agent only sets mode/provider/connectors on init; an update payload // built without them must not carry mode/provider/connector_ properties. diff --git a/vitest.config.ts b/vitest.config.ts index 30b71af0..0571e40f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,7 +15,11 @@ export default defineConfig({ provider: "v8", all: true, include: ["src/**/*.{ts,tsx}"], - exclude: ["src/**/*.d.ts"], + // `types.ts` modules are pure `interface`/`type` declarations that emit no + // runtime JavaScript, so v8 reports them as 0-of-0 statements and drags the + // aggregate down for code that cannot be executed. Exclude them (and .d.ts) + // so the denominator reflects only files with real, coverable behavior. + exclude: ["src/**/*.d.ts", "src/**/types.ts"], reporter: ["text", "text-summary", "html", "json-summary", "lcov"], }, }, From 4806421a72a9594f288d084f43d9e4d523f4ec7a Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 16:48:23 -0700 Subject: [PATCH 06/13] test coverage --- CONTRIBUTING.md | 4 +- package.json | 2 +- test/agent/skills.test.ts | 52 +- test/auth/ngrok.test.ts | 320 ++++++- test/auth/oauth-redirect-override.test.ts | 121 +++ test/auth/oauth-run.test.ts | 645 ++++++++++++++ test/connectors/mcp-client.test.ts | 819 +++++++++++++++++- test/ingestion/ingestion-run.test.ts | 554 ++++++++++++ .../launchd-calendar-interval.test.ts | 22 + test/scheduling/schedule-operations.test.ts | 63 ++ test/scheduling/schedules-launchd.test.ts | 405 +++++++++ vitest.config.ts | 11 +- 12 files changed, 3003 insertions(+), 15 deletions(-) create mode 100644 test/auth/oauth-redirect-override.test.ts create mode 100644 test/auth/oauth-run.test.ts create mode 100644 test/ingestion/ingestion-run.test.ts create mode 100644 test/scheduling/schedules-launchd.test.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4eda11e..59f55daf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,8 +29,8 @@ pnpm run lint pnpm test ``` -`format` and `lint` match the checks that run on every PR, and `test` runs the -Vitest suite. +`format` and `lint` match the checks that run on every PR, and `test` +typechecks, builds, and runs the Vitest suite with coverage. If your change should ship in a release, also add a changeset (see below). diff --git a/package.json b/package.json index 1897f75d..a101263d 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "prepack": "pnpm run build", "release": "pnpm run build && changeset publish", "start": "node dist/cli/cli.js", - "test": "vitest run", + "test": "pnpm run typecheck && pnpm run build && pnpm run coverage", "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.client.json" }, "dependencies": { diff --git a/test/agent/skills.test.ts b/test/agent/skills.test.ts index 54aff7ad..21d5abde 100644 --- a/test/agent/skills.test.ts +++ b/test/agent/skills.test.ts @@ -9,7 +9,7 @@ import { } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, test } from "vitest"; +import { describe, expect, test, vi } from "vitest"; import { replaceSkillDirectories } from "../../src/agent/skills.ts"; describe("replaceSkillDirectories", () => { @@ -107,3 +107,53 @@ describe("replaceSkillDirectories", () => { expect(normalizedSkill).toContain("openwiki: mermaid parse failed"); }); }); + +describe("syncBundledSkills", () => { + test("copies the bundled skills into the OpenWiki home", async () => { + // openWikiSkillsDir is derived from os.homedir() at module load, so point + // HOME (and USERPROFILE for the Windows portability job) at a throwaway home + // and re-import both modules so the write lands in the temp tree, not the + // developer's real ~/.openwiki. + const home = await mkdtemp(path.join(os.tmpdir(), "openwiki-skills-home-")); + const originalHome = process.env.HOME; + const originalUserProfile = process.env.USERPROFILE; + process.env.HOME = home; + process.env.USERPROFILE = home; + vi.resetModules(); + + try { + const { syncBundledSkills } = await import("../../src/agent/skills.ts"); + const { openWikiSkillsDir } = + await import("../../src/config/openwiki-home.ts"); + + await syncBundledSkills(); + + const listDirs = async (dir: string): Promise => + (await readdir(dir, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + + // The source of truth is the repo's bundled skills/ directory; the home + // copy must reproduce exactly those skill directories. + const bundled = await listDirs(path.join(process.cwd(), "skills")); + const copied = await listDirs(openWikiSkillsDir); + + expect(bundled.length).toBeGreaterThan(0); + expect(copied).toEqual(bundled); + } finally { + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalUserProfile === undefined) { + delete process.env.USERPROFILE; + } else { + process.env.USERPROFILE = originalUserProfile; + } + vi.resetModules(); + await rm(home, { force: true, recursive: true }); + } + }); +}); diff --git a/test/auth/ngrok.test.ts b/test/auth/ngrok.test.ts index 82d6d0cf..a4835136 100644 --- a/test/auth/ngrok.test.ts +++ b/test/auth/ngrok.test.ts @@ -1,11 +1,24 @@ -import { describe, expect, test, vi } from "vitest"; +import { EventEmitter } from "node:events"; +import type { MockInstance } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; // startNgrokTunnel persists the resolved redirect config through the env file. -// It is mocked so the validation-rejection cases below cannot touch disk; those -// cases all throw during validatePort / normalizeNgrokUrl, before any save or -// `ngrok` spawn, so no real tunnel process is ever launched here. +// The save is mocked so no test touches ~/.openwiki/.env: the validation cases +// throw before any save or `ngrok` spawn, and the tunnel-lifecycle cases below +// assert on the mock's calls instead of writing real credentials to disk. The +// mock is hoisted so those cases can inspect exactly what was persisted. +const saveOpenWikiEnvMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); vi.mock("../../src/config/env.ts", () => ({ - saveOpenWikiEnv: vi.fn(() => Promise.resolve()), + saveOpenWikiEnv: saveOpenWikiEnvMock, +})); + +// `startNgrokTunnel` shells out to the real `ngrok` binary via child_process. +// The spawn is mocked to hand back a controllable fake child so the suite never +// launches a subprocess, and so it can assert the exact argv and `shell:false` +// that keep operator-supplied ports/URLs from being reinterpreted by a shell. +const spawnMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", () => ({ + spawn: spawnMock, })); import { @@ -15,6 +28,43 @@ import { const PORT = 53682; +/** + * A fake ngrok child process. `waitForNgrokExit` only listens for the "error" + * and "exit" events, so an EventEmitter is a faithful stand-in that lets a test + * drive either outcome deterministically without a real subprocess. + */ +function fakeChild(): EventEmitter { + const child = new EventEmitter(); + spawnMock.mockReturnValue(child); + return child; +} + +/** + * Drains queued microtasks (via a macrotask boundary) so the mocked `spawn` + * runs and `waitForNgrokExit` registers its listeners before the test emits an + * exit/error event. `setImmediate` runs only after the microtask queue is fully + * drained, so a single await settles the whole promise chain up to the point + * where the code parks on the child's exit. It is real-timer safe and never + * waits on the wall clock. + */ +function flushMicrotasks(): Promise { + return new Promise((resolve) => setImmediate(resolve)); +} + +/** + * Builds a fake `fetch` Response exposing just the `ok`/`json` surface that + * `fetchNgrokRedirectUri` consumes from the ngrok local API. + */ +function fetchResponse( + ok: boolean, + body: unknown, +): { ok: boolean; json: () => Promise } { + return { + ok, + json: () => Promise.resolve(body), + }; +} + /** * Builds an ngrok `/api/tunnels` style payload from tunnel descriptors. */ @@ -241,3 +291,263 @@ describe("startNgrokTunnel validation", () => { ); }); }); + +describe("startNgrokTunnel with a fixed custom url", () => { + let stdoutSpy: MockInstance; + + beforeEach(() => { + saveOpenWikiEnvMock.mockClear(); + spawnMock.mockReset(); + // The production code streams progress to stdout; silence and capture it so + // the test output stays clean and the messages can be asserted. + stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + }); + + test("spawns ngrok with a pinned --url and no shell, then resolves on clean exit", async () => { + const child = fakeChild(); + + // A bare host (no scheme) exercises the https:// prepend and the success + // return of normalizeNgrokUrl. + const pending = startNgrokTunnel({ url: "custom.ngrok.app" }); + await flushMicrotasks(); + + // argv-injection safety: the port and pinned URL are passed as discrete argv + // entries with shell:false, so a shell can never re-parse them. + expect(spawnMock).toHaveBeenCalledWith( + "ngrok", + ["http", String(PORT), "--url", "https://custom.ngrok.app"], + { shell: false, stdio: "inherit" }, + ); + + child.emit("exit", 0); + + await expect(pending).resolves.toEqual({ + baseUrl: "https://custom.ngrok.app", + port: PORT, + redirectUri: "https://custom.ngrok.app/callback", + }); + + // With a pinned URL the redirect is known up front, so it is persisted + // directly and the local-API discovery poll is skipped entirely. + expect(saveOpenWikiEnvMock).toHaveBeenCalledWith({ + OPENWIKI_OAUTH_CALLBACK_PORT: String(PORT), + OPENWIKI_HTTPS_OAUTH_REDIRECT_URI: "https://custom.ngrok.app/callback", + }); + }); + + test("accepts an explicit /callback path on the custom url", async () => { + const child = fakeChild(); + + const pending = startNgrokTunnel({ + port: 8080, + url: "https://custom.ngrok.app/callback", + }); + await flushMicrotasks(); + + expect(spawnMock).toHaveBeenCalledWith( + "ngrok", + ["http", "8080", "--url", "https://custom.ngrok.app"], + { shell: false, stdio: "inherit" }, + ); + + child.emit("exit", 0); + await expect(pending).resolves.toMatchObject({ + redirectUri: "https://custom.ngrok.app/callback", + }); + }); + + test("treats a SIGINT shutdown as a clean exit", async () => { + // An operator pressing Ctrl-C stops the tunnel intentionally, so the signal + // must resolve rather than surface as an ngrok failure. + const child = fakeChild(); + + const pending = startNgrokTunnel({ url: "https://custom.ngrok.app" }); + await flushMicrotasks(); + child.emit("exit", null, "SIGINT"); + + await expect(pending).resolves.toMatchObject({ port: PORT }); + }); + + test("rejects when ngrok cannot be spawned", async () => { + // A missing binary surfaces as an "error" event; the wrapper wraps it with + // context so the caller learns ngrok never started. + const child = fakeChild(); + + const pending = startNgrokTunnel({ url: "https://custom.ngrok.app" }); + await flushMicrotasks(); + child.emit("error", new Error("ENOENT")); + + await expect(pending).rejects.toThrow("Could not start ngrok: ENOENT"); + }); + + test("wraps a non-Error spawn failure with a generic message", async () => { + // The "error" payload is typed as Error but is not guaranteed to be one; a + // non-Error value must still yield a clean message rather than leaking + // undefined from `.message`. + const child = fakeChild(); + + const pending = startNgrokTunnel({ url: "https://custom.ngrok.app" }); + await flushMicrotasks(); + child.emit("error", "boom"); + + await expect(pending).rejects.toThrow("Could not start ngrok."); + }); + + test("rejects when ngrok exits non-zero", async () => { + const child = fakeChild(); + + const pending = startNgrokTunnel({ url: "https://custom.ngrok.app" }); + await flushMicrotasks(); + child.emit("exit", 1, null); + + await expect(pending).rejects.toThrow( + "ngrok exited with code=1 signal=null.", + ); + }); +}); + +describe("startNgrokTunnel with a random url (local-API discovery)", () => { + let stdoutSpy: MockInstance; + let fetchMock: ReturnType; + + beforeEach(() => { + saveOpenWikiEnvMock.mockClear(); + spawnMock.mockReset(); + stdoutSpy = vi + .spyOn(process.stdout, "write") + .mockImplementation(() => true); + fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + }); + + afterEach(() => { + stdoutSpy.mockRestore(); + vi.unstubAllGlobals(); + vi.useRealTimers(); + }); + + test("spawns ngrok with no --url and discovers the redirect from the ngrok API", async () => { + const child = fakeChild(); + fetchMock.mockResolvedValue( + fetchResponse( + true, + tunnels([ + { addr: `localhost:${PORT}`, public_url: "https://random.ngrok.app" }, + ]), + ), + ); + + const pending = startNgrokTunnel({ url: null }); + await flushMicrotasks(); + + // No pinned URL means no `--url` flag; ngrok picks the forwarding host. + expect(spawnMock).toHaveBeenCalledWith("ngrok", ["http", String(PORT)], { + shell: false, + stdio: "inherit", + }); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:4040/api/tunnels"); + + child.emit("exit", 0); + + // The discovered value is only persisted to env; the resolved result keeps + // the empty base/redirect that the random-URL branch returns. + await expect(pending).resolves.toEqual({ + baseUrl: "", + port: PORT, + redirectUri: "", + }); + expect(saveOpenWikiEnvMock).toHaveBeenCalledWith({ + OPENWIKI_OAUTH_CALLBACK_PORT: String(PORT), + OPENWIKI_HTTPS_OAUTH_REDIRECT_URI: "https://random.ngrok.app/callback", + }); + }); + + test("retries the poll until the tunnel is ready, tolerating a fetch error and an empty payload", async () => { + // The poll loop uses a real 500ms sleep between attempts; fake timers make + // the retry deterministic instead of racing the wall clock. + vi.useFakeTimers(); + const child = fakeChild(); + fetchMock + // First attempt: ngrok API not up yet -> fetch rejects (caught -> null). + .mockRejectedValueOnce(new Error("ECONNREFUSED")) + // Second attempt: API up but no matching tunnel yet -> null redirect. + .mockResolvedValueOnce(fetchResponse(true, { tunnels: [] })) + // Third attempt: tunnel ready. + .mockResolvedValue( + fetchResponse( + true, + tunnels([ + { + addr: `localhost:${PORT}`, + public_url: "https://ready.ngrok.app", + }, + ]), + ), + ); + + const pending = startNgrokTunnel({ url: null }); + + // Advance across two 500ms sleeps so the third fetch discovers the tunnel. + await vi.advanceTimersByTimeAsync(500); + await vi.advanceTimersByTimeAsync(500); + + expect(fetchMock).toHaveBeenCalledTimes(3); + + child.emit("exit", 0); + await pending; + + expect(saveOpenWikiEnvMock).toHaveBeenCalledWith({ + OPENWIKI_OAUTH_CALLBACK_PORT: String(PORT), + OPENWIKI_HTTPS_OAUTH_REDIRECT_URI: "https://ready.ngrok.app/callback", + }); + }); + + test("gives up after the discovery timeout without persisting a redirect", async () => { + // A ngrok that never exposes a usable tunnel must not hang the caller: after + // the 15s discovery window the poll returns null and the code prints manual + // instructions instead of saving a redirect. + vi.useFakeTimers(); + const child = fakeChild(); + fetchMock.mockResolvedValue(fetchResponse(false, {})); + + const pending = startNgrokTunnel({ url: null }); + + // Exhaust the whole 15s discovery budget (30 polls at 500ms apart). + await vi.advanceTimersByTimeAsync(15_000); + + child.emit("exit", 0); + await pending; + + // Only the initial "clear the redirect" save happened; discovery saved + // nothing because no redirect was ever found. + expect(saveOpenWikiEnvMock).toHaveBeenCalledTimes(1); + expect(saveOpenWikiEnvMock).toHaveBeenCalledWith({ + OPENWIKI_OAUTH_CALLBACK_PORT: String(PORT), + OPENWIKI_HTTPS_OAUTH_REDIRECT_URI: "", + }); + }); + + test("ends discovery early when ngrok exits before a tunnel appears", async () => { + // The discovery poll races the process exit; if ngrok dies first the race + // resolves via the exit and a non-zero code still surfaces as a failure. + vi.useFakeTimers(); + const child = fakeChild(); + fetchMock.mockResolvedValue(fetchResponse(false, {})); + + const pending = startNgrokTunnel({ url: null }); + // Drain the pre-spawn microtasks (fake timers stub setImmediate, so advance + // by 0 to settle them) until the exit listener is registered, then kill it. + await vi.advanceTimersByTimeAsync(0); + child.emit("exit", 1, null); + + await expect(pending).rejects.toThrow( + "ngrok exited with code=1 signal=null.", + ); + }); +}); diff --git a/test/auth/oauth-redirect-override.test.ts b/test/auth/oauth-redirect-override.test.ts new file mode 100644 index 00000000..ecdfc691 --- /dev/null +++ b/test/auth/oauth-redirect-override.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// getProviderRedirectUri validates the Slack https redirect override (used for +// tunnelled callbacks) only after the loopback server has bound but before +// createCallbackServer returns its close handle. On the rejecting branches the +// function throws with no handle to clean up, so a real listener would leak and +// hang the run. node:http is mocked here with a server that never binds a real +// socket, keeping those SSRF-guard branches reachable with zero open handles. +// The full happy-path flow that needs a real callback lives in oauth-run and +// oauth-callback-server; this file targets only the override validation. +vi.mock("node:http", () => { + function createServer(): unknown { + return { + address() { + return { address: "127.0.0.1", family: "IPv4", port: 54321 }; + }, + close(callback?: (error?: Error) => void) { + callback?.(); + }, + closeAllConnections() {}, + closeIdleConnections() {}, + listen(_port: number, _host: string, callback?: () => void) { + callback?.(); + return this; + }, + once() { + return this; + }, + }; + } + + return { default: { createServer } }; +}); + +import { createCallbackServer } from "../../src/auth/oauth.ts"; +import { getAuthProvider } from "../../src/auth/providers.ts"; + +const CALLBACK_PORT_ENV_KEY = "OPENWIKI_OAUTH_CALLBACK_PORT"; +const HTTPS_REDIRECT_ENV_KEY = "OPENWIKI_HTTPS_OAUTH_REDIRECT_URI"; + +const originalCallbackPort = process.env[CALLBACK_PORT_ENV_KEY]; +const originalHttpsRedirect = process.env[HTTPS_REDIRECT_ENV_KEY]; + +beforeEach(() => { + // A valid port keeps getCallbackPort from throwing first, so each case + // exercises the override validation rather than the port guard. + process.env[CALLBACK_PORT_ENV_KEY] = "54321"; + delete process.env[HTTPS_REDIRECT_ENV_KEY]; +}); + +afterEach(() => { + if (originalCallbackPort === undefined) { + delete process.env[CALLBACK_PORT_ENV_KEY]; + } else { + process.env[CALLBACK_PORT_ENV_KEY] = originalCallbackPort; + } + if (originalHttpsRedirect === undefined) { + delete process.env[HTTPS_REDIRECT_ENV_KEY]; + } else { + process.env[HTTPS_REDIRECT_ENV_KEY] = originalHttpsRedirect; + } +}); + +describe("slack https redirect override validation", () => { + test("rejects an override whose path is not /callback", async () => { + // A tunnel must terminate on the exact /callback path OpenWiki listens for; + // any other path would silently drop the redirect off-target. + process.env[HTTPS_REDIRECT_ENV_KEY] = "https://tunnel.example.com/wrong"; + + await expect( + createCallbackServer(getAuthProvider("slack")), + ).rejects.toThrow(`${HTTPS_REDIRECT_ENV_KEY} must end with /callback.`); + }); + + test("rejects an override that embeds credentials", async () => { + // Credentials in the redirect URI would leak into the authorization request + // and browser history, so a userinfo component is refused. + process.env[HTTPS_REDIRECT_ENV_KEY] = + "https://user:pass@tunnel.example.com/callback"; + + await expect( + createCallbackServer(getAuthProvider("slack")), + ).rejects.toThrow( + `${HTTPS_REDIRECT_ENV_KEY} must not include credentials or a fragment.`, + ); + }); + + test("rejects an override that carries a fragment", async () => { + // A fragment is not part of what the authorization server matches, so a + // stray one signals a malformed override and is refused. + process.env[HTTPS_REDIRECT_ENV_KEY] = + "https://tunnel.example.com/callback#frag"; + + await expect( + createCallbackServer(getAuthProvider("slack")), + ).rejects.toThrow( + `${HTTPS_REDIRECT_ENV_KEY} must not include credentials or a fragment.`, + ); + }); + + test("rejects a non-https override", async () => { + // The override exists precisely to keep the redirect on TLS end-to-end, so a + // plaintext http override defeats its purpose and is refused. + process.env[HTTPS_REDIRECT_ENV_KEY] = "http://tunnel.example.com/callback"; + + await expect( + createCallbackServer(getAuthProvider("slack")), + ).rejects.toThrow(`${HTTPS_REDIRECT_ENV_KEY} must use https.`); + }); + + test("adopts a fully valid https override", async () => { + process.env[HTTPS_REDIRECT_ENV_KEY] = "https://tunnel.example.com/callback"; + const callback = await createCallbackServer(getAuthProvider("slack")); + + try { + expect(callback.redirectUri).toBe("https://tunnel.example.com/callback"); + } finally { + await callback.close(); + } + }); +}); diff --git a/test/auth/oauth-run.test.ts b/test/auth/oauth-run.test.ts new file mode 100644 index 00000000..48f69e0e --- /dev/null +++ b/test/auth/oauth-run.test.ts @@ -0,0 +1,645 @@ +import { createHash } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; + +// runOAuthAuth is the orchestrator that ties together the whole authorization +// code + PKCE flow. Every external effect it has is mocked so the flow runs +// end-to-end without ever leaving the machine: the env file is never written, +// no `open`/`pbcopy` subprocess is spawned, and the only real socket is the +// loopback callback server the code binds on 127.0.0.1. `fetch` is stubbed with +// a URL router that returns synthetic discovery / registration / token JSON, so +// the assertions can inspect exactly what the flow sent (PKCE challenge, state, +// code_verifier) without contacting a real provider. + +// loadOpenWikiEnv/saveOpenWikiEnv are mocked so no test reads or writes the +// user's real ~/.openwiki/.env; saveOpenWikiEnv is a spy so the token->env +// mapping the flow persists can be asserted directly. +const loadOpenWikiEnvMock = vi.hoisted(() => vi.fn(() => Promise.resolve({}))); +const saveOpenWikiEnvMock = vi.hoisted(() => vi.fn(() => Promise.resolve())); +vi.mock("../../src/config/env.ts", () => ({ + loadOpenWikiEnv: loadOpenWikiEnvMock, + saveOpenWikiEnv: saveOpenWikiEnvMock, +})); + +// openBrowser/copyToClipboard shell out through execFile; the mock hands back a +// fake child (with a no-op stdin) and reports success so the suite never +// launches `open`, `xdg-open`, `rundll32`, or `pbcopy`. Calls are recorded so +// the argv can be asserted: execFile (not exec) with an explicit args array +// means the authorization URL's `&` separators are passed verbatim and never +// reinterpreted by a shell. +const execFileMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", () => ({ + execFile: execFileMock, +})); + +import net from "node:net"; +import { runOAuthAuth } from "../../src/auth/oauth.ts"; + +const CALLBACK_PORT_ENV_KEY = "OPENWIKI_OAUTH_CALLBACK_PORT"; +const GOOGLE_CLIENT_ID_ENV_KEY = "OPENWIKI_GOOGLE_CLIENT_ID"; +const GOOGLE_CLIENT_SECRET_ENV_KEY = "OPENWIKI_GOOGLE_CLIENT_SECRET"; + +// The real loopback callback must be driven by a genuine HTTP client, but the +// test stubs global fetch with the provider router. Capturing the original +// fetch first lets the callback request bypass that router and hit 127.0.0.1. +const realFetch = globalThis.fetch; + +const NOTION_AUTH_ENDPOINT = "https://mcp.notion.com/authorize"; +const NOTION_TOKEN_ENDPOINT = "https://api.notion.com/v1/oauth/token"; +const NOTION_REGISTRATION_ENDPOINT = "https://api.notion.com/v1/oauth/register"; +const GMAIL_TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; + +type FetchCall = { + body: unknown; + method: string; + url: string; +}; + +type RouterOptions = { + registrationNoClientId?: boolean; + registrationStatus?: number; + tokenPayload?: unknown; + tokenStatus?: number; +}; + +let port: number; +let savedEnv: Record; +// Captured from the saveOpenWikiEnv spy so the persisted token->env mapping can +// be inspected with a real type instead of reaching into `any` mock.calls. +let persistedUpdates: Record | undefined; + +/** + * Reserves and releases an ephemeral loopback port so each flow binds its own + * callback server and concurrent test files never collide on the fixed default. + */ +async function findFreePort(): Promise { + return await new Promise((resolve, reject) => { + const probe = net.createServer(); + probe.once("error", reject); + probe.listen(0, "127.0.0.1", () => { + const address = probe.address() as net.AddressInfo; + probe.close(() => resolve(address.port)); + }); + }); +} + +/** + * Builds a JSON Response the stubbed fetch can return for discovery, + * registration, and token endpoints. + */ +function jsonResponse(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + headers: { "content-type": "application/json" }, + status, + }); +} + +/** + * Installs a fetch stub that routes provider traffic to synthetic responses and + * records every call so the test can assert the PKCE code_verifier, resource + * binding, and client authentication the flow sent. + */ +function installFetchRouter(options: RouterOptions = {}): FetchCall[] { + const calls: FetchCall[] = []; + // The flow only ever calls fetch with a string or URL, so the mock narrows to + // those and returns a Response synchronously (an awaited non-promise resolves + // fine) to keep the router free of a needless async wrapper. + const fetchMock = vi.fn((input: string | URL, init: RequestInit = {}) => { + const url = typeof input === "string" ? input : input.href; + calls.push({ body: init.body, method: init.method ?? "GET", url }); + + if (url.includes(".well-known/oauth-protected-resource")) { + return jsonResponse({ + authorization_servers: ["https://mcp.notion.com"], + }); + } + + if ( + url.includes(".well-known/oauth-authorization-server") || + url.includes(".well-known/openid-configuration") + ) { + return jsonResponse({ + authorization_endpoint: NOTION_AUTH_ENDPOINT, + registration_endpoint: NOTION_REGISTRATION_ENDPOINT, + token_endpoint: NOTION_TOKEN_ENDPOINT, + }); + } + + if (url === NOTION_REGISTRATION_ENDPOINT) { + if (options.registrationStatus && options.registrationStatus !== 200) { + return jsonResponse({}, options.registrationStatus); + } + if (options.registrationNoClientId) { + return jsonResponse({}); + } + return jsonResponse({ client_id: "notion-client-123" }); + } + + if (url === NOTION_TOKEN_ENDPOINT || url === GMAIL_TOKEN_ENDPOINT) { + if (options.tokenStatus && options.tokenStatus !== 200) { + return jsonResponse({ error: "invalid_grant" }, options.tokenStatus); + } + return jsonResponse(options.tokenPayload ?? {}); + } + + throw new Error(`unexpected fetch to ${url}`); + }); + + vi.stubGlobal("fetch", fetchMock); + return calls; +} + +/** + * Starts runOAuthAuth and exposes a promise that resolves with the + * authorization URL event once the flow reaches the "waiting for callback" + * stage, so the test can read the generated state and drive the loopback + * redirect deterministically (the server is already bound by this point). + */ +function startRun(providerId: "gmail" | "notion"): { + runPromise: Promise; + urlReady: Promise<{ + copiedToClipboard: boolean; + openedBrowser: boolean; + url: string; + }>; +} { + let resolveUrl: (event: { + copiedToClipboard: boolean; + openedBrowser: boolean; + url: string; + }) => void; + const urlReady = new Promise<{ + copiedToClipboard: boolean; + openedBrowser: boolean; + url: string; + }>((resolve) => { + resolveUrl = resolve; + }); + + const runPromise = runOAuthAuth(providerId, { + onAuthorizationUrl: (event) => { + resolveUrl({ + copiedToClipboard: event.copiedToClipboard, + openedBrowser: event.openedBrowser, + url: event.url, + }); + }, + silent: true, + }); + + return { runPromise, urlReady }; +} + +/** + * Sends the OAuth provider's redirect to the loopback callback with the given + * state and code, standing in for the browser the flow would otherwise open. + */ +async function driveCallback(state: string, code: string): Promise { + await realFetch( + `http://127.0.0.1:${port}/callback?code=${encodeURIComponent( + code, + )}&state=${encodeURIComponent(state)}`, + ); +} + +/** + * Returns the recorded token-exchange call for a given endpoint, whose body + * carries the PKCE code_verifier and (for confidential clients) the secret. + */ +function tokenCall(calls: FetchCall[], endpoint: string): FetchCall { + const call = calls.find((entry) => entry.url === endpoint); + if (!call) { + throw new Error(`no token exchange recorded for ${endpoint}`); + } + return call; +} + +beforeEach(async () => { + port = await findFreePort(); + savedEnv = { + [CALLBACK_PORT_ENV_KEY]: process.env[CALLBACK_PORT_ENV_KEY], + [GOOGLE_CLIENT_ID_ENV_KEY]: process.env[GOOGLE_CLIENT_ID_ENV_KEY], + [GOOGLE_CLIENT_SECRET_ENV_KEY]: process.env[GOOGLE_CLIENT_SECRET_ENV_KEY], + }; + process.env[CALLBACK_PORT_ENV_KEY] = String(port); + + persistedUpdates = undefined; + saveOpenWikiEnvMock.mockImplementation((updates: Record) => { + persistedUpdates = updates; + return Promise.resolve(); + }); + + // execFile always "succeeds" with a fake child; the stdin end() is a no-op so + // copyToClipboard's pipe write has somewhere to go without a real pbcopy. + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + callback: (error: Error | null) => void, + ) => { + queueMicrotask(() => callback(null)); + return { stdin: { end: vi.fn() } }; + }, + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + execFileMock.mockReset(); + loadOpenWikiEnvMock.mockClear(); + saveOpenWikiEnvMock.mockClear(); + for (const [key, value] of Object.entries(savedEnv)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +}); + +describe("runOAuthAuth authorization code + PKCE flow", () => { + test("completes the Gmail confidential-client flow and persists the token mapping", async () => { + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + process.env[GOOGLE_CLIENT_SECRET_ENV_KEY] = "gmail-secret"; + const calls = installFetchRouter({ + tokenPayload: { + access_token: "gmail-access", + expires_in: 3600, + refresh_token: "gmail-refresh", + token_type: "Bearer", + }, + }); + + const { runPromise, urlReady } = startRun("gmail"); + const event = await urlReady; + const authUrl = new URL(event.url); + + // The authorization request must carry a hashed PKCE challenge (S256), never + // the raw verifier, plus an unguessable state that binds the later redirect + // to this process (CSRF integrity). + expect(authUrl.searchParams.get("code_challenge_method")).toBe("S256"); + expect(authUrl.searchParams.get("response_type")).toBe("code"); + expect(authUrl.searchParams.get("client_id")).toBe("gmail-client"); + expect(authUrl.searchParams.get("redirect_uri")).toBe( + `http://127.0.0.1:${port}/callback`, + ); + const state = authUrl.searchParams.get("state"); + const challenge = authUrl.searchParams.get("code_challenge"); + expect(state).toBeTruthy(); + expect(challenge).toBeTruthy(); + + // openBrowser reported success and did so via an explicit argv, not a shell + // string: the last argument is the verbatim URL and the third execFile arg + // is the completion callback (no options object, so shell defaults false). + expect(event.openedBrowser).toBe(true); + const browserCall = execFileMock.mock.calls.find( + (call) => Array.isArray(call[1]) && call[1].at(-1) === event.url, + ); + expect(browserCall).toBeDefined(); + expect(typeof browserCall?.[2]).toBe("function"); + + await driveCallback(state as string, "gmail-auth-code"); + const result = await runPromise; + + // The token request must present the code_verifier whose SHA-256 equals the + // challenge advertised in the authorization URL: that pairing is what proves + // the redeeming client is the one that started the flow. + const exchange = tokenCall(calls, GMAIL_TOKEN_ENDPOINT); + const body = exchange.body as URLSearchParams; + const verifier = body.get("code_verifier") as string; + expect(verifier).toBeTruthy(); + expect(createHash("sha256").update(verifier).digest("base64url")).toBe( + challenge, + ); + expect(body.get("grant_type")).toBe("authorization_code"); + expect(body.get("code")).toBe("gmail-auth-code"); + // client_secret_post providers authenticate the token call with the secret. + expect(body.get("client_secret")).toBe("gmail-secret"); + expect(body.get("redirect_uri")).toBe(`http://127.0.0.1:${port}/callback`); + + expect(saveOpenWikiEnvMock).toHaveBeenCalledTimes(1); + expect(saveOpenWikiEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + OPENWIKI_GMAIL_ACCESS_TOKEN: "gmail-access", + OPENWIKI_GMAIL_REFRESH_TOKEN: "gmail-refresh", + OPENWIKI_GMAIL_TOKEN_TYPE: "Bearer", + }), + ); + // expires_in is converted to an absolute ISO expiry before it is stored. + expect(typeof persistedUpdates?.OPENWIKI_GMAIL_TOKEN_EXPIRES_AT).toBe( + "string", + ); + const gmailResult = result as { + provider: string; + savedEnvKeys: string[]; + }; + expect(gmailResult.provider).toBe("gmail"); + expect(gmailResult.savedEnvKeys).toContain("OPENWIKI_GMAIL_ACCESS_TOKEN"); + expect(gmailResult.savedEnvKeys).toContain("OPENWIKI_GMAIL_REFRESH_TOKEN"); + }); + + test("registers a Notion MCP client dynamically, binds the resource, and stores the client id", async () => { + const calls = installFetchRouter({ + tokenPayload: { + access_token: "notion-access", + expires_in: 3600, + refresh_token: "notion-refresh", + token_type: "bearer", + }, + }); + + const { runPromise, urlReady } = startRun("notion"); + const event = await urlReady; + const authUrl = new URL(event.url); + + // Dynamic client registration must have produced the client_id used in the + // authorization request, and the MCP resource must be bound into the URL so + // the issued token is audience-restricted to Notion's MCP endpoint. + expect(authUrl.origin + authUrl.pathname).toBe(NOTION_AUTH_ENDPOINT); + expect(authUrl.searchParams.get("client_id")).toBe("notion-client-123"); + expect(authUrl.searchParams.get("resource")).toBe( + "https://mcp.notion.com/mcp", + ); + expect(authUrl.searchParams.get("code_challenge_method")).toBe("S256"); + const state = authUrl.searchParams.get("state") as string; + const challenge = authUrl.searchParams.get("code_challenge"); + + const registration = calls.find( + (call) => call.url === NOTION_REGISTRATION_ENDPOINT, + ); + expect(registration?.method).toBe("POST"); + + await driveCallback(state, "notion-auth-code"); + const result = await runPromise; + + const exchange = tokenCall(calls, NOTION_TOKEN_ENDPOINT); + const body = exchange.body as URLSearchParams; + const verifier = body.get("code_verifier") as string; + expect(createHash("sha256").update(verifier).digest("base64url")).toBe( + challenge, + ); + // A public (token_endpoint_auth_method "none") client sends no secret, and + // the token request repeats the resource binding. + expect(body.get("client_secret")).toBeNull(); + expect(body.get("resource")).toBe("https://mcp.notion.com/mcp"); + + // The registered client_id is persisted alongside the tokens so refreshes + // can reuse the same dynamic registration. + expect(saveOpenWikiEnvMock).toHaveBeenCalledWith( + expect.objectContaining({ + OPENWIKI_NOTION_MCP_ACCESS_TOKEN: "notion-access", + OPENWIKI_NOTION_MCP_CLIENT_ID: "notion-client-123", + OPENWIKI_NOTION_MCP_REFRESH_TOKEN: "notion-refresh", + }), + ); + expect(result).toMatchObject({ provider: "notion" }); + }); + + test("rejects a callback whose state does not match the started flow", async () => { + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + process.env[GOOGLE_CLIENT_SECRET_ENV_KEY] = "gmail-secret"; + installFetchRouter({ + tokenPayload: { access_token: "unused" }, + }); + + const { runPromise, urlReady } = startRun("gmail"); + await urlReady; + + // A forged state is the CSRF signal: the redirect did not originate from the + // authorization request this process started, so no token exchange runs. + const rejection = expect(runPromise).rejects.toThrow( + "OAuth callback state did not match.", + ); + await driveCallback("attacker-state", "gmail-auth-code"); + await rejection; + }); + + test("surfaces a failed token exchange", async () => { + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + process.env[GOOGLE_CLIENT_SECRET_ENV_KEY] = "gmail-secret"; + installFetchRouter({ tokenStatus: 500 }); + + const { runPromise, urlReady } = startRun("gmail"); + const event = await urlReady; + const state = new URL(event.url).searchParams.get("state") as string; + + // Attach the rejection expectation before driving the redirect so the + // failure the token exchange raises is never momentarily unhandled. + const rejection = expect(runPromise).rejects.toThrow( + "Gmail token exchange failed: 500", + ); + await driveCallback(state, "gmail-auth-code"); + await rejection; + expect(saveOpenWikiEnvMock).not.toHaveBeenCalled(); + }); + + test("rejects a token response that omits the access token", async () => { + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + process.env[GOOGLE_CLIENT_SECRET_ENV_KEY] = "gmail-secret"; + installFetchRouter({ tokenPayload: { refresh_token: "only-refresh" } }); + + const { runPromise, urlReady } = startRun("gmail"); + const event = await urlReady; + const state = new URL(event.url).searchParams.get("state") as string; + + // Attach the rejection expectation before driving the redirect so the + // mapping failure is never momentarily unhandled. + const rejection = expect(runPromise).rejects.toThrow( + "Gmail did not return an access token.", + ); + await driveCallback(state, "gmail-auth-code"); + await rejection; + expect(saveOpenWikiEnvMock).not.toHaveBeenCalled(); + }); + + test("requires the Gmail client id before any browser is opened", async () => { + // Missing OPENWIKI_GOOGLE_CLIENT_ID fails inside resolveClientRegistration, + // before an authorization URL exists, so the flow never spawns a browser. + delete process.env[GOOGLE_CLIENT_ID_ENV_KEY]; + delete process.env[GOOGLE_CLIENT_SECRET_ENV_KEY]; + installFetchRouter(); + + await expect(runOAuthAuth("gmail", { silent: true })).rejects.toThrow( + "OPENWIKI_GOOGLE_CLIENT_ID is required for auth.", + ); + expect(execFileMock).not.toHaveBeenCalled(); + expect(saveOpenWikiEnvMock).not.toHaveBeenCalled(); + }); + + test("fails when dynamic client registration is rejected", async () => { + installFetchRouter({ registrationStatus: 400 }); + + await expect(runOAuthAuth("notion", { silent: true })).rejects.toThrow( + "Notion MCP dynamic client registration failed: 400", + ); + expect(saveOpenWikiEnvMock).not.toHaveBeenCalled(); + }); + + test("fails when dynamic registration returns no client id", async () => { + installFetchRouter({ registrationNoClientId: true }); + + await expect(runOAuthAuth("notion", { silent: true })).rejects.toThrow( + "Notion MCP dynamic client registration did not return a client_id.", + ); + expect(saveOpenWikiEnvMock).not.toHaveBeenCalled(); + }); + + test("requires the Gmail client secret for a confidential client", async () => { + // Gmail authenticates the token call with client_secret_post, so a present + // client id but missing secret must fail before any browser is opened. + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + delete process.env[GOOGLE_CLIENT_SECRET_ENV_KEY]; + installFetchRouter(); + + await expect(runOAuthAuth("gmail", { silent: true })).rejects.toThrow( + "OPENWIKI_GOOGLE_CLIENT_SECRET is required for auth.", + ); + expect(execFileMock).not.toHaveBeenCalled(); + expect(saveOpenWikiEnvMock).not.toHaveBeenCalled(); + }); + + test("fails when the MCP resource advertises no authorization server", async () => { + // An empty protected-resource document means there is no issuer to register + // with, so the flow stops rather than guessing an endpoint. + const fetchMock = vi.fn((input: string | URL) => { + const url = typeof input === "string" ? input : input.href; + if (url.includes(".well-known/oauth-protected-resource")) { + return jsonResponse({}); + } + throw new Error(`unexpected fetch to ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(runOAuthAuth("notion", { silent: true })).rejects.toThrow( + "Notion MCP did not advertise an authorization server.", + ); + }); + + test("fails when OAuth discovery omits a required endpoint", async () => { + // Registration cannot proceed without all three endpoints; a metadata + // document missing the registration endpoint is rejected. + const fetchMock = vi.fn((input: string | URL) => { + const url = typeof input === "string" ? input : input.href; + if (url.includes(".well-known/oauth-protected-resource")) { + return jsonResponse({ + authorization_servers: ["https://mcp.notion.com"], + }); + } + if (url.includes(".well-known/oauth-authorization-server")) { + return jsonResponse({ + authorization_endpoint: NOTION_AUTH_ENDPOINT, + token_endpoint: NOTION_TOKEN_ENDPOINT, + }); + } + throw new Error(`unexpected fetch to ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(runOAuthAuth("notion", { silent: true })).rejects.toThrow( + "Notion MCP OAuth discovery did not return required endpoints.", + ); + }); + + test("completes the flow even when the browser launcher fails", async () => { + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + process.env[GOOGLE_CLIENT_SECRET_ENV_KEY] = "gmail-secret"; + installFetchRouter({ tokenPayload: { access_token: "gmail-access" } }); + + // A launcher that errors (no `open`/`xdg-open`, no `pbcopy`) must degrade to + // printing the URL, not abort the authorization: openBrowser and + // copyToClipboard swallow the failure and report false. + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + callback: (error: Error | null) => void, + ) => { + queueMicrotask(() => callback(new Error("launcher missing"))); + return { stdin: { end: vi.fn() } }; + }, + ); + + const { runPromise, urlReady } = startRun("gmail"); + const event = await urlReady; + const state = new URL(event.url).searchParams.get("state") as string; + + expect(event.openedBrowser).toBe(false); + expect(event.copiedToClipboard).toBe(false); + + await driveCallback(state, "gmail-auth-code"); + await runPromise; + expect(saveOpenWikiEnvMock).toHaveBeenCalledTimes(1); + }); + + test("uses the win32 file-protocol handler to open the browser", async () => { + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + process.env[GOOGLE_CLIENT_SECRET_ENV_KEY] = "gmail-secret"; + installFetchRouter({ tokenPayload: { access_token: "gmail-access" } }); + + // Exercise the Windows dispatch branch on any host by faking the platform; + // restored in finally so it cannot bleed into another test. + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: "win32", + }); + + try { + const { runPromise, urlReady } = startRun("gmail"); + const event = await urlReady; + const state = new URL(event.url).searchParams.get("state") as string; + + // rundll32 receives the URL as a verbatim argv element (no shell), and + // clipboard copy is macOS-only so it reports false here. + expect(event.openedBrowser).toBe(true); + expect(event.copiedToClipboard).toBe(false); + const browserCall = execFileMock.mock.calls.find( + (call) => call[0] === "rundll32", + ); + expect(browserCall?.[1]).toEqual([ + "url.dll,FileProtocolHandler", + event.url, + ]); + + await driveCallback(state, "gmail-auth-code"); + await runPromise; + } finally { + Object.defineProperty(process, "platform", { + configurable: true, + value: originalPlatform, + }); + } + }); + + test("uses xdg-open to open the browser on linux", async () => { + process.env[GOOGLE_CLIENT_ID_ENV_KEY] = "gmail-client"; + process.env[GOOGLE_CLIENT_SECRET_ENV_KEY] = "gmail-secret"; + installFetchRouter({ tokenPayload: { access_token: "gmail-access" } }); + + const originalPlatform = process.platform; + Object.defineProperty(process, "platform", { + configurable: true, + value: "linux", + }); + + try { + const { runPromise, urlReady } = startRun("gmail"); + const event = await urlReady; + const state = new URL(event.url).searchParams.get("state") as string; + + expect(event.openedBrowser).toBe(true); + expect(event.copiedToClipboard).toBe(false); + const browserCall = execFileMock.mock.calls.find( + (call) => call[0] === "xdg-open", + ); + expect(browserCall?.[1]).toEqual([event.url]); + + await driveCallback(state, "gmail-auth-code"); + await runPromise; + } finally { + Object.defineProperty(process, "platform", { + configurable: true, + value: originalPlatform, + }); + } + }); +}); diff --git a/test/connectors/mcp-client.test.ts b/test/connectors/mcp-client.test.ts index 18d79c36..a02442ad 100644 --- a/test/connectors/mcp-client.test.ts +++ b/test/connectors/mcp-client.test.ts @@ -1,4 +1,5 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { buildChildEnv, executeMcpReadOnlyOperations, @@ -6,6 +7,13 @@ import { listMcpTools, } from "../../src/connectors/mcp-client.ts"; +// The stdio transport spawns a real subprocess. Mocking node:child_process lets +// us drive the JSON-RPC framing (initialize -> list/call -> response) entirely +// in-process, so no test ever forks a child or races on real I/O. +vi.mock("node:child_process"); + +const spawnMock = vi.mocked(spawn); + describe("buildChildEnv", () => { const SECRET_KEYS = [ "ANTHROPIC_API_KEY", @@ -263,3 +271,812 @@ describe("listMcpTools transport validation", () => { ).rejects.toThrow(/Invalid MCP stdio command/u); }); }); + +// --------------------------------------------------------------------------- +// Transport layer: stdio (mocked subprocess) and http (stubbed fetch). +// --------------------------------------------------------------------------- + +/** + * A fake ChildProcessWithoutNullStreams that speaks just enough of the shape + * StdioJsonRpcClient touches: a capturing stdin, a pushable stdout, and the + * error/exit event registration. `onRequest` receives each parsed JSON-RPC + * frame the client writes and an `api` it uses to emit response frames back on + * stdout (or to fire lifecycle events), so a whole round-trip runs synchronously + * without a real process. + */ +interface FakeChildApi { + /** Emit a JSON-RPC object as a single newline-framed stdout line. */ + emit: (frame: unknown) => void; + + /** Emit an arbitrary raw stdout chunk (for malformed-frame tests). */ + emitRaw: (chunk: string) => void; + + /** Fire a child lifecycle event ("error" | "exit") the client subscribed to. */ + fireChild: (event: string, ...args: unknown[]) => void; + + /** Fire a stderr "data" chunk to exercise the swallow-stderr handler. */ + fireStderr: (chunk: string) => void; +} + +interface FakeChildHarness { + child: ChildProcessWithoutNullStreams; + + /** Every string written to the child's stdin, in order. */ + writes: string[]; + + api: FakeChildApi; +} + +/** + * Builds the fake child. Responses are emitted synchronously from inside + * stdin.write: StdioJsonRpcClient.request() registers its pending entry BEFORE + * it writes, so a same-tick stdout frame resolves the already-registered + * promise deterministically, with no timers or microtask juggling. + */ +function makeFakeChild( + onRequest: ( + frame: { id?: number; method?: string }, + api: FakeChildApi, + ) => void, +): FakeChildHarness { + const stdoutHandlers: Record void> = {}; + const stderrHandlers: Record void> = {}; + const childHandlers: Record void> = {}; + const writes: string[] = []; + + const api: FakeChildApi = { + emit: (frame) => stdoutHandlers.data?.(`${JSON.stringify(frame)}\n`), + emitRaw: (chunk) => stdoutHandlers.data?.(chunk), + fireChild: (event, ...args) => childHandlers[event]?.(...args), + fireStderr: (chunk) => stderrHandlers.data?.(chunk), + }; + + const child = { + stdin: { + write: (data: string) => { + writes.push(String(data)); + const frame = JSON.parse(String(data).trim()) as { + id?: number; + method?: string; + }; + onRequest(frame, api); + }, + end: () => undefined, + }, + stdout: { + setEncoding: () => undefined, + on: (event: string, cb: (chunk: string) => void) => { + stdoutHandlers[event] = cb; + }, + }, + stderr: { + on: (event: string, cb: (chunk: string) => void) => { + stderrHandlers[event] = cb; + }, + }, + on: (event: string, cb: (...args: unknown[]) => void) => { + childHandlers[event] = cb; + }, + kill: () => undefined, + exitCode: null, + }; + + return { + child: child as unknown as ChildProcessWithoutNullStreams, + writes, + api, + }; +} + +/** Default JSON-RPC result payloads keyed by the method the client invokes. */ +function defaultResultFor(method: string | undefined): unknown { + switch (method) { + case "tools/list": + return { + tools: [ + { + name: "search", + description: "Search the workspace", + inputSchema: { type: "object" }, + annotations: { readOnly: true }, + }, + ], + }; + case "tools/call": + return { content: [{ type: "text", text: "ok" }] }; + case "resources/read": + return { contents: [{ uri: "doc://readme", text: "hello" }] }; + default: + return {}; + } +} + +/** + * A responder that answers every request with a standard success frame. This is + * the happy-path JSON-RPC framing: match the id the client sent so the correct + * pending promise resolves. + */ +function respondOk( + frame: { id?: number; method?: string }, + api: FakeChildApi, +): void { + if (frame.id === undefined) { + // Notifications (e.g. notifications/initialized) carry no id and expect no + // reply; answering one would be a protocol violation. + return; + } + api.emit({ + jsonrpc: "2.0", + id: frame.id, + result: defaultResultFor(frame.method), + }); +} + +describe("stdio MCP transport (mocked subprocess)", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("runs a full initialize -> tool/resource round trip over stdio", async () => { + const harness = makeFakeChild(respondOk); + spawnMock.mockReturnValue(harness.child); + + const result = await executeMcpReadOnlyOperations({ + transport: { + type: "stdio", + command: "notion-mcp", + args: ["--flag", "value"], + }, + readOnlyOperations: [ + { name: "search", type: "tool", args: { q: "hi" } }, + { name: "doc://readme", type: "resource" }, + ], + }); + + // The subprocess is launched with an explicit argv array and shell:false so + // no arg can be reinterpreted by a shell (argv-injection safety). + expect(spawnMock).toHaveBeenCalledWith( + "notion-mcp", + ["--flag", "value"], + expect.objectContaining({ + shell: false, + stdio: ["pipe", "pipe", "pipe"], + }), + ); + + // The tool op and the resource op both round-tripped and carried their + // results back through executeOperation. + expect(result.transport).toEqual({ command: "notion-mcp", type: "stdio" }); + expect(result.operations).toHaveLength(2); + expect(result.operations[0]).toMatchObject({ + name: "search", + type: "tool", + }); + expect(result.operations[1]).toMatchObject({ + name: "doc://readme", + type: "resource", + }); + + // The first frame the client ever writes must be a well-formed initialize + // request; the framing (jsonrpc/method/id) is what the server keys on. + const firstFrame = JSON.parse(harness.writes[0].trim()) as Record< + string, + unknown + >; + expect(firstFrame).toMatchObject({ + jsonrpc: "2.0", + method: "initialize", + id: 1, + }); + }); + + test("normalizes and filters the tools/list payload", async () => { + // A server's tool list is untrusted: only object entries with a name that + // passes the operation-name allowlist survive, and only the known fields + // are carried forward. + const harness = makeFakeChild((frame, api) => { + if (frame.id === undefined) { + return; + } + if (frame.method === "tools/list") { + api.emit({ + jsonrpc: "2.0", + id: frame.id, + result: { + tools: [ + { + name: "search", + description: "ok", + inputSchema: { type: "object" }, + annotations: { a: 1 }, + }, + { name: "bad name; rm -rf /" }, // rejected by the name allowlist + { description: "no name" }, // no string name + null, // not an object + 42, // not an object + ], + }, + }); + return; + } + api.emit({ jsonrpc: "2.0", id: frame.id, result: {} }); + }); + spawnMock.mockReturnValue(harness.child); + + const listing = await listMcpTools({ + transport: { type: "stdio", command: "notion-mcp", args: ["--verbose"] }, + }); + + expect(listing.transport).toEqual({ command: "notion-mcp", type: "stdio" }); + expect(listing.tools).toEqual([ + { + name: "search", + description: "ok", + inputSchema: { type: "object" }, + annotations: { a: 1 }, + }, + ]); + }); + + test("rejects when the server returns a JSON-RPC error object", async () => { + // An `error` member (not a `result`) must reject with the server message so + // a failed tool call surfaces rather than resolving to undefined. + const harness = makeFakeChild((frame, api) => { + if (frame.id === undefined) { + return; + } + if (frame.method === "tools/call") { + api.emit({ + jsonrpc: "2.0", + id: frame.id, + error: { code: -32000, message: "tool exploded" }, + }); + return; + } + api.emit({ jsonrpc: "2.0", id: frame.id, result: {} }); + }); + spawnMock.mockReturnValue(harness.child); + + await expect( + executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp" } }, + "search", + {}, + ), + ).rejects.toThrow(/tool exploded/u); + }); + + test("ignores malformed, mistyped-id, and unknown-id stdout frames", async () => { + // Untrusted stdout must not crash the client or resolve the wrong promise: + // non-JSON lines, frames whose id is not a number, and ids with no pending + // request are all dropped, and a later valid frame still completes the call. + const harness = makeFakeChild((frame, api) => { + if (frame.id === undefined) { + return; + } + if (frame.method === "tools/call") { + api.emitRaw("this is not json\n"); + api.emit({ jsonrpc: "2.0", id: "not-a-number", result: {} }); + api.emit({ jsonrpc: "2.0", id: 9999, result: {} }); + // Also exercise the stderr swallow path while noise is in flight. + api.fireStderr("a warning on stderr"); + api.emit({ jsonrpc: "2.0", id: frame.id, result: { ok: true } }); + return; + } + api.emit({ jsonrpc: "2.0", id: frame.id, result: {} }); + }); + spawnMock.mockReturnValue(harness.child); + + const value = await executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp", args: ["--x"] } }, + "search", + {}, + ); + expect(value).toEqual({ ok: true }); + }); + + test("rejects every pending request when the child errors", async () => { + // A spawn/pipe failure surfaces as a child 'error' event; all in-flight + // requests must reject rather than hang forever. + const harness = makeFakeChild((frame, api) => { + if (frame.id === undefined) { + return; + } + if (frame.method === "initialize") { + api.fireChild("error", new Error("ENOENT: command not found")); + return; + } + api.emit({ jsonrpc: "2.0", id: frame.id, result: {} }); + }); + spawnMock.mockReturnValue(harness.child); + + await expect( + executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp" } }, + "search", + {}, + ), + ).rejects.toThrow(/ENOENT: command not found/u); + }); + + test("rejects pending requests when the child exits early", async () => { + // An early exit (crash before answering) must reject with a diagnostic that + // names the exit code/signal, not silently drop the request. + const harness = makeFakeChild((frame, api) => { + if (frame.id === undefined) { + return; + } + if (frame.method === "initialize") { + api.fireChild("exit", 1, null); + return; + } + api.emit({ jsonrpc: "2.0", id: frame.id, result: {} }); + }); + spawnMock.mockReturnValue(harness.child); + + await expect( + executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp" } }, + "search", + {}, + ), + ).rejects.toThrow(/exited early: code=1 signal=null/u); + }); + + test("rejects when a request outlives the per-request timeout", async () => { + // A silent server must not block ingestion forever: the 60s per-request + // timeout fires and rejects. Fake timers drive the clock so the suite never + // actually waits. + vi.useFakeTimers(); + const harness = makeFakeChild(() => undefined); // never answers anything + spawnMock.mockReturnValue(harness.child); + + const pending = executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp" } }, + "search", + {}, + ); + const assertion = expect(pending).rejects.toThrow( + /Timed out waiting for MCP response to initialize/u, + ); + await vi.advanceTimersByTimeAsync(60_000); + await assertion; + }); +}); + +/** + * Installs a fetch stub that answers MCP JSON-RPC posts. `overrides` maps a + * method name (or "notify" for id-less notifications) to a function returning a + * Response; anything unmapped gets a default JSON success frame. Returns the + * stub plus a running log of the calls so tests can assert URL/header targeting. + */ +function installMcpFetch( + overrides: Record Response> = {}, +): { + stub: ReturnType; + calls: { + url: string; + headers: Record; + frame: { id?: number; method?: string }; + }[]; +} { + const calls: { + url: string; + headers: Record; + frame: { id?: number; method?: string }; + }[] = []; + + const stub = vi.fn((url: string, init: RequestInit) => { + const frame = JSON.parse(init.body as string) as { + id?: number; + method?: string; + }; + calls.push({ + url: String(url), + headers: init.headers as Record, + frame, + }); + + if (frame.id === undefined) { + const notify = overrides.notify; + return Promise.resolve( + notify ? notify(frame) : new Response(null, { status: 202 }), + ); + } + + const override = frame.method ? overrides[frame.method] : undefined; + if (override) { + return Promise.resolve(override(frame)); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: frame.id, + result: defaultResultFor(frame.method), + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + }); + + vi.stubGlobal("fetch", stub); + return { stub, calls }; +} + +describe("http MCP transport (stubbed fetch)", () => { + const savedToken = process.env.MY_MCP_TOKEN; + + beforeEach(() => { + process.env.MY_MCP_TOKEN = "s3cr3t-token"; + }); + + afterEach(() => { + if (savedToken === undefined) { + delete process.env.MY_MCP_TOKEN; + } else { + process.env.MY_MCP_TOKEN = savedToken; + } + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("posts JSON-RPC to a validated https URL and parses the JSON body", async () => { + const { calls } = installMcpFetch(); + + const result = await executeMcpReadOnlyOperations({ + transport: { type: "http", url: "https://mcp.example.com/rpc" }, + readOnlyOperations: [ + { name: "search", type: "tool" }, + { name: "doc://readme", type: "resource" }, + ], + }); + + expect(result.transport).toEqual({ + type: "http", + url: "https://mcp.example.com/rpc", + }); + expect(result.operations[0]).toMatchObject({ + name: "search", + type: "tool", + }); + // The resource branch of executeOperation posts a resources/read request. + expect(result.operations[1]).toMatchObject({ + name: "doc://readme", + type: "resource", + }); + expect(calls.some((c) => c.frame.method === "resources/read")).toBe(true); + + // Every request targets the single validated https endpoint; no request is + // ever sent to an unvalidated or downgraded (http) host. + expect(calls.length).toBeGreaterThan(0); + for (const call of calls) { + expect(call.url).toBe("https://mcp.example.com/rpc"); + } + // The first request is the initialize handshake. + expect(calls[0].frame).toMatchObject({ method: "initialize", id: 1 }); + }); + + test("sends the resolved Authorization header only to the validated host", async () => { + const { calls } = installMcpFetch(); + + await listMcpTools({ + transport: { + type: "http", + url: "https://mcp.example.com/rpc", + // The credential is referenced via ${ENV}; its value is resolved from + // process.env and must reach only the validated host. + headers: { Authorization: "Bearer ${MY_MCP_TOKEN}" }, + }, + }); + + // Header-leak safety: the secret went out attached to the validated https + // endpoint and nowhere else. + for (const call of calls) { + expect(call.url).toBe("https://mcp.example.com/rpc"); + expect(call.headers.Authorization).toBe("Bearer s3cr3t-token"); + } + }); + + test("threads a server-issued session id onto later requests", async () => { + // The server binds the session via Mcp-Session-Id on the initialize reply; + // every subsequent request must echo it so state is preserved. + const { calls } = installMcpFetch({ + initialize: (frame) => + new Response( + JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: {} }), + { + status: 200, + headers: { + "content-type": "application/json", + "mcp-session-id": "sess-42", + }, + }, + ), + }); + + await executeMcpTool( + { transport: { type: "http", url: "https://mcp.example.com/rpc" } }, + "search", + {}, + ); + + const initialize = calls.find((c) => c.frame.method === "initialize"); + const toolCall = calls.find((c) => c.frame.method === "tools/call"); + expect(initialize?.headers["Mcp-Session-Id"]).toBeUndefined(); + expect(toolCall?.headers["Mcp-Session-Id"]).toBe("sess-42"); + }); + + test("parses a text/event-stream (SSE) body, skipping non-result frames", async () => { + // The MCP spec allows the JSON-RPC reply to arrive as SSE. parseSseDataLines + // must join data: lines per event, ignore comments/blank separators, skip a + // frame that carries neither id/result/error, and return the real result. + const sseBody = [ + ": keep-alive comment", + 'data: {"jsonrpc":"2.0"}', // no id/result/error -> skipped + "", + 'data: {"jsonrpc":"2.0","id":2,', + 'data: "result":{"tools":[{"name":"search"}]}}', + "", + ].join("\n"); + + const { calls } = installMcpFetch({ + "tools/list": () => + new Response(sseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + // Exercise the empty-body branch of parseHttpMcpResponse: a 200 with no + // content for the id-less notification. + notify: () => + new Response("", { + status: 200, + headers: { "content-type": "application/json" }, + }), + }); + + const listing = await listMcpTools({ + transport: { type: "http", url: "https://mcp.example.com/rpc" }, + }); + + expect(listing.tools).toEqual([{ name: "search" }]); + expect(calls.some((c) => c.frame.method === "tools/list")).toBe(true); + }); + + test("rejects a JSON-RPC error object in the http response", async () => { + installMcpFetch({ + "tools/call": (frame) => + new Response( + JSON.stringify({ + jsonrpc: "2.0", + id: frame.id, + error: { code: -32000, message: "remote refused" }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + }); + + await expect( + executeMcpTool( + { transport: { type: "http", url: "https://mcp.example.com/rpc" } }, + "search", + {}, + ), + ).rejects.toThrow(/remote refused/u); + }); + + test("rejects on a non-ok http status", async () => { + // A non-retryable 4xx must surface as an error naming the status rather than + // being parsed as a JSON-RPC body. + installMcpFetch({ + initialize: () => new Response("nope", { status: 400 }), + }); + + await expect( + executeMcpTool( + { transport: { type: "http", url: "https://mcp.example.com/rpc" } }, + "search", + {}, + ), + ).rejects.toThrow(/MCP HTTP request failed: 400/u); + }); + + test("rejects a malformed SSE frame", async () => { + // Untrusted SSE payloads that are not valid JSON must reject, not silently + // resolve. + installMcpFetch({ + initialize: () => + new Response("data: {not valid json}\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + }); + + await expect( + executeMcpTool( + { transport: { type: "http", url: "https://mcp.example.com/rpc" } }, + "search", + {}, + ), + ).rejects.toThrow(); + }); + + test("localhost may use plain http", async () => { + // The one allowed cleartext exception: a loopback host for local dev. + const { calls } = installMcpFetch(); + + await executeMcpTool( + { transport: { type: "http", url: "http://localhost:8080/rpc" } }, + "search", + {}, + ); + + expect(calls[0].url).toBe("http://localhost:8080/rpc"); + }); +}); + +describe("http MCP header resolution", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("rejects a literal credential that does not use ${ENV}", async () => { + // Secrets must be referenced through env, never pasted as a literal, so they + // are not committed to config. fetch must never be reached. + const { stub } = installMcpFetch(); + + await expect( + listMcpTools({ + transport: { + type: "http", + url: "https://mcp.example.com/rpc", + headers: { Authorization: "Bearer literal-secret" }, + }, + }), + ).rejects.toThrow(/must reference credentials with/u); + expect(stub).not.toHaveBeenCalled(); + }); + + test("rejects an invalid HTTP header name", async () => { + const { stub } = installMcpFetch(); + + await expect( + listMcpTools({ + transport: { + type: "http", + url: "https://mcp.example.com/rpc", + headers: { "Bad Header!": "value" }, + }, + }), + ).rejects.toThrow(/Invalid HTTP header name/u); + expect(stub).not.toHaveBeenCalled(); + }); + + test("passes through a non-credential literal header unchanged", async () => { + const { calls } = installMcpFetch(); + + await listMcpTools({ + transport: { + type: "http", + url: "https://mcp.example.com/rpc", + headers: { "X-Client": "openwiki" }, + }, + }); + + expect(calls[0].headers["X-Client"]).toBe("openwiki"); + }); +}); + +// These guards fire AFTER pre-flight validateMcpTransport (which only checks a +// transport exists): a stdio transport can still lack a command, and an http +// transport can still lack a url, once dispatch reaches the transport-specific +// entry point. +describe("post-validation transport guards", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("stdio execute rejects when command is missing", async () => { + await expect( + executeMcpReadOnlyOperations({ + transport: { type: "stdio" }, + readOnlyOperations: [VALID_TOOL_OP], + }), + ).rejects.toThrow(/stdio MCP transport requires a command/u); + }); + + test("stdio tool call rejects when command is missing", async () => { + await expect( + executeMcpTool({ transport: { type: "stdio" } }, "search", {}), + ).rejects.toThrow(/stdio MCP transport requires a command/u); + }); + + test("stdio listing rejects when command is missing", async () => { + await expect( + listMcpTools({ transport: { type: "stdio" } }), + ).rejects.toThrow(/stdio MCP transport requires a command/u); + }); + + test("http tool call rejects when url is missing", async () => { + await expect( + executeMcpTool({ transport: { type: "http" } }, "search", {}), + ).rejects.toThrow(/HTTP MCP transport requires a URL/u); + }); +}); + +describe("tool-list normalization edge cases", () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + test("yields no tools when the result lacks a tools array", async () => { + // A well-formed but tools-less result must degrade to an empty list rather + // than throw. + installMcpFetch({ + "tools/list": (frame) => + new Response( + JSON.stringify({ jsonrpc: "2.0", id: frame.id, result: {} }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + }); + + const listing = await listMcpTools({ + transport: { type: "http", url: "https://mcp.example.com/rpc" }, + }); + expect(listing.tools).toEqual([]); + }); + + test("SSE without a result-bearing frame resolves to an empty response", async () => { + // Every SSE event is a keep-alive/ping with no id/result/error, and the body + // has no trailing blank line, so the final data buffer is flushed by the + // tail path. The request resolves with an empty result. + const sseBody = [ + 'data: {"jsonrpc":"2.0"}', + "", + 'data: {"jsonrpc":"2.0"}', + ].join("\n"); + installMcpFetch({ + "tools/list": () => + new Response(sseBody, { + status: 200, + headers: { "content-type": "text/event-stream" }, + }), + }); + + const listing = await listMcpTools({ + transport: { type: "http", url: "https://mcp.example.com/rpc" }, + }); + expect(listing.tools).toEqual([]); + }); +}); + +describe("stdio client deferred kill", () => { + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + test("force-kills a child that has not exited when close's grace timer fires", async () => { + // close() ends stdin and schedules a 1s fallback kill; if the process is + // still alive (exitCode null) it is killed so no orphan lingers. + vi.useFakeTimers(); + const harness = makeFakeChild(respondOk); + const killSpy = vi.spyOn(harness.child, "kill"); + spawnMock.mockReturnValue(harness.child); + + await executeMcpTool( + { transport: { type: "stdio", command: "notion-mcp" } }, + "search", + {}, + ); + + expect(killSpy).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1_000); + expect(killSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/ingestion/ingestion-run.test.ts b/test/ingestion/ingestion-run.test.ts new file mode 100644 index 00000000..f61f48ac --- /dev/null +++ b/test/ingestion/ingestion-run.test.ts @@ -0,0 +1,554 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import type { + ConnectorId, + ConnectorIngestResult, + ConnectorRuntime, +} from "../../src/connectors/types.ts"; +import type { + OnboardingSourceInstanceConfig, + OpenWikiOnboardingConfig, +} from "../../src/setup/onboarding.ts"; +import type { OpenWikiRunEvent } from "../../src/agent/types.ts"; + +// This file exercises the runOpenWikiIngestion orchestrator and the private +// message/policy/per-source helpers. Every heavy collaborator (env load, home +// creation, onboarding read, connector registry, and the agent run) is mocked +// so no real LLM, network, git, or filesystem work happens. The pure-surface +// tests live in ingestion.test.ts; keeping the mocks in a separate file avoids +// disturbing that file's real createConnectorRegistry usage. + +vi.mock("../../src/config/env.ts", async (importActual) => { + const actual = await importActual(); + return { ...actual, loadOpenWikiEnv: vi.fn().mockResolvedValue({}) }; +}); + +vi.mock("../../src/config/openwiki-home.ts", async (importActual) => { + const actual = + await importActual(); + // Keep the pure path helpers real (getConnectorConfigPath / openWikiLocalWikiDir + // are asserted downstream) but neuter the mkdir side effect on the real home. + return { + ...actual, + ensureOpenWikiHome: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock("../../src/setup/onboarding.ts", async (importActual) => { + const actual = + await importActual(); + return { ...actual, readOpenWikiOnboardingConfig: vi.fn() }; +}); + +vi.mock("../../src/connectors/registry.ts", async (importActual) => { + const actual = + await importActual(); + // isConnectorId stays real (it is the connected-source gate); only the + // registry factory is swapped so a fake, side-effect-free connector is used. + return { ...actual, createConnectorRegistry: vi.fn() }; +}); + +vi.mock("../../src/agent/index.ts", () => ({ + createOpenWikiThreadId: vi.fn(() => "thread-test"), + runOpenWikiAgent: vi.fn(), +})); + +import { readOpenWikiOnboardingConfig } from "../../src/setup/onboarding.ts"; +import { createConnectorRegistry } from "../../src/connectors/registry.ts"; +import { getConnectorConfigPath } from "../../src/config/openwiki-home.ts"; +import { runOpenWikiAgent } from "../../src/agent/index.ts"; +import { runOpenWikiIngestion } from "../../src/ingestion/ingestion.ts"; + +/** Build a fake connector whose ingest is fully controllable. */ +function makeConnector( + id: ConnectorId, + overrides: Partial = {}, +): ConnectorRuntime { + return { + backend: "direct-api", + description: `${id} test connector`, + displayName: id, + id, + mode: "personal", + requiredEnv: [], + // false => deterministic path (ingest is called before the agent run). + supportsAgenticDiscovery: false, + ingest: vi.fn(), + ...overrides, + }; +} + +/** A deterministic pull result the connector.ingest mock can resolve with. */ +function makeIngestResult( + id: ConnectorId, + overrides: Partial = {}, +): ConnectorIngestResult { + return { + connectorId: id, + message: `${id} pulled`, + rawFiles: [`/home/.openwiki/connectors/${id}/raw/a.json`], + runId: "run-1", + statePath: `/home/.openwiki/connectors/${id}/state.json`, + status: "success", + warnings: [], + ...overrides, + }; +} + +/** A source instance that passes the connected + known-connector gate. */ +function makeSourceInstance( + overrides: Partial & { + connectorId: ConnectorId; + id: string; + }, +): OnboardingSourceInstanceConfig { + return { + connectedAt: "2026-07-01T00:00:00.000Z", + ...overrides, + }; +} + +/** An onboarding config carrying the given source instances. */ +function makeConfig( + sourceInstances: OnboardingSourceInstanceConfig[], + overrides: Partial = {}, +): OpenWikiOnboardingConfig { + return { + sourceInstances, + sources: {}, + version: 1, + wikiGoal: "Track project status.", + ...overrides, + }; +} + +/** Register the config + registry the orchestrator will read this run. */ +function primeRun( + config: OpenWikiOnboardingConfig, + registry: Partial>, +): void { + vi.mocked(readOpenWikiOnboardingConfig).mockResolvedValue(config); + vi.mocked(createConnectorRegistry).mockReturnValue( + registry as Record, + ); +} + +/** Collect the text of every emitted progress event. */ +function textEvents(events: OpenWikiRunEvent[]): string { + return events + .filter((event) => event.type === "text") + .map((event) => (event as { text: string }).text) + .join(""); +} + +beforeEach(() => { + vi.mocked(runOpenWikiAgent).mockResolvedValue( + {} as Awaited>, + ); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("runOpenWikiIngestion", () => { + test("runs a single deterministic source and composes the pull-aware message", async () => { + // A deterministic connector is pulled first, and the composed agent message + // must carry the pull result, the raw file paths, and the synthesis policy + // so the model updates the wiki from the freshly written evidence. + const connector = makeConnector("git-repo"); + const pull = makeIngestResult("git-repo", { + rawFiles: ["/raw/one.json", "/raw/two.json"], + }); + vi.mocked(connector.ingest).mockResolvedValue(pull); + + const source = makeSourceInstance({ + connectorId: "git-repo", + id: "git-repo", + ingestionGoal: "Watch the release branch.", + }); + primeRun(makeConfig([source]), { "git-repo": connector }); + + const events: OpenWikiRunEvent[] = []; + const result = await runOpenWikiIngestion("/some/cwd", { + target: "git-repo", + onEvent: (event) => events.push(event), + }); + + expect(result.results).toHaveLength(1); + expect(result.results[0]?.status).toBe("agent-updated"); + expect(result.results[0]?.rawFiles).toEqual(pull.rawFiles); + + // The connector pull ran with the ingestion window and instance id. + expect(connector.ingest).toHaveBeenCalledWith({ + connectorConfig: undefined, + instanceId: "git-repo", + windowHours: 24, + }); + + // The agent ran once against the local wiki dir with the composed message. + expect(runOpenWikiAgent).toHaveBeenCalledTimes(1); + const [phase, , runOptions] = vi.mocked(runOpenWikiAgent).mock.calls[0]; + expect(phase).toBe("update"); + const message = runOptions.userMessage ?? ""; + expect(message).toContain("Deterministic pull result:"); + expect(message).toContain("Status: success"); + expect(message).toContain("- /raw/one.json"); + expect(message).toContain("- /raw/two.json"); + expect(message).toContain("Watch the release branch."); + expect(message).toContain("Track project status."); + // The synthesis policy is inlined into the message. + expect(message).toContain("Reusable synthesis policy:"); + expect(message).toContain("Apply confidence labels"); + + // Progress is surfaced: a start line plus the deterministic pull summary. + const emitted = textEvents(events); + expect(emitted).toContain("Starting git-repo ingestion."); + expect(emitted).toContain("git-repo pulled"); + }); + + test("composes the discovery message (no pull) for an agentic connector", async () => { + // An agentic connector is not pulled up front, so the message must instead + // point the agent at connector tools and the connector config path. + const connector = makeConnector("web-search", { + supportsAgenticDiscovery: true, + }); + + const source = makeSourceInstance({ + connectorId: "web-search", + id: "web-search", + }); + primeRun(makeConfig([source]), { "web-search": connector }); + + const result = await runOpenWikiIngestion(undefined, { + target: "web-search", + }); + + // Agentic connectors skip the deterministic ingest entirely. + expect(connector.ingest).not.toHaveBeenCalled(); + expect(result.results[0]?.status).toBe("agent-updated"); + + const runOptions = vi.mocked(runOpenWikiAgent).mock.calls[0][2]; + const message = runOptions.userMessage ?? ""; + expect(message).toContain("cannot be fully pulled deterministically"); + expect(message).toContain( + `Connector config path: ${getConnectorConfigPath("web-search")}`, + ); + expect(message).not.toContain("Deterministic pull result:"); + }); + + test("includes the source instance name when one is configured", async () => { + // A named instance disambiguates multiple instances of one connector, so + // the display name and the parenthetical id/name must reach the message. + const connector = makeConnector("git-repo"); + vi.mocked(connector.ingest).mockResolvedValue(makeIngestResult("git-repo")); + + const source = makeSourceInstance({ + connectorId: "git-repo", + id: "repo-primary", + name: "Primary repo", + }); + primeRun(makeConfig([source]), { "git-repo": connector }); + + await runOpenWikiIngestion(undefined, { + target: { kind: "source-instance", id: "repo-primary" }, + }); + + const message = + vi.mocked(runOpenWikiAgent).mock.calls[0][2].userMessage ?? ""; + expect(message).toContain("Primary repo"); + expect(message).toContain("repo-primary (Primary repo)"); + }); + + test("marks a zero-item pull as updated with an explicit no-files note", async () => { + // A successful pull that wrote nothing still runs the agent, and the + // message must state there are no raw files rather than omit the section. + const connector = makeConnector("git-repo"); + vi.mocked(connector.ingest).mockResolvedValue( + makeIngestResult("git-repo", { rawFiles: [], message: "nothing new" }), + ); + + const source = makeSourceInstance({ + connectorId: "git-repo", + id: "git-repo", + }); + primeRun(makeConfig([source]), { "git-repo": connector }); + + const events: OpenWikiRunEvent[] = []; + const result = await runOpenWikiIngestion(undefined, { + target: "git-repo", + onEvent: (event) => events.push(event), + }); + + expect(result.results[0]?.status).toBe("agent-updated"); + expect(runOpenWikiAgent).toHaveBeenCalledTimes(1); + const message = + vi.mocked(runOpenWikiAgent).mock.calls[0][2].userMessage ?? ""; + expect(message).toContain("(no raw files written)"); + expect(textEvents(events)).toContain("Raw files: none"); + }); + + test("short-circuits on a deterministic pull error without running the agent", async () => { + // A hard pull failure with no salvaged files is terminal for that source: + // it must be reported as an error and must not reach the agent. + const connector = makeConnector("git-repo"); + vi.mocked(connector.ingest).mockResolvedValue( + makeIngestResult("git-repo", { + rawFiles: [], + status: "error", + message: "auth expired", + }), + ); + + const source = makeSourceInstance({ + connectorId: "git-repo", + id: "git-repo", + }); + primeRun(makeConfig([source]), { "git-repo": connector }); + + const events: OpenWikiRunEvent[] = []; + const result = await runOpenWikiIngestion(undefined, { + target: "git-repo", + onEvent: (event) => events.push(event), + }); + + expect(result.results[0]?.status).toBe("error"); + expect(result.results[0]?.deterministicPull?.message).toBe("auth expired"); + expect(runOpenWikiAgent).not.toHaveBeenCalled(); + expect(textEvents(events)).toContain( + "deterministic pull failed: auth expired", + ); + }); + + test("isolates a thrown connector ingest into a single error result", async () => { + // A connector that throws mid-pull must be caught and reported as an error + // for that source only, again without invoking the agent. + const connector = makeConnector("git-repo"); + vi.mocked(connector.ingest).mockRejectedValue(new Error("boom")); + + const source = makeSourceInstance({ + connectorId: "git-repo", + id: "git-repo", + }); + primeRun(makeConfig([source]), { "git-repo": connector }); + + const events: OpenWikiRunEvent[] = []; + const result = await runOpenWikiIngestion(undefined, { + target: "git-repo", + onEvent: (event) => events.push(event), + }); + + expect(result.results[0]?.status).toBe("error"); + expect(result.results[0]?.rawFiles).toEqual([]); + expect(runOpenWikiAgent).not.toHaveBeenCalled(); + expect(textEvents(events)).toContain("ingestion failed: boom"); + }); + + test("fans out across every connected source for the all target", async () => { + // The "all" target ingests each connected source in turn, and one source + // failing must not stop or corrupt the others (per-source isolation). + const good = makeConnector("git-repo"); + vi.mocked(good.ingest).mockResolvedValue(makeIngestResult("git-repo")); + const bad = makeConnector("hackernews"); + vi.mocked(bad.ingest).mockRejectedValue(new Error("network down")); + + const config = makeConfig([ + makeSourceInstance({ connectorId: "git-repo", id: "git-repo" }), + makeSourceInstance({ connectorId: "hackernews", id: "hackernews" }), + ]); + primeRun(config, { "git-repo": good, hackernews: bad }); + + const result = await runOpenWikiIngestion(undefined, { target: "all" }); + + expect(result.results).toHaveLength(2); + const byId = Object.fromEntries( + result.results.map((entry) => [entry.connectorId, entry.status]), + ); + expect(byId["git-repo"]).toBe("agent-updated"); + expect(byId.hackernews).toBe("error"); + // Only the healthy source reached the agent. + expect(runOpenWikiAgent).toHaveBeenCalledTimes(1); + }); + + test("throws when a specific target matches no connected source", async () => { + // For a non-"all" target, an empty match set is a user error and must be + // reported rather than silently producing zero results. + primeRun(makeConfig([]), {}); + + await expect( + runOpenWikiIngestion(undefined, { target: "git-repo" }), + ).rejects.toThrow("No configured ingestion source matched git-repo"); + expect(runOpenWikiAgent).not.toHaveBeenCalled(); + }); + + test("names the source-instance id when that target matches nothing", async () => { + // The no-match error must format a source-instance target by its id, not by + // leaking the wrapper object into the message. + primeRun(makeConfig([]), {}); + + await expect( + runOpenWikiIngestion(undefined, { + target: { kind: "source-instance", id: "repo-missing" }, + }), + ).rejects.toThrow("No configured ingestion source matched repo-missing"); + }); + + test("falls back to not-provided when goals are absent", async () => { + // A source with neither a wiki goal nor an ingestion goal must still yield a + // valid message, with explicit not-provided placeholders on both fields. + // Cover both message templates: an agentic source (named, no goals) and a + // deterministic source (no goals) both fall back to the placeholders. + const agentic = makeConnector("web-search", { + supportsAgenticDiscovery: true, + }); + primeRun( + makeConfig( + [ + makeSourceInstance({ + connectorId: "web-search", + id: "web-search", + name: "Web", + }), + ], + { wikiGoal: undefined }, + ), + { "web-search": agentic }, + ); + + await runOpenWikiIngestion(undefined, { target: "web-search" }); + const agenticMessage = + vi.mocked(runOpenWikiAgent).mock.calls[0][2].userMessage ?? ""; + expect(agenticMessage).toContain("web-search (Web)"); + expect(agenticMessage).toContain("User wiki goal:\n(not provided)"); + expect(agenticMessage).toContain( + "Source-specific instructions:\n(not provided)", + ); + + vi.clearAllMocks(); + vi.mocked(runOpenWikiAgent).mockResolvedValue( + {} as Awaited>, + ); + const deterministic = makeConnector("git-repo"); + vi.mocked(deterministic.ingest).mockResolvedValue( + makeIngestResult("git-repo"), + ); + primeRun( + makeConfig( + [makeSourceInstance({ connectorId: "git-repo", id: "git-repo" })], + { wikiGoal: undefined }, + ), + { "git-repo": deterministic }, + ); + + await runOpenWikiIngestion(undefined, { target: "git-repo" }); + const deterministicMessage = + vi.mocked(runOpenWikiAgent).mock.calls[0][2].userMessage ?? ""; + expect(deterministicMessage).toContain("User wiki goal:\n(not provided)"); + }); + + test("stringifies a non-Error thrown by a connector", async () => { + // Connectors may reject with a non-Error value; the error result must carry + // a stringified message rather than crashing the per-source catch. + const connector = makeConnector("git-repo"); + vi.mocked(connector.ingest).mockRejectedValue("plain string failure"); + primeRun( + makeConfig([ + makeSourceInstance({ connectorId: "git-repo", id: "git-repo" }), + ]), + { "git-repo": connector }, + ); + + const events: OpenWikiRunEvent[] = []; + const result = await runOpenWikiIngestion(undefined, { + target: "git-repo", + onEvent: (event) => events.push(event), + }); + + expect(result.results[0]?.status).toBe("error"); + expect(textEvents(events)).toContain( + "ingestion failed: plain string failure", + ); + }); + + test("returns no results for the all target when nothing is connected", async () => { + // "all" with nothing connected is a valid no-op, not an error. + primeRun(makeConfig([]), {}); + + const result = await runOpenWikiIngestion(undefined, { target: "all" }); + expect(result.results).toEqual([]); + expect(runOpenWikiAgent).not.toHaveBeenCalled(); + }); + + test("scheduledOnly skips sources when the schedule is missing or paused", async () => { + // scheduledOnly gates the run on an active schedule; a paused or absent + // schedule filters every source out, so a specific target finds no match. + const connector = makeConnector("git-repo"); + vi.mocked(connector.ingest).mockResolvedValue(makeIngestResult("git-repo")); + const source = makeSourceInstance({ + connectorId: "git-repo", + id: "git-repo", + }); + + // Paused schedule => filtered out. + primeRun( + makeConfig([source], { + ingestionSchedule: { + description: "daily", + expression: "0 9 * * *", + updatedAt: "2026-07-01T00:00:00.000Z", + pausedAt: "2026-07-02T00:00:00.000Z", + }, + }), + { "git-repo": connector }, + ); + + await expect( + runOpenWikiIngestion(undefined, { + target: "git-repo", + scheduledOnly: true, + }), + ).rejects.toThrow("No configured ingestion source matched"); + }); + + test("scheduledOnly runs sources when an active schedule exists", async () => { + // With an active (non-paused) schedule the scheduled run proceeds normally. + const connector = makeConnector("git-repo"); + vi.mocked(connector.ingest).mockResolvedValue(makeIngestResult("git-repo")); + const source = makeSourceInstance({ + connectorId: "git-repo", + id: "git-repo", + }); + + primeRun( + makeConfig([source], { + ingestionSchedule: { + description: "daily", + expression: "0 9 * * *", + updatedAt: "2026-07-01T00:00:00.000Z", + }, + }), + { "git-repo": connector }, + ); + + const result = await runOpenWikiIngestion(undefined, { + target: "all", + scheduledOnly: true, + }); + expect(result.results).toHaveLength(1); + expect(result.results[0]?.status).toBe("agent-updated"); + }); + + test("skips sources that were never connected", async () => { + // A source instance without connectedAt is not eligible, so an "all" run + // over only-unconnected sources yields nothing and never calls the agent. + const connector = makeConnector("git-repo"); + const source: OnboardingSourceInstanceConfig = { + connectorId: "git-repo", + id: "git-repo", + }; + primeRun(makeConfig([source]), { "git-repo": connector }); + + const result = await runOpenWikiIngestion(undefined, { target: "all" }); + expect(result.results).toEqual([]); + expect(connector.ingest).not.toHaveBeenCalled(); + }); +}); diff --git a/test/scheduling/launchd-calendar-interval.test.ts b/test/scheduling/launchd-calendar-interval.test.ts index 1e65e474..11da4f3d 100644 --- a/test/scheduling/launchd-calendar-interval.test.ts +++ b/test/scheduling/launchd-calendar-interval.test.ts @@ -56,4 +56,26 @@ describe("parseLaunchdCalendarInterval", () => { expect(parseLaunchdCalendarInterval("*/15 2 * * *")).toBeNull(); expect(parseLaunchdCalendarInterval("0 2 1-5 * *")).toBeNull(); }); + + test("returns null when the expression does not have exactly five fields", () => { + // parseSimpleCronFields rejects a wrong field count outright, so a + // four-field ("0 2 * *") or six-field expression can never become a plist. + expect(parseLaunchdCalendarInterval("0 2 * *")).toBeNull(); + expect(parseLaunchdCalendarInterval("0 2 * * * *")).toBeNull(); + }); + + test("returns null for a non-single, non-wildcard hour", () => { + // A step hour like `*/2` is neither a single integer nor `*`, so it can't be + // pinned to one launchd Hour; refuse rather than silently drop the step. + expect(parseLaunchdCalendarInterval("0 */2 * * *")).toBeNull(); + }); + + test("returns null for a non-single, non-wildcard month", () => { + expect(parseLaunchdCalendarInterval("0 2 * 1-5 *")).toBeNull(); + }); + + test("returns null for a non-single, non-wildcard weekday", () => { + // Weekday `1-5` restricts weekday but maps to no single Weekday integer. + expect(parseLaunchdCalendarInterval("0 2 * * 1-5")).toBeNull(); + }); }); diff --git a/test/scheduling/schedule-operations.test.ts b/test/scheduling/schedule-operations.test.ts index a8dad5d4..64c97054 100644 --- a/test/scheduling/schedule-operations.test.ts +++ b/test/scheduling/schedule-operations.test.ts @@ -134,6 +134,28 @@ describe("installOpenWikiPowerSchedule", () => { expect(result.warning).toMatch(/no saved schedules/i); }); + test("skips when the expression has the wrong number of cron fields", async () => { + // A malformed expression fails parseSimpleCronFields inside + // parseRepeatScheduleTime, so no repeat window can be derived. + const result = await installOpenWikiPowerSchedule( + configWithSchedule("0 2 *"), + ); + + expect(result.enabled).toBe(false); + expect(result.warning).toMatch(/no saved schedules/i); + }); + + test("skips when the minute field is a step it cannot pin to a wake time", async () => { + // `*/5` is not a single minute, so parseRepeatScheduleTime returns null even + // though day and month are wildcards. + const result = await installOpenWikiPowerSchedule( + configWithSchedule("*/5 2 * * *"), + ); + + expect(result.enabled).toBe(false); + expect(result.warning).toMatch(/no saved schedules/i); + }); + test("skips when weekday is a range that maps to no single pmset day", async () => { // `1-5` is not a single weekday number, so parsePmsetDays yields null and // the whole window is rejected. @@ -368,6 +390,47 @@ describe("deleteConnectorSchedules", () => { expect(result.powerSchedule).toBeUndefined(); }); + test("leaves an already-disabled power schedule untouched and mirrors legacy sources", async () => { + stubPlatform("linux"); + + // The saved pmset is already disabled, so with no ingestion schedule left + // reconciliation returns early without shelling out or rewriting pmset. The + // populated sourceInstances also drive cloneOnboardingConfig's legacy-source + // derivation (the `sources` map is rebuilt from the instances). + const base = configWithSchedule("0 2 * * *"); + const config: OpenWikiOnboardingConfig = { + ...base, + sourceInstances: [ + { + connectedAt: "2026-01-01T00:00:00.000Z", + connectorId: "git-repo", + id: "git-repo-1", + ingestionGoal: "index the repo", + }, + ], + powerManagement: { + pmset: { + days: "MTWRFSU", + enabled: false, + sleepTime: "02:30:00", + updatedAt: "2026-01-01T00:00:00.000Z", + wakeTime: "01:58:00", + }, + }, + }; + + const result = await deleteConnectorSchedules(config, "all"); + + expect(result.config.ingestionSchedule).toBeUndefined(); + expect(result.powerSchedule).toBeUndefined(); + expect(result.config.powerManagement?.pmset?.enabled).toBe(false); + expect(result.config.sources["git-repo"]).toEqual({ + connectedAt: "2026-01-01T00:00:00.000Z", + connectorConfig: undefined, + ingestionGoal: "index the repo", + }); + }); + test("cancels a saved-and-enabled power schedule when the last schedule is deleted", async () => { stubPlatform("linux"); diff --git a/test/scheduling/schedules-launchd.test.ts b/test/scheduling/schedules-launchd.test.ts new file mode 100644 index 00000000..ee3733a4 --- /dev/null +++ b/test/scheduling/schedules-launchd.test.ts @@ -0,0 +1,405 @@ +import { mkdir, readFile, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + afterAll, + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; +import { createEmptyOnboardingConfig } from "../../src/setup/onboarding.ts"; +import type { OpenWikiOnboardingConfig } from "../../src/setup/onboarding.ts"; + +// This file exclusively drives the macOS native surface of schedules.ts: the +// launchctl / osascript(pmset) shell-outs and the plist/argv builders that are +// only reachable through them. The child_process and os mocks are deliberately +// kept in their own file so they never leak into the pure-surface suites. + +// A private HOME so every plist/log write lands under a throwaway tree instead +// of the developer's real ~/Library/LaunchAgents and ~/.openwiki. It is a plain +// path string (not yet created) because openwiki-home.ts computes its module +// constant from os.homedir() at import time; the directories are materialized +// lazily by the code under test and removed in afterAll. +const HOME = vi.hoisted(() => { + const base = (process.env.TMPDIR ?? "/tmp").replace(/\/$/u, ""); + return `${base}/openwiki-launchd-home-${process.pid}-${Date.now()}`; +}); + +// schedules.ts and openwiki-home.ts both resolve install locations from +// os.homedir(); redirect only that function and pass everything else through so +// os.userInfo()/os.tmpdir() keep working. +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + const patched = { ...actual, homedir: () => HOME }; + return { ...patched, default: patched }; +}); + +// promisify(execFile) inside schedules.ts turns this plain mock into a +// promise-returning shim: it invokes the mock with (command, argv, callback) +// and resolves/rejects from the callback, so the fake never spawns a real +// launchctl/osascript process. +const execFileMock = vi.hoisted(() => vi.fn()); +vi.mock("node:child_process", () => ({ + execFile: execFileMock, +})); + +import { + deleteConnectorSchedules, + installConnectorSchedule, + installOpenWikiPowerSchedule, + listConnectorSchedules, + resumeConnectorSchedules, +} from "../../src/scheduling/schedules.ts"; + +const LABEL = "com.openwiki.ingestion"; +const LAUNCH_AGENTS_DIR = path.join(HOME, "Library", "LaunchAgents"); +const PLIST_PATH = path.join(LAUNCH_AGENTS_DIR, `${LABEL}.plist`); +// Mirrors getLaunchdDomain(): the current uid on any POSIX host, matching the +// value the code derives from process.getuid(). +const LAUNCHD_DOMAIN = `gui/${process.getuid?.() ?? os.userInfo().uid}`; + +const ORIGINAL_PLATFORM = process.platform; + +/** + * Outcome the execFile stub should produce for a given invocation. Returning an + * error drives the callback's failure branch (a non-zero exit or a spawn + * error, which are indistinguishable to promisify(execFile)); returning nothing + * resolves as a clean, zero-exit run. + */ +type ExecOutcome = { error?: Error }; + +/** + * Per-test router for the execFile stub, keyed off the command and its argv so + * a test can fail one specific shell-out (e.g. `launchctl print`) while letting + * the rest succeed. Reset to all-success before each test. + */ +let execFileOutcome: (command: string, args: string[]) => ExecOutcome; + +/** + * Forces the module's `process.platform` reads down the darwin branch + * regardless of the host CI runner, so the native install/uninstall paths are + * exercised everywhere. Restored in afterEach. + */ +function stubDarwin(): void { + Object.defineProperty(process, "platform", { + configurable: true, + value: "darwin", + }); +} + +beforeEach(async () => { + stubDarwin(); + execFileOutcome = () => ({}); + execFileMock.mockImplementation((...callArgs: unknown[]) => { + const done = callArgs.at(-1) as ( + error: Error | null, + stdout: unknown, + stderr: string, + ) => void; + const command = callArgs[0] as string; + const args = (callArgs[1] as string[]) ?? []; + const { error } = execFileOutcome(command, args); + // Mirror execFile's { stdout, stderr } custom-promisify shape; the code only + // cares about resolve vs reject, not the payload. + done(error ?? null, { stdout: "", stderr: "" }, ""); + }); + + // Each test starts from a clean plist slot so a prior install's file can't + // mask a later "already removed" assertion; force ignores a missing path. + await rm(PLIST_PATH, { force: true, recursive: true }); +}); + +afterEach(() => { + Object.defineProperty(process, "platform", { + configurable: true, + value: ORIGINAL_PLATFORM, + }); + execFileMock.mockReset(); +}); + +afterAll(async () => { + await rm(HOME, { force: true, recursive: true }); +}); + +/** Builds an onboarding config carrying a single ingestion schedule. */ +function configWithSchedule( + expression: string, + overrides: Partial< + NonNullable + > = {}, +): OpenWikiOnboardingConfig { + return { + ...createEmptyOnboardingConfig(), + ingestionSchedule: { + description: "All ingestion", + expression, + updatedAt: "2026-01-01T00:00:00.000Z", + ...overrides, + }, + }; +} + +/** + * Adds an enabled pmset repeat schedule so power reconciliation has saved state + * to act on (install-or-cancel), reaching the darwin osascript paths. + */ +function withEnabledPmset( + config: OpenWikiOnboardingConfig, +): OpenWikiOnboardingConfig { + return { + ...config, + powerManagement: { + pmset: { + days: "MTWRFSU", + enabled: true, + sleepTime: "02:30:00", + updatedAt: "2026-01-01T00:00:00.000Z", + wakeTime: "01:58:00", + }, + }, + }; +} + +/** All recorded execFile invocations as [command, argv, ...] tuples. */ +function execFileCalls(): unknown[][] { + return execFileMock.mock.calls; +} + +/** First recorded invocation of `command` whose first argv entry is `sub`. */ +function findCall(command: string, sub: string): unknown[] | undefined { + return execFileCalls().find( + (call) => + call[0] === command && + Array.isArray(call[1]) && + (call[1] as string[])[0] === sub, + ); +} + +/** + * Asserts a recorded execFile invocation shells out safely: the binary and its + * arguments are passed as a (command, argv[]) pair rather than one concatenated + * shell string, and no `{ shell: true }` option is present. execFile never + * routes through /bin/sh unless shell:true is set, so this shape is what + * prevents operator- or config-influenced values from being reinterpreted as + * shell syntax (argv-injection safety). + */ +function expectSafeArgv(call: unknown[], command: string): void { + expect(call[0]).toBe(command); + expect(Array.isArray(call[1])).toBe(true); + // promisify(execFile) appends only a Node-style callback, so the final arg is + // a function and there is no options object carrying shell:true. + expect(call.at(-1)).toBeTypeOf("function"); + const options = call + .slice(2, -1) + .find( + (arg): arg is Record => + typeof arg === "object" && arg !== null && !Array.isArray(arg), + ); + expect(options?.shell ?? false).toBe(false); +} + +describe("installConnectorSchedule (darwin native install)", () => { + test("writes an escaped plist and bootstraps it with an explicit argv", async () => { + // A working directory laden with every XML-significant character proves the + // plist builder escapes each one; an unescaped value would corrupt the + // property list (or allow injecting extra keys). + const cwd = `/repo & "q" 'a'`; + + const result = await installConnectorSchedule({ + connectorId: "git-repo", + cronExpression: "0 2 * * *", + cwd, + }); + + expect(result.launchAgentPath).toBe(PLIST_PATH); + expect(result.warning).toBeUndefined(); + + const plist = await readFile(PLIST_PATH, "utf8"); + // escapePlist must map & < > " ' to their entities in the WorkingDirectory. + expect(plist).toContain( + `/repo & <danger> "q" 'a'`, + ); + expect(plist).not.toContain(``); + expect(plist).toContain(`${LABEL}`); + // "0 2 * * *" -> launchd StartCalendarInterval Minute 0, Hour 2. + expect(plist).toContain("Minute\n 0"); + expect(plist).toContain("Hour\n 2"); + + // Pre-existing agents are booted out before the new one is bootstrapped. + const bootout = findCall("launchctl", "bootout"); + const bootstrap = findCall("launchctl", "bootstrap"); + expect(bootout?.[1]).toEqual(["bootout", `${LAUNCHD_DOMAIN}/${LABEL}`]); + expect(bootstrap?.[1]).toEqual(["bootstrap", LAUNCHD_DOMAIN, PLIST_PATH]); + expectSafeArgv(bootstrap as unknown[], "launchctl"); + }); + + test("propagates a launchctl bootstrap failure", async () => { + // bootout is best-effort (swallowed); a bootstrap failure is real and must + // surface so the caller does not report a phantom install. + execFileOutcome = (command, args) => + command === "launchctl" && args[0] === "bootstrap" + ? { error: new Error("Bootstrap failed: 5: Input/output error") } + : {}; + + await expect( + installConnectorSchedule({ + connectorId: "git-repo", + cronExpression: "0 2 * * *", + cwd: "/repo", + }), + ).rejects.toThrow(/Bootstrap failed/u); + }); +}); + +describe("installOpenWikiPowerSchedule (darwin pmset via osascript)", () => { + test("installs the repeat wake window with single-quoted pmset argv under admin privileges", async () => { + const result = await installOpenWikiPowerSchedule( + configWithSchedule("0 2 * * *"), + ); + + expect(result.enabled).toBe(true); + expect(result.warning).toMatch(/one repeat power schedule/iu); + + const call = findCall("osascript", "-e"); + expect(call).toBeDefined(); + expectSafeArgv(call as unknown[], "osascript"); + + const script = (call?.[1] as string[])[1]; + expect(script).toContain("do shell script "); + expect(script).toContain("with administrator privileges"); + // Every pmset token is individually single-quoted, so an + // operator-configured day/time value cannot break out of its argument and + // inject additional shell words. + expect(script).toContain( + `'pmset' 'repeat' 'wakeorpoweron' 'MTWRFSU' '01:58:00' 'sleep' 'MTWRFSU' '02:30:00'`, + ); + }); + + test("reports a warning instead of throwing when osascript fails", async () => { + // A cancelled admin prompt or a pmset error exits non-zero; the failure is + // captured into a user-facing warning rather than propagated. + execFileOutcome = () => ({ error: new Error("User cancelled.") }); + + const result = await installOpenWikiPowerSchedule( + configWithSchedule("0 2 * * *"), + ); + + expect(result.enabled).toBe(false); + expect(result.warning).toBe( + "Wake setup was not installed: User cancelled.", + ); + }); +}); + +describe("isLaunchAgentLoaded via listConnectorSchedules (darwin)", () => { + test("reports the agent as loaded when launchctl print succeeds", async () => { + const [status] = await listConnectorSchedules( + configWithSchedule("0 2 * * *"), + ); + + expect(status.launchAgentLoaded).toBe(true); + const call = findCall("launchctl", "print"); + expect(call?.[1]).toEqual(["print", `${LAUNCHD_DOMAIN}/${LABEL}`]); + expectSafeArgv(call as unknown[], "launchctl"); + }); + + test("reports the agent as not loaded when launchctl print fails", async () => { + // `launchctl print` exits non-zero for an unknown label; that is the normal + // "not installed" signal and must read as unloaded, not as an error. + execFileOutcome = () => ({ error: new Error("Could not find service") }); + + const [status] = await listConnectorSchedules( + configWithSchedule("0 2 * * *"), + ); + + expect(status.launchAgentLoaded).toBe(false); + }); +}); + +describe("deleteConnectorSchedules (darwin cleanup)", () => { + test("unloads the agent, removes the plist, and cancels the pmset schedule", async () => { + const result = await deleteConnectorSchedules( + withEnabledPmset(configWithSchedule("0 2 * * *")), + "all", + ); + + expect(result.config.ingestionSchedule).toBeUndefined(); + // No ingestion schedule remains, so reconciliation cancels the saved, + // enabled pmset window. + expect(result.powerSchedule?.enabled).toBe(false); + expect(result.config.powerManagement?.pmset?.enabled).toBe(false); + expect(result.warnings.length).toBeGreaterThan(0); + + expect(findCall("launchctl", "bootout")?.[1]).toEqual([ + "bootout", + `${LAUNCHD_DOMAIN}/${LABEL}`, + ]); + const cancel = findCall("osascript", "-e"); + expect(cancel).toBeDefined(); + expectSafeArgv(cancel as unknown[], "osascript"); + expect((cancel?.[1] as string[])[1]).toContain(`'pmset' 'repeat' 'cancel'`); + }); + + test("swallows a bootout failure yet still reports a pmset cancel failure", async () => { + // `launchctl bootout` failing (agent already gone) is expected and must be + // swallowed so deletion proceeds; a failing pmset cancel, by contrast, is + // captured into a warning rather than dropped. + execFileOutcome = (command) => + command === "launchctl" + ? { error: new Error("Boot-out failed: 3: No such process") } + : { error: new Error("cancel denied") }; + + const result = await deleteConnectorSchedules( + withEnabledPmset(configWithSchedule("0 2 * * *")), + "all", + ); + + expect(result.config.ingestionSchedule).toBeUndefined(); + expect(result.powerSchedule?.enabled).toBe(false); + expect(result.warnings).toContain( + "Wake setup was not removed: cancel denied", + ); + }); + + test("rethrows a non-ENOENT failure while removing the plist", async () => { + // Make the plist path a directory so unlink() fails with EPERM/EISDIR rather + // than ENOENT; that is a real filesystem fault the code must surface instead + // of silently swallowing like the missing-file case. + await mkdir(PLIST_PATH, { recursive: true }); + + await expect( + deleteConnectorSchedules(configWithSchedule("0 2 * * *"), "all"), + ).rejects.toThrow(); + }); +}); + +describe("resumeConnectorSchedules (darwin reinstall + power reconcile)", () => { + test("reinstalls the launch agent and updates the pmset wake window", async () => { + const result = await resumeConnectorSchedules({ + config: withEnabledPmset( + configWithSchedule("0 2 * * *", { + pausedAt: "2026-01-02T00:00:00.000Z", + }), + ), + cwd: "/repo", + target: "all", + }); + + expect(result.connectorIds).toEqual(["all"]); + expect(result.config.ingestionSchedule?.pausedAt).toBeUndefined(); + expect(result.config.ingestionSchedule?.launchAgentPath).toBe(PLIST_PATH); + // An active ingestion schedule plus saved pmset drives reconciliation down + // the install-power-window branch, re-enabling the wake schedule. + expect(result.powerSchedule?.enabled).toBe(true); + + expect(findCall("launchctl", "bootstrap")?.[1]).toEqual([ + "bootstrap", + LAUNCHD_DOMAIN, + PLIST_PATH, + ]); + expect(findCall("osascript", "-e")).toBeDefined(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 0571e40f..0be4a881 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,11 +15,12 @@ export default defineConfig({ provider: "v8", all: true, include: ["src/**/*.{ts,tsx}"], - // `types.ts` modules are pure `interface`/`type` declarations that emit no - // runtime JavaScript, so v8 reports them as 0-of-0 statements and drags the - // aggregate down for code that cannot be executed. Exclude them (and .d.ts) - // so the denominator reflects only files with real, coverable behavior. - exclude: ["src/**/*.d.ts", "src/**/types.ts"], + // `types.ts` modules are pure `interface`/`type` declarations, and + // `telemetry/index.ts` is a pure re-export barrel; both emit no runtime + // JavaScript of their own, so v8 reports them as 0-of-0 statements and drags + // the aggregate down for code that cannot be executed. Exclude them (and + // .d.ts) so the denominator reflects only files with real, coverable behavior. + exclude: ["src/**/*.d.ts", "src/**/types.ts", "src/telemetry/index.ts"], reporter: ["text", "text-summary", "html", "json-summary", "lcov"], }, }, From b00478eab4e651ce2b328a8864f796b63e835ada Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 16:58:18 -0700 Subject: [PATCH 07/13] fix test --- test/scheduling/schedule-operations.test.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/test/scheduling/schedule-operations.test.ts b/test/scheduling/schedule-operations.test.ts index 64c97054..90122eea 100644 --- a/test/scheduling/schedule-operations.test.ts +++ b/test/scheduling/schedule-operations.test.ts @@ -15,8 +15,10 @@ import { // These exercise the reachable, non-shelling surface of the schedule lifecycle // helpers. Two techniques keep them off child_process: -// 1. Some branches (invalid/too-complex cron, "no representable power window") -// return before any platform check, so they are pure on macOS as-is. +// 1. Some branches (invalid cron, "no representable power window") return +// before any platform check, so they are pure on every host as-is. The +// "too complex" branch sits after the darwin guard, so that test stubs the +// platform to darwin to reach it (still returns before shelling). // 2. The native paths guard on `process.platform === "darwin"`; on every other // platform launchctl/pmset/unload/remove are documented no-ops. We stub the // platform to a non-Darwin value to drive the graceful-degradation paths and @@ -84,9 +86,14 @@ describe("installConnectorSchedule", () => { }); test("returns a 'too complex for launchd' warning for a valid-but-unrepresentable cron", async () => { + // The darwin guard precedes the representability check, so on a non-Darwin + // host this would return the "macOS-only" warning instead. Force darwin so + // the too-complex branch is reached regardless of the CI host OS. + stubPlatform("darwin"); + // `*/15 2 * * *` parses as cron but has no single-value launchd calendar // interval, so install must degrade to a saved-only warning rather than - // writing a plist. This branch runs on macOS without shelling. + // writing a plist. This branch returns before any shelling. const result = await installConnectorSchedule({ connectorId: "git-repo", cronExpression: "*/15 2 * * *", From 716a712de4f3e6fae0e3a37cbf8418b6da1ba083 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Tue, 28 Jul 2026 17:04:28 -0700 Subject: [PATCH 08/13] codeql --- src/telemetry/senders.ts | 28 +++++++++++++++++++++++++--- test/telemetry/telemetry.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/telemetry/senders.ts b/src/telemetry/senders.ts index 73ee2e66..32cf072b 100644 --- a/src/telemetry/senders.ts +++ b/src/telemetry/senders.ts @@ -1,5 +1,6 @@ +import { randomBytes } from "node:crypto"; import { readFileSync } from "node:fs"; -import { mkdir, writeFile } from "node:fs/promises"; +import { mkdir, rename, rm, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -244,11 +245,32 @@ async function writeTelemetryFile( return; } + const resolved = path.resolve(process.cwd(), filePath); + // Write to an unguessable, owner-only scratch sibling and atomically rename it + // into place, rather than writing the caller's path directly. `--telemetry-file` + // may point at a shared directory such as /tmp, where a direct write would + // follow a pre-planted symlink (clobbering an arbitrary file with our + // privileges) and inherit umask permissions (leaking run metadata to other + // local users). The random name defeats pre-creation, `flag: "wx"` refuses to + // open through an existing symlink, `mode: 0o600` keeps it owner-only, and + // `rename` replaces the final directory entry itself instead of writing + // through a symlink at that path. + const scratch = path.join( + path.dirname(resolved), + `.${path.basename(resolved)}.${randomBytes(6).toString("hex")}.tmp`, + ); + try { - const resolved = path.resolve(process.cwd(), filePath); await mkdir(path.dirname(resolved), { recursive: true }); - await writeFile(resolved, `${JSON.stringify(record, null, 2)}\n`, "utf8"); + await writeFile(scratch, `${JSON.stringify(record, null, 2)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(scratch, resolved); } catch (error) { + // Best-effort cleanup so a failed write never leaves the scratch behind. + await rm(scratch, { force: true }).catch(() => {}); const message = error instanceof Error ? error.message : String(error); console.error( `OpenWiki: could not write telemetry file "${filePath}": ${message}`, diff --git a/test/telemetry/telemetry.test.ts b/test/telemetry/telemetry.test.ts index 1d4817fa..9f1a7000 100644 --- a/test/telemetry/telemetry.test.ts +++ b/test/telemetry/telemetry.test.ts @@ -1,4 +1,11 @@ -import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; @@ -818,6 +825,29 @@ describe("senders.recordRun", () => { await rm(file, { force: true }); }); + test("tees with owner-only permissions and leaves no scratch file behind", async () => { + // The tee may land in a shared directory, so the write must not leak run + // metadata to other local users and must clean up its atomic-write scratch. + const dir = await mkdtemp(path.join(tmpdir(), "ow-tel-perms-")); + const file = path.join(dir, "out.json"); + + await recordRun(runDetails({ telemetryFile: file })); + + // The final payload is intact and it is the only file left in the directory + // (the randomly-named scratch was renamed into place, not orphaned). + expect((await readTee(file)).sent).toBe(true); + expect(await readdir(dir)).toEqual(["out.json"]); + + // On POSIX the file is created 0o600 (owner read/write only). Windows does + // not model these mode bits, so the assertion is POSIX-only. + if (process.platform !== "win32") { + const mode = (await stat(file)).mode & 0o777; + expect(mode).toBe(0o600); + } + + await rm(dir, { force: true, recursive: true }); + }); + test("never throws even if capture fails", async () => { posthog.captureImmediate.mockImplementation(() => { throw new Error("boom"); From 1fe9ffc5bb723e35c3ecb1d94a6649690c11dc43 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Mon, 3 Aug 2026 09:11:54 -0700 Subject: [PATCH 09/13] rebase --- src/agent/openwiki-ignore.ts | 2 +- src/config/constants.ts | 2 +- src/telemetry/with-run-telemetry.ts | 2 +- test/agent/parse-stream-event.test.ts | 8 ++++---- test/agent/update-noop.test.ts | 2 +- test/config/env-behavior.test.ts | 2 +- test/visualize-command.test.ts | 2 +- 7 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/agent/openwiki-ignore.ts b/src/agent/openwiki-ignore.ts index b9d25818..014cdd52 100644 --- a/src/agent/openwiki-ignore.ts +++ b/src/agent/openwiki-ignore.ts @@ -1,6 +1,6 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; -import { isFileNotFoundError } from "../fs-errors.js"; +import { isFileNotFoundError } from "../platform/fs-errors.js"; /** * Name of the gitignore-style file that lists paths the doc agent must not touch. diff --git a/src/config/constants.ts b/src/config/constants.ts index d7475193..21d0eb51 100644 --- a/src/config/constants.ts +++ b/src/config/constants.ts @@ -928,4 +928,4 @@ export function isValidModelId(value: string): boolean { // Derived at runtime from package.json (single source of truth) rather than // hardcoded here; re-exported so existing importers of `OPENWIKI_VERSION` are // unchanged. -export { OPENWIKI_VERSION } from "./version.js"; +export { OPENWIKI_VERSION } from "../version.js"; diff --git a/src/telemetry/with-run-telemetry.ts b/src/telemetry/with-run-telemetry.ts index 6ba0fff4..69c61bc4 100644 --- a/src/telemetry/with-run-telemetry.ts +++ b/src/telemetry/with-run-telemetry.ts @@ -1,5 +1,5 @@ import type { OpenWikiCommand, OpenWikiRunOptions } from "../agent/types.js"; -import type { OpenWikiProvider } from "../constants.js"; +import type { OpenWikiProvider } from "../config/constants.js"; import { describeErrorForTelemetry } from "./errors.js"; import { recordRunSafe } from "./record-run-safe.js"; diff --git a/test/agent/parse-stream-event.test.ts b/test/agent/parse-stream-event.test.ts index 7728ad28..bdabd078 100644 --- a/test/agent/parse-stream-event.test.ts +++ b/test/agent/parse-stream-event.test.ts @@ -384,7 +384,7 @@ describe("parseStreamEvent – tools branch", () => { }, ); - test("an array tool input is rendered via its indexed entries", () => { + test("an array tool input is rendered as a positional value list", () => { const event = parseStreamEvent( toolsChunk({ event: "on_tool_start", @@ -394,9 +394,9 @@ describe("parseStreamEvent – tools branch", () => { }), ); - // An array is an object, so formatToolArgs takes the record branch first - // and keys by array index rather than positionally. - expect((event as { call: string }).call).toBe('batch(0="a", 1=2)'); + // formatToolArgs checks Array.isArray before the record branch, so arrays + // render positionally as a value list rather than keyed by index. + expect((event as { call: string }).call).toBe('batch("a", 2)'); }); test("an absent tool input renders an empty argument list", () => { diff --git a/test/agent/update-noop.test.ts b/test/agent/update-noop.test.ts index faf8c5be..8de1bb98 100644 --- a/test/agent/update-noop.test.ts +++ b/test/agent/update-noop.test.ts @@ -4,7 +4,7 @@ import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; -import { OpenWikiIgnore } from "../src/agent/openwiki-ignore.ts"; +import { OpenWikiIgnore } from "../../src/agent/openwiki-ignore.ts"; import { getUpdateNoopStatus, shouldCheckUpdateNoop, diff --git a/test/config/env-behavior.test.ts b/test/config/env-behavior.test.ts index 866e62ac..9b760a6d 100644 --- a/test/config/env-behavior.test.ts +++ b/test/config/env-behavior.test.ts @@ -333,7 +333,7 @@ describe("saveOpenWikiEnv", () => { }); try { - const concurrentEnv = await import("../src/env.ts"); + const concurrentEnv = await import("../../src/config/env.ts"); const first = concurrentEnv.saveOpenWikiEnv({ [OPENAI_API_KEY_ENV_KEY]: "first", }); diff --git a/test/visualize-command.test.ts b/test/visualize-command.test.ts index 446aac5d..000040e6 100644 --- a/test/visualize-command.test.ts +++ b/test/visualize-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseCommand } from "../src/commands.ts"; +import { parseCommand } from "../src/cli/commands.ts"; describe("parseCommand visualize", () => { test("defaults: openwiki dir, port 4321, opens the browser", () => { From 7469eab6206f77113991ef108ad5e1221419141a Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Mon, 3 Aug 2026 09:12:41 -0700 Subject: [PATCH 10/13] add changeset --- .changeset/huge-ants-stick.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/huge-ants-stick.md diff --git a/.changeset/huge-ants-stick.md b/.changeset/huge-ants-stick.md new file mode 100644 index 00000000..78277b40 --- /dev/null +++ b/.changeset/huge-ants-stick.md @@ -0,0 +1,5 @@ +--- +"openwiki": patch +--- + +chore: reorganize repo code to make into domain specific directories and improve test coverage to prevent regressions From d9289a77d7f63437e77e32e27d25f472465762e5 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Thu, 6 Aug 2026 09:57:25 -0700 Subject: [PATCH 11/13] format --- test/agent/parse-stream-event.test.ts | 93 ++++++++------------- test/agent/prompt.test.ts | 32 ++++--- test/agent/utils.test.ts | 116 +------------------------- test/telemetry/telemetry.test.ts | 2 + 4 files changed, 63 insertions(+), 180 deletions(-) diff --git a/test/agent/parse-stream-event.test.ts b/test/agent/parse-stream-event.test.ts index bdabd078..f3a63039 100644 --- a/test/agent/parse-stream-event.test.ts +++ b/test/agent/parse-stream-event.test.ts @@ -180,49 +180,29 @@ describe("parseStreamEvent – message text extraction shapes", () => { }); describe("parseStreamEvent – protocol streaming sub-events", () => { - test("content-block-delta text-delta streams the delta text", () => { - const event = parseStreamEvent( - messagesChunk({ - event: "content-block-delta", - delta: { type: "text-delta", text: "streamed" }, - }), - ); - - expect(expectText(event)).toBe("streamed"); - }); - - test("content-block-delta block-delta reads text out of `fields`", () => { - const event = parseStreamEvent( - messagesChunk({ - event: "content-block-delta", - delta: { type: "block-delta", fields: { text: "block body" } }, - }), - ); - - expect(expectText(event)).toBe("block body"); - }); - - test("content-block-delta falls back to a bare `text` on the delta", () => { - const event = parseStreamEvent( - messagesChunk({ - event: "content-block-delta", - delta: { text: "bare delta text" }, - }), - ); - - expect(expectText(event)).toBe("bare delta text"); - }); - - test("content-block-delta falls back to a bare `delta` string", () => { - const event = parseStreamEvent( - messagesChunk({ - event: "content-block-delta", - delta: { delta: "nested delta" }, - }), - ); - - expect(expectText(event)).toBe("nested delta"); - }); + // A bare protocol-framing record (`{ event, delta }`) carries no message + // `content` and no recognizable role, so extractMessageText finds nothing to + // read and the frame resolves to null. Real streamed delta text arrives + // wrapped in a content block (see the content-array delta case below), which + // is the path that actually surfaces text. + test.each([ + ["text-delta frame", { type: "text-delta", text: "streamed" }], + [ + "block-delta frame", + { type: "block-delta", fields: { text: "block body" } }, + ], + ["bare-text delta", { text: "bare delta text" }], + ["nested-delta string", { delta: "nested delta" }], + ])( + "a bare content-block-delta record (%s) surfaces no renderable text", + (_label, delta) => { + expect( + parseStreamEvent( + messagesChunk({ event: "content-block-delta", delta }), + ), + ).toBeNull(); + }, + ); test("content-block-start reads text from the block content", () => { const event = parseStreamEvent( @@ -323,7 +303,7 @@ describe("parseStreamEvent – tools branch", () => { test("the `execute` tool name is capitalized in the call line", () => { const event = parseStreamEvent( - toolsChunk({ event: "tool-started", tool_name: "execute", input: "ls" }), + toolsChunk({ event: "on_tool_start", name: "execute", input: "ls" }), ); // formatToolCallName maps execute -> Execute; a bare non-JSON string input @@ -337,9 +317,9 @@ describe("parseStreamEvent – tools branch", () => { toolsChunk({ event: "on_tool_start", input: { q: 1 } }), ); - // Absent name -> "tool"; absent id -> `${name}:${formatToolValue(input)}`. + // Absent name -> "tool"; absent toolCallId -> the resolved name itself. expect(event).toMatchObject({ type: "tool_start", name: "tool" }); - expect((event as { id: string }).id).toBe('tool:{"q":1}'); + expect((event as { id: string }).id).toBe("tool"); }); test("a stringified-JSON input is parsed before formatting", () => { @@ -347,7 +327,7 @@ describe("parseStreamEvent – tools branch", () => { toolsChunk({ event: "on_tool_start", name: "search", - tool_call_id: "c2", + toolCallId: "c2", input: '{"query":"hi"}', }), ); @@ -373,16 +353,17 @@ describe("parseStreamEvent – tools branch", () => { }); }); - test.each(["on_tool_error", "tool-error"])( - "%s yields a tool_end event with error status", - (event) => { - const parsed = parseStreamEvent( - toolsChunk({ event, name: "write_file", tool_call_id: "c4" }), - ); + test("on_tool_error yields a tool_end event with error status", () => { + const parsed = parseStreamEvent( + toolsChunk({ + event: "on_tool_error", + name: "write_file", + toolCallId: "c4", + }), + ); - expect(parsed).toMatchObject({ type: "tool_end", status: "error" }); - }, - ); + expect(parsed).toMatchObject({ type: "tool_end", status: "error" }); + }); test("an array tool input is rendered as a positional value list", () => { const event = parseStreamEvent( diff --git a/test/agent/prompt.test.ts b/test/agent/prompt.test.ts index 751d1a7d..a90ea880 100644 --- a/test/agent/prompt.test.ts +++ b/test/agent/prompt.test.ts @@ -15,7 +15,6 @@ import type { RunContext } from "../../src/agent/types.ts"; function emptyContext(overrides: Partial = {}): RunContext { return { lastUpdate: null, - gitSummary: "no git changes", ...overrides, }; } @@ -122,13 +121,17 @@ describe("createSystemPrompt filesystem path guidance", () => { }); }); - test("both modes forbid typing host/tilde paths into filesystem tools", () => { - for (const outputMode of ["repository", "local-wiki"] as const) { - const prompt = createSystemPrompt("update", outputMode); - expect(prompt).toMatch( - /Never type ~, ~\/\.openwiki\/wiki, or host paths/, - ); - } + test("both modes forbid typing host paths into filesystem tools", () => { + // The hazard differs by mode, so the guidance does too: repository update + // warns against host *absolute* paths (/Users/...), since a repo has no + // ~/.openwiki/wiki to confuse; local-wiki update additionally forbids ~ and + // the wiki home. Both keep host paths out of the filesystem tools. + expect(createSystemPrompt("update", "repository")).toMatch( + /Never pass host absolute paths like \/Users\/\.\.\. to filesystem tools/, + ); + expect(createSystemPrompt("update", "local-wiki")).toMatch( + /Never type ~, ~\/\.openwiki\/wiki, or host paths/, + ); }); }); @@ -216,10 +219,10 @@ describe("createUserPrompt", () => { ); }); - test("init embeds the wiki goal and git summary for the resolved subject", () => { + test("init embeds the wiki goal for the resolved subject", () => { const prompt = createUserPrompt( "init", - emptyContext({ wikiGoal: "Explain the CLI", gitSummary: "3 files" }), + emptyContext({ wikiGoal: "Explain the CLI" }), null, "repository", ); @@ -227,8 +230,10 @@ describe("createUserPrompt", () => { expect(prompt).toContain("Initialize OpenWiki documentation for"); // Repository mode resolves the subject to the repo, not the personal brain. expect(prompt).toContain("this repository"); + // The wiki goal is interpolated into the brief; the git summary is not part + // of the user prompt (it rides the run context for the agent, not the + // template), so only the goal is asserted here. expect(prompt).toContain("Explain the CLI"); - expect(prompt).toContain("3 files"); // No user message means no appended instruction block. expect(prompt).not.toContain("Additional user instruction:"); }); @@ -242,6 +247,9 @@ describe("createUserPrompt", () => { }); test("update renders the previous-run metadata as pretty JSON", () => { + // Only local-wiki mode inlines the recorded metadata into the prompt; + // repository update instead tells the agent to read /openwiki/.last-update.json + // from disk, so the {LAST_UPDATE} block lives on the personal template. const prompt = createUserPrompt( "update", emptyContext({ @@ -252,7 +260,7 @@ describe("createUserPrompt", () => { }, }), null, - "repository", + "local-wiki", ); expect(prompt).toContain("Update the existing OpenWiki documentation"); diff --git a/test/agent/utils.test.ts b/test/agent/utils.test.ts index 4be91ddb..514c8cf2 100644 --- a/test/agent/utils.test.ts +++ b/test/agent/utils.test.ts @@ -6,15 +6,15 @@ import { promisify } from "node:util"; import { describe, expect, test } from "vitest"; import { createOpenWikiContentSnapshot, - createRunContext, getUpdateNoopStatus, removeTemporaryPlanFile, } from "../../src/agent/utils.ts"; // These cover the branches of utils.ts that the sibling run-context, -// run-metadata, and update-noop suites do not reach: the repository-mode git -// evidence block, the local-wiki summary text, the degenerate no-op paths, the -// snapshot recursion, and the unexpected-error path of plan-file removal. +// run-metadata, and update-noop suites do not reach: the degenerate no-op +// paths, the snapshot recursion, and the unexpected-error path of plan-file +// removal. (createRunContext's own behavior is covered by run-context.test.ts; +// it no longer computes a git summary in code — the agent runs git itself.) const execFileAsync = promisify(execFile); @@ -50,114 +50,6 @@ async function writeMetadata( ); } -describe("createRunContext git summary", () => { - test("init in a repository embeds the standard git evidence sections", async () => { - const repo = await createGitRepo(); - - try { - const context = await createRunContext("init", repo, "repository"); - - // The prompt relies on these labeled sections to reason about the repo, - // so their presence is the observable contract of createGitSummary. - expect(context.gitSummary).toContain("$ git status --short"); - expect(context.gitSummary).toContain("$ git rev-parse HEAD"); - expect(context.gitSummary).toContain( - "$ git log --max-count=20 --name-status --oneline", - ); - expect(context.gitSummary).toContain("$ git diff --name-status HEAD"); - // An init run has no prior timestamp, but the "No prior" note is reserved - // for update runs and must not appear here. - expect(context.gitSummary).not.toContain( - "No prior OpenWiki update timestamp", - ); - } finally { - await rm(repo, { recursive: true, force: true }); - } - }); - - test("update without prior metadata falls back to the recent log with a note", async () => { - const repo = await createGitRepo(); - - try { - const context = await createRunContext("update", repo, "repository"); - - expect(context.gitSummary).toContain( - "No prior OpenWiki update timestamp was found.", - ); - expect(context.gitSummary).toContain( - "$ git log --max-count=20 --name-status --oneline", - ); - } finally { - await rm(repo, { recursive: true, force: true }); - } - }); - - test("update diffs since the recorded git head when one exists", async () => { - const repo = await createGitRepo(); - - try { - const firstHead = await git(repo, ["rev-parse", "HEAD"]); - await writeFile(path.join(repo, "README.md"), "# Changed\n", "utf8"); - await git(repo, ["add", "."]); - await git(repo, ["commit", "-m", "second"]); - await writeMetadata(repo, { - updatedAt: new Date().toISOString(), - command: "update", - gitHead: firstHead, - model: "test-model", - }); - - const context = await createRunContext("update", repo, "repository"); - - // A recorded head drives a precise range diff rather than the timestamp - // fallback or the recent-log fallback. - expect(context.gitSummary).toContain( - `$ git log ${firstHead}..HEAD --name-status --oneline`, - ); - expect(context.gitSummary).not.toContain( - "No prior OpenWiki update timestamp", - ); - } finally { - await rm(repo, { recursive: true, force: true }); - } - }); - - test("update falls back to a --since log when only a timestamp was recorded", async () => { - const repo = await createGitRepo(); - - try { - // Metadata that predates the gitHead field still carries updatedAt, which - // selects the `git log --since` branch. - await writeMetadata(repo, { - updatedAt: "2020-01-01T00:00:00.000Z", - command: "update", - model: "test-model", - }); - - const context = await createRunContext("update", repo, "repository"); - - expect(context.gitSummary).toContain( - "$ git log --since 2020-01-01T00:00:00.000Z --name-status --oneline", - ); - } finally { - await rm(repo, { recursive: true, force: true }); - } - }); - - test("local-wiki mode reports that git context is not used", async () => { - const cwd = await mkdtemp(path.join(tmpdir(), "openwiki-utils-local-")); - - try { - const context = await createRunContext("update", cwd, "local-wiki"); - - expect(context.gitSummary).toContain("Local wiki mode"); - expect(context.gitSummary).not.toContain("$ git status"); - } finally { - await rm(cwd, { recursive: true, force: true }); - } - }); -}); - describe("getUpdateNoopStatus degenerate cases", () => { test("does not skip when prior metadata has no git head", async () => { const repo = await createGitRepo(); diff --git a/test/telemetry/telemetry.test.ts b/test/telemetry/telemetry.test.ts index 9f1a7000..7ef2fc63 100644 --- a/test/telemetry/telemetry.test.ts +++ b/test/telemetry/telemetry.test.ts @@ -889,6 +889,8 @@ describe("senders.recordRun", () => { sent: false, }); await rm(file, { force: true }); + }); + test("reports, without throwing, when the tee file cannot be written", async () => { // A tee target under a regular file cannot have its parent directory // created; recordRun must log the failure and carry on, never breaking the From eef0966fedf3652f8cea323e8e5bec3687f5523e Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Thu, 6 Aug 2026 10:13:45 -0700 Subject: [PATCH 12/13] mirror src dir in test dir --- test/{ => agent}/conversation-history-offload.test.ts | 4 ++-- test/{ => agent}/crash-guard.test.ts | 6 +++--- test/{ => agent}/create-openwiki-agent.test.ts | 2 +- test/{ => agent}/openwiki-ignore.test.ts | 4 ++-- test/{ => agent}/tool-args-formatting.test.ts | 2 +- test/{ => agent}/wiki-link-validator-dogfood.test.ts | 4 ++-- test/{ => agent}/wiki-link-validator.test.ts | 4 ++-- test/{ => cli}/visualize-command.test.ts | 2 +- test/{ => visualize}/visualize-client-lib.test.ts | 2 +- test/{ => visualize}/visualize-graph.test.ts | 2 +- test/{ => visualize}/visualize-server.test.ts | 0 11 files changed, 16 insertions(+), 16 deletions(-) rename test/{ => agent}/conversation-history-offload.test.ts (98%) rename test/{ => agent}/crash-guard.test.ts (97%) rename test/{ => agent}/create-openwiki-agent.test.ts (95%) rename test/{ => agent}/openwiki-ignore.test.ts (98%) rename test/{ => agent}/tool-args-formatting.test.ts (96%) rename test/{ => agent}/wiki-link-validator-dogfood.test.ts (78%) rename test/{ => agent}/wiki-link-validator.test.ts (99%) rename test/{ => cli}/visualize-command.test.ts (96%) rename test/{ => visualize}/visualize-client-lib.test.ts (98%) rename test/{ => visualize}/visualize-graph.test.ts (99%) rename test/{ => visualize}/visualize-server.test.ts (100%) diff --git a/test/conversation-history-offload.test.ts b/test/agent/conversation-history-offload.test.ts similarity index 98% rename from test/conversation-history-offload.test.ts rename to test/agent/conversation-history-offload.test.ts index ce4dff0c..0b74c705 100644 --- a/test/conversation-history-offload.test.ts +++ b/test/agent/conversation-history-offload.test.ts @@ -4,12 +4,12 @@ import path from "node:path"; import { AIMessage, HumanMessage } from "@langchain/core/messages"; import { createSummarizationMiddleware } from "deepagents"; import { describe, expect, test, vi } from "vitest"; -import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; +import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; import { AGENT_FILESYSTEM_PERMISSIONS, CONVERSATION_HISTORY_MOUNT, createAgentBackend, -} from "../src/agent/index.ts"; +} from "../../src/agent/index.ts"; async function createBackendFixture(options: { docsOnly: boolean }) { const repoDir = await mkdtemp(path.join(os.tmpdir(), "openwiki-repo-")); diff --git a/test/crash-guard.test.ts b/test/agent/crash-guard.test.ts similarity index 97% rename from test/crash-guard.test.ts rename to test/agent/crash-guard.test.ts index 4f2475be..ad4e92f5 100644 --- a/test/crash-guard.test.ts +++ b/test/agent/crash-guard.test.ts @@ -15,10 +15,10 @@ import { const recordRunSafe = vi.fn(() => Promise.resolve(undefined)); const persistRunMetadataIfChanged = vi.fn(() => Promise.resolve(true)); -vi.mock("../src/telemetry/record-run-safe.ts", () => ({ +vi.mock("../../src/telemetry/record-run-safe.ts", () => ({ recordRunSafe: (...args: unknown[]) => recordRunSafe(...args), })); -vi.mock("../src/agent/utils.ts", () => ({ +vi.mock("../../src/agent/utils.ts", () => ({ persistRunMetadataIfChanged: (...args: unknown[]) => persistRunMetadataIfChanged(...args), })); @@ -29,7 +29,7 @@ import { handleFatal, registerActiveRun, type ActiveRunRecord, -} from "../src/agent/crash-guard.ts"; +} from "../../src/agent/crash-guard.ts"; const ACTIVE: ActiveRunRecord = { command: "init", diff --git a/test/create-openwiki-agent.test.ts b/test/agent/create-openwiki-agent.test.ts similarity index 95% rename from test/create-openwiki-agent.test.ts rename to test/agent/create-openwiki-agent.test.ts index 82557d08..a37053fd 100644 --- a/test/create-openwiki-agent.test.ts +++ b/test/agent/create-openwiki-agent.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { FakeListChatModel } from "@langchain/core/utils/testing"; import { afterEach, describe, expect, test } from "vitest"; -import { createOpenWikiAgent } from "../src/agent/index.ts"; +import { createOpenWikiAgent } from "../../src/agent/index.ts"; const temporaryDirectories: string[] = []; diff --git a/test/openwiki-ignore.test.ts b/test/agent/openwiki-ignore.test.ts similarity index 98% rename from test/openwiki-ignore.test.ts rename to test/agent/openwiki-ignore.test.ts index c1bbc379..358bc71a 100644 --- a/test/openwiki-ignore.test.ts +++ b/test/agent/openwiki-ignore.test.ts @@ -2,8 +2,8 @@ import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; -import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; -import { OpenWikiIgnore } from "../src/agent/openwiki-ignore.ts"; +import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; +import { OpenWikiIgnore } from "../../src/agent/openwiki-ignore.ts"; async function createIgnoredRepo(): Promise<{ backend: OpenWikiLocalShellBackend; diff --git a/test/tool-args-formatting.test.ts b/test/agent/tool-args-formatting.test.ts similarity index 96% rename from test/tool-args-formatting.test.ts rename to test/agent/tool-args-formatting.test.ts index b387ce57..e4bdfdb3 100644 --- a/test/tool-args-formatting.test.ts +++ b/test/agent/tool-args-formatting.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseStreamEvent } from "../src/agent/index.ts"; +import { parseStreamEvent } from "../../src/agent/index.ts"; // `formatToolArgs` is module-private, so these drive it the way the CLI does: // through the exported stream-event parser, whose "tools" branch builds the diff --git a/test/wiki-link-validator-dogfood.test.ts b/test/agent/wiki-link-validator-dogfood.test.ts similarity index 78% rename from test/wiki-link-validator-dogfood.test.ts rename to test/agent/wiki-link-validator-dogfood.test.ts index 9df7d907..5158ebef 100644 --- a/test/wiki-link-validator-dogfood.test.ts +++ b/test/agent/wiki-link-validator-dogfood.test.ts @@ -1,7 +1,7 @@ import path from "node:path"; import { describe, expect, test } from "vitest"; -import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; -import { validateWikiInternalLinks } from "../src/agent/wiki-link-validator.ts"; +import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; +import { validateWikiInternalLinks } from "../../src/agent/wiki-link-validator.ts"; describe("validateWikiInternalLinks dogfood", () => { test("accepts the repository's checked-in openwiki tree", async () => { diff --git a/test/wiki-link-validator.test.ts b/test/agent/wiki-link-validator.test.ts similarity index 99% rename from test/wiki-link-validator.test.ts rename to test/agent/wiki-link-validator.test.ts index 4d855c9e..95fd185c 100644 --- a/test/wiki-link-validator.test.ts +++ b/test/agent/wiki-link-validator.test.ts @@ -2,14 +2,14 @@ import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, test, vi } from "vitest"; -import { OpenWikiLocalShellBackend } from "../src/agent/docs-only-backend.ts"; +import { OpenWikiLocalShellBackend } from "../../src/agent/docs-only-backend.ts"; import { formatBrokenLinkStamp, formatWikiLinkIssues, stampBrokenLinks, stripBrokenLinkStamps, validateWikiInternalLinks, -} from "../src/agent/wiki-link-validator.ts"; +} from "../../src/agent/wiki-link-validator.ts"; async function setupWiki( outputMode: "local-wiki" | "repository" = "repository", diff --git a/test/visualize-command.test.ts b/test/cli/visualize-command.test.ts similarity index 96% rename from test/visualize-command.test.ts rename to test/cli/visualize-command.test.ts index 000040e6..92c51689 100644 --- a/test/visualize-command.test.ts +++ b/test/cli/visualize-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "vitest"; -import { parseCommand } from "../src/cli/commands.ts"; +import { parseCommand } from "../../src/cli/commands.ts"; describe("parseCommand visualize", () => { test("defaults: openwiki dir, port 4321, opens the browser", () => { diff --git a/test/visualize-client-lib.test.ts b/test/visualize/visualize-client-lib.test.ts similarity index 98% rename from test/visualize-client-lib.test.ts rename to test/visualize/visualize-client-lib.test.ts index afde63bc..7db7a815 100644 --- a/test/visualize-client-lib.test.ts +++ b/test/visualize/visualize-client-lib.test.ts @@ -9,7 +9,7 @@ import { normalize, signature, stripFrontmatter, -} from "../src/visualize/client-lib.ts"; +} from "../../src/visualize/client-lib.ts"; describe("escapeHtml", () => { test("escapes the HTML-significant characters", () => { diff --git a/test/visualize-graph.test.ts b/test/visualize/visualize-graph.test.ts similarity index 99% rename from test/visualize-graph.test.ts rename to test/visualize/visualize-graph.test.ts index c8bcbd47..22f60746 100644 --- a/test/visualize-graph.test.ts +++ b/test/visualize/visualize-graph.test.ts @@ -6,7 +6,7 @@ import { buildGraph, firstHeading, splitFrontmatter, -} from "../src/visualize/graph.ts"; +} from "../../src/visualize/graph.ts"; const tempDirs: string[] = []; diff --git a/test/visualize-server.test.ts b/test/visualize/visualize-server.test.ts similarity index 100% rename from test/visualize-server.test.ts rename to test/visualize/visualize-server.test.ts From 4be0fc165851a8aa6366a18fa5d0a2c9ff9614b2 Mon Sep 17 00:00:00 2001 From: Colin Francis Date: Thu, 6 Aug 2026 10:50:02 -0700 Subject: [PATCH 13/13] empty